diff --git a/Dockerfile b/Dockerfile index 4efb8cb..48c0416 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:2.7 +FROM python:3.9 WORKDIR /destalinator ADD requirements.txt . RUN pip install -r requirements.txt diff --git a/README.md b/README.md index 1e47339..f32b1c7 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,9 @@ All configs in `configuration.yaml` are overrideable through environment variabl 1. Make sure [the Slackbot app](https://slack.com/apps/A0F81R8ET-slackbot) is installed for your Slack 2. Add a Slackbot integration, and copy the `token` parameter from the URL provided -#### `DESTALINATOR_API_TOKEN` (Required) +#### `DESTALINATOR_API_BOT_TOKEN` and `DESTALINATOR_API_USER_TOKEN`(Required) -The best way to get an `API_TOKEN` is to [create a new Slack App](https://api.slack.com/apps/new). +The best way to get the `API BOT_TOKEN` and `API_USER_TOKEN` is to [create a new Slack App](https://api.slack.com/apps/new). Once you create and name your app on your team, go to "OAuth & Permissions" to give it the following permission scopes: @@ -102,7 +102,7 @@ Once you create and name your app on your team, go to "OAuth & Permissions" to g - `emoji:read` - `users:read` -After saving, you can copy the OAuth Access Token value from the top of the same screen. It probably starts with `xox`. +After saving, you can copy the OAuth Access Tokens value from the top of the same screen. It probably starts with `xox`. #### `DESTALINATOR_ACTIVATED` (Required) diff --git a/destalinator.py b/destalinator.py index 2a029d4..f48665d 100755 --- a/destalinator.py +++ b/destalinator.py @@ -129,6 +129,7 @@ def stale(self, channel_name, days): # the message is not from an ignored user x.get("user") not in self.config.ignore_users \ and x.get("username") not in self.config.ignore_users \ + and x.get("bot_profile", {}).get("name") not in self.config.ignore_users \ and ( # the message must have text that doesn't include ignored words (x.get("text") and b":dolphin:" not in x.get("text").encode('utf-8', 'ignore')) \ @@ -244,7 +245,7 @@ def warn_in_general(self, stale_channels): being = "is" there = "it" message = "Hey, heads up -- the following {} {} stale and will be " - message += "archived if no one participates in {} over the next 30 days: " + message += "archived if no one participates in {} over the next days: " message += ", ".join(["#" + x for x in stale_channels]) message = message.format(channel, being, there) if self.config.activated: diff --git a/executor.py b/executor.py index a05b638..b933f45 100755 --- a/executor.py +++ b/executor.py @@ -17,7 +17,7 @@ def __init__(self, slackbot_injected=None, slacker_injected=None): self.logger.debug("activated is %s", self.config.activated) - self.slacker = slacker_injected or slacker.Slacker(self.config.slack_name, token=self.config.api_token) + self.slacker = slacker_injected or slacker.Slacker(self.config.slack_name, bot_token=self.config.api_bot_token, user_token=self.config.api_user_token) self.ds = destalinator.Destalinator(slacker=self.slacker, slackbot=self.slackbot, diff --git a/slacker.py b/slacker.py index 049cb4c..c9d9237 100755 --- a/slacker.py +++ b/slacker.py @@ -12,22 +12,29 @@ class Slacker(WithLogger, WithConfig): - def __init__(self, slack_name, token, init=True): + def __init__(self, slack_name, bot_token, user_token, init=True): """ slack name is the short name of the slack (preceding '.slack.com') - token should be a Slack API Token. + bot_token should be a Slack API Bot Token. + user_token should be a Slack API User Token. """ self.slack_name = slack_name - self.token = token - assert self.token, "Token should not be blank" self.url = self.api_url() - self.session = requests.Session() + + assert bot_token, "Bot Token should not be blank" + self.bot_session = requests.Session() + self.bot_session.headers.update({"Authorization": "Bearer " + bot_token}) + + assert user_token, "User Token should not be blank" + self.user_session = requests.Session() + self.user_session.headers.update({"Authorization": "Bearer " + user_token}) + if init: self.get_users() self.get_channels() def get_emojis(self): - url = self.url + "emoji.list?token={}".format(self.token) + url = self.url + "emoji.list" return self.get_with_retry_to_json(url) def get_users(self): @@ -56,7 +63,7 @@ def get_with_retry_to_json(self, url): max_retry_attempts = 10 payload = None while not payload: - response = self.session.get(url) + response = self.user_session.get(url) try: response.raise_for_status() @@ -82,7 +89,7 @@ def get_messages_in_time_range(self, oldest, cid, latest=None): messages = [] done = False while not done: - murl = self.url + "conversations.history?oldest={}&token={}&channel={}".format(oldest, self.token, cid) + murl = self.url + "conversations.history?oldest={}&channel={}".format(oldest, cid) if latest: murl += "&latest={}".format(latest) else: @@ -183,8 +190,8 @@ def get_channel_members_ids(self, channel_name): if not member_count: return members # should be an empty set - url_template = self.url + "conversations.members?token={}&channel={}" - url = url_template.format(self.token, cid) + url_template = self.url + "conversations.members?channel={}" + url = url_template.format(cid) while True: ret = self.get_with_retry_to_json(url) @@ -198,8 +205,8 @@ def get_channel_members_ids(self, channel_name): # once through the loop once, update the url to call to include the cursor if ret['response_metadata']['next_cursor']: - url_template = self.url + "conversations.members?token={}&channel={}&cursor={}" - url = url_template.format(self.token, cid, ret['response_metadata']['next_cursor']) + url_template = self.url + "conversations.members?channel={}&cursor={}" + url = url_template.format(cid, ret['response_metadata']['next_cursor']) # no more members to iterate over else: break @@ -231,10 +238,10 @@ def get_channel_info(self, channel_name): returns JSON with channel information. Adds 'age' in seconds to JSON """ # ensure include_num_members is available for get_channel_member_count() - url_template = self.url + "conversations.info?token={}&channel={}&include_num_members=true" + url_template = self.url + "conversations.info?channel={}&include_num_members=true" cid = self.get_channelid(channel_name) now = int(time.time()) - url = url_template.format(self.token, cid) + url = url_template.format(cid) ret = self.get_with_retry_to_json(url) if ret['ok'] is not True: m = "Attempted get_channel_info() for {}, but return was {}" @@ -259,8 +266,8 @@ def get_all_channel_objects(self, exclude_archived=True): else: exclude_archived = 0 - url_template = self.url + "conversations.list?exclude_archived={}&token={}" - url = url_template.format(exclude_archived, self.token) + url_template = self.url + "conversations.list?exclude_archived={}" + url = url_template.format(exclude_archived) while True: ret = self.get_with_retry_to_json(url) @@ -274,8 +281,8 @@ def get_all_channel_objects(self, exclude_archived=True): # after going through the loop once, update the url to call to # include the pagination cursor if ret['response_metadata']['next_cursor']: - url_template = self.url + "conversations.list?exclude_archived={}&token={}&cursor={}" - url = url_template.format(exclude_archived, self.token, ret['response_metadata']['next_cursor']) + url_template = self.url + "conversations.list?exclude_archived={}&cursor={}" + url = url_template.format(exclude_archived, ret['response_metadata']['next_cursor']) # no more channels to iterate over else: @@ -284,7 +291,7 @@ def get_all_channel_objects(self, exclude_archived=True): return channels def get_all_user_objects(self): - url = self.url + "users.list?token=" + self.token + url = self.url + "users.list" response = self.get_with_retry_to_json(url) try: return response['members'] @@ -293,10 +300,10 @@ def get_all_user_objects(self): raise e def archive(self, channel_name): - url_template = self.url + "conversations.archive?token={}&channel={}" + url_template = self.url + "conversations.archive?channel={}" cid = self.get_channelid(channel_name) - url = url_template.format(self.token, cid) - request = self.session.post(url) + url = url_template.format(cid) + request = self.user_session.post(url) payload = request.json() return payload @@ -312,7 +319,6 @@ def post_message(self, channel, message, message_type=None): channel = channel[1:] post_data = { - 'token': self.token, 'channel': channel, 'text': message.encode('utf-8') } @@ -320,14 +326,13 @@ def post_message(self, channel, message, message_type=None): bot_name = self.config.bot_name bot_avatar_url = self.config.bot_avatar_url if bot_name or bot_avatar_url: - post_data['as_user'] = False if bot_name: post_data['username'] = bot_name if bot_avatar_url: post_data['icon_url'] = bot_avatar_url if message_type: - post_data['attachments'] = json.dumps([{'fallback': message_type}], encoding='utf-8') + post_data['attachments'] = json.dumps([{'fallback': message_type}]) - p = self.session.post(self.url + "chat.postMessage", data=post_data) + p = self.bot_session.post(self.url + "chat.postMessage", data=post_data) return p.json() diff --git a/tests/fixtures.py b/tests/fixtures.py index 966a90d..2af7590 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,4 +1,5 @@ import time +from config import get_config channels = [ { @@ -39,7 +40,7 @@ { 'id': 'C0133272', 'creator': 'U012742', - 'name': 'zmeta-new-channels', + 'name': get_config().announce_channel, 'purpose': {'value': "New channel annoucements."}, 'created': int(time.time()) - 86400 * 90 } diff --git a/tests/mocks.py b/tests/mocks.py index ff1553f..9b224f1 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -12,7 +12,7 @@ def mocked_slackbot_object(): def mocked_slacker_object(channels_list=None, users_list=None, messages_list=None, emoji_list=None): - slacker_obj = slacker.Slacker(get_config().slack_name, token='token', init=False) + slacker_obj = slacker.Slacker(get_config().slack_name, bot_token='bot_token', user_token='user_token', init=False) slacker_obj.get_all_channel_objects = mock.MagicMock(return_value=channels_list or []) slacker_obj.get_channels() diff --git a/tests/test_destalinator.py b/tests/test_destalinator.py index 5188040..fea58d4 100644 --- a/tests/test_destalinator.py +++ b/tests/test_destalinator.py @@ -110,7 +110,7 @@ def get_channels(self): class DestalinatorChannelMarkupTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -156,7 +156,7 @@ def test_add_slack_channel_markup_ignore_screaming(self, mock_slacker): class DestalinatorChannelMinimumAgeTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -184,7 +184,7 @@ def test_channel_is_young(self, mock_slacker): class DestalinatorGetEarliestArchiveDateTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") # TODO: This test (and others) would be redundant with solid testing around config directly. @@ -210,7 +210,7 @@ def test_falls_back_to_past_date(self): class DestalinatorGetMessagesTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -245,7 +245,7 @@ def test_with_limited_included_subtypes(self, mock_slacker): class DestalinatorIgnoreChannelTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch.object(get_config(), 'ignore_channels', ['stalinists']) @@ -288,7 +288,7 @@ def test_with_empty_ignore_channel_config(self): class DestalinatorPostMarkedUpMessageTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") def test_with_a_string_having_a_channel(self): @@ -319,7 +319,7 @@ def test_with_a_string_having_no_channels(self): class DestalinatorStaleTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -379,7 +379,7 @@ def test_with_only_an_attachment_message(self, mock_slacker): class DestalinatorArchiveTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch.object(get_config(), 'ignore_channels', ['stalinists']) @@ -442,7 +442,7 @@ def test_handles_a_bad_archive_api_response(self, mock_slacker): class DestalinatorSafeArchiveTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -477,7 +477,7 @@ def test_calls_archive_method(self, mock_slacker): class DestalinatorSafeArchiveAllTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -534,7 +534,7 @@ def fake_stale(channel, days): class DestalinatorWarnTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock') @@ -603,7 +603,7 @@ def test_does_not_warn_when_previous_warning_with_changed_text_found(self, mock_ class DestalinatorWarnAllTestCase(unittest.TestCase): def setUp(self): - self.slacker = SlackerMock("testing", "token") + self.slacker = SlackerMock("testing", "bot_token", "user_token") self.slackbot = slackbot.Slackbot("testing", "token") @mock.patch('tests.test_destalinator.SlackerMock')