Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rate limited #6

Merged
merged 6 commits into from
Oct 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,18 @@ A channel will be archived by this script is it doesn't meet any of the followin
## What Happens When A Channel Is Archived By This Script

- *Don't panic! It can be unarchived by following [these instructions](https://slack.com/intl/en-ca/help/articles/201563847#unarchive-a-channel) However all previous members would be kicked out of the channel and not be automatically invited back.
- A message will be dropped into the channel saying the channel is being auto archived because of low activity
- You can always whitelist a channel if it indeed needs to be kept despite meeting the auto-archive criteria.

## Custom Archive Messages
## I don't trust the DRY_RUN option to not mess up my Slack org or cause confusion amongst my users.

Just before a channel is archived, a message will be sent with information about the archive process. The default message is:

This channel has had no activity for %s days. It is being auto-archived. If you feel this is a mistake you can <https://get.slack.help/hc/en-us/articles/201563847-Archive-a-channel#unarchive-a-channel|unarchive this channel> to bring it back at any point.'

To provide a custom message, simply edit `templates.json`.
Create a new Slack org to test this script against. Use the create_test_channels.py script to quickly create channels in your new Slack org. Edit create_test_channels.py to change the number of channels to create.

## Known Issues

- Since slack doesn't have a batch API, we have to hit the api a couple times for each channel. This makes the performance of this script slow. If you have thousands of channels (which some people do), get some coffee and be patient.

- Channels that aren't archivable, such as #general, are reported as being archived. This can be ignored.

## Docker

- First build the docker image (in the root of the project)
Expand Down
45 changes: 45 additions & 0 deletions create_test_channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env python
"""
Super quick way to create empty channels in a Slack org.
Intended purpose is to try slack-autoarchive in a test org without
worrying about messing up your current channels or inflicting confusion
to your users.

There's a decent chance slack-autoarchive is broken given the last time
the slack-archive was updated or when Slack updated their product.
"""

import json
import os
import requests
import time
from dotenv import load_dotenv
from slack_autoarchive import ChannelReaper

load_dotenv()
cr = ChannelReaper()
number_channels = 50
channel_name_prefix = 'an-interesting-channel'

if __name__ == '__main__':
if os.environ.get('BOT_SLACK_TOKEN', False) == False:
print('Need to set BOT_SLACK_TOKEN before running this program.\n\n' \
'Either set it in .env or run this script as:\n' \
'BOT_SLACK_TOKEN=<secret token> python create_test_channels.py')
exit(1)

for x in range(number_channels):
channel_name = f'{channel_name_prefix}-{x}'
payload = {'name': channel_name}
print(f'Creating channel: {channel_name}')
resp = cr.slack_api_http('conversations.create', payload, 'POST')

if resp['ok']:
payload = {'channel': resp['channel']['id']}
resp_leave = cr.slack_api_http('conversations.leave', payload, 'POST')

if not resp_leave['ok']:
print(f'Error removing the bot from #{channel_name}: '\
f'{resp_leave.json()["error"]}')
else:
print(resp)
91 changes: 43 additions & 48 deletions slack_autoarchive.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,48 +44,37 @@ def get_whitelist_keywords(self):
keywords = keywords + whitelist_keywords.split(',')
return list(keywords)

# pylint: disable=too-many-arguments
def slack_api_http(
self,
api_endpoint=None,
payload=None,
method='GET',
# pylint: disable=unused-argument
retry=True,
retry_delay=0):
def slack_api_http(self, api_endpoint=None, payload=None, method='GET'):
""" Helper function to query the slack api and handle errors and rate limit. """
# pylint: disable=no-member
uri = 'https://slack.com/api/' + api_endpoint
header = {'Authorization': 'Bearer ' + self.settings.get('bot_slack_token')}
uri = f'https://slack.com/api/{api_endpoint}'
header = {'Authorization': f'Bearer {self.settings.get("bot_slack_token")}'}
try:
# Force request to take at least 1 second. Slack docs state:
# > In general we allow applications that integrate with Slack to send
# > no more than one message per second. We allow bursts over that
# > limit for short periods.
if retry_delay > 0:
time.sleep(retry_delay)

if method == 'POST':
response = requests.post(uri, headers=header, data=payload)
else:
response = requests.get(uri, headers=header, params=payload)

if response.status_code == requests.codes.ok and 'error' in response.json(
) and response.json()['error'] == 'not_authed':
self.logger.error(
'Need to setup auth. eg, BOT_SLACK_TOKEN=<secret token> python slack-autoarchive.py'
)
sys.exit(1)
elif response.status_code == requests.codes.too_many_requests:
retry_timeout = float(response.headers['Retry-After'])
# pylint: disable=too-many-function-args
return self.slack_api_http(api_endpoint, payload, method,
False, retry_timeout)
else:
return response.json()
except Exception as error_msg:
raise Exception(error_msg)
return None
except requests.exceptions.RequestException as e:
# TODO: Do something more interesting here?
raise SystemExit(e)

if response.status_code == requests.codes.too_many_requests:
timeout = int(response.headers['retry-after']) + 3
self.logger.info(
f'rate-limited: Trying again in {timeout} seconds.'
)
time.sleep(timeout)
return self.slack_api_http(api_endpoint, payload, method)

if response.status_code == requests.codes.ok and \
response.json().get('error', False) == 'not_authed':
self.logger.error(
f'Need to setup auth. eg, BOT_SLACK_TOKEN=<secret token> ' \
f'python slack-autoarchive.py'
)
sys.exit(1)

return response.json()

def get_all_channels(self):
""" Get a list of all non-archived channels from slack channels.list. """
Expand Down Expand Up @@ -166,6 +155,7 @@ def is_channel_whitelisted(self, channel, white_listed_channels):
""" Return True or False depending on if a channel is exempt from being archived. """
# self.settings.get('skip_channel_str')
# if the channel purpose contains the string self.settings.get('skip_channel_str'), we'll skip it.

info_payload = {'channel': channel['id']}
channel_info = self.slack_api_http(api_endpoint='conversations.info',
payload=info_payload,
Expand All @@ -188,7 +178,6 @@ def send_channel_message(self, channel_id, message):
""" Send a message to a channel or user. """
payload = {
'channel': channel_id,
'username': 'Channel Cleaner Bot',
'text': message
}
api_endpoint = 'chat.postMessage'
Expand All @@ -199,38 +188,41 @@ def send_channel_message(self, channel_id, message):
def archive_channel(self, channel):
""" Archive a channel, and send alert to slack admins. """
api_endpoint = 'conversations.archive'
stdout_message = f'Archiving channel... #{channel["name"]}'
self.logger.info(stdout_message)

if not self.settings.get('dry_run'):
self.logger.info(f'Archiving channel #{channel["name"]}')
payload = {'channel': channel['id']}
resp = self.slack_api_http(api_endpoint=api_endpoint, \
payload=payload)
if not resp['ok']:
if not resp.get('ok'):
stdout_message = f'Error archiving #{channel["name"]}: ' \
f'{resp["error"]}'
self.logger.error(stdout_message)
else:
self.logger.info(f'THIS IS A DRY RUN. ' \
f'{channel["name"]} would have been archived.')

def join_channel(self, channel_name, channel_id):
def join_channel(self, channel):
""" Joins a channel so that the bot can read the last message. """
if not self.settings.get('dry_run'):
self.logger.info(f'Adding bot to #{channel["name"]}')
join_api_endpoint='conversations.join'
join_payload = {'channel': channel_id}
join_payload = {'channel': channel['id']}
channel_info = self.slack_api_http(api_endpoint=join_api_endpoint, \
payload=join_payload)
else:
self.logger.info(
'THIS IS A DRY RUN. BOT would have joined %s.' % channel_name)
f'THIS IS A DRY RUN. BOT would have joined {channel["name"]}')

def send_admin_report(self, channels):
""" Optionally this will message admins with which channels were archived. """
if self.settings.get('admin_channel'):
channel_names = ', '.join('#' + channel['name']
for channel in channels)
admin_msg = 'Archiving %d channels: %s' % (len(channels),
channel_names)
admin_msg = f'Archiving {len(channels)} channels: {channel_names}'

if self.settings.get('dry_run'):
admin_msg = '[DRY RUN] %s' % admin_msg
admin_msg = f'[DRY RUN] {admin_msg}'
self.send_channel_message(self.settings.get('admin_channel'),
admin_msg)

Expand All @@ -245,10 +237,13 @@ def main(self):
whitelist_keywords = self.get_whitelist_keywords()
archived_channels = []

# Add bot to all channels
self.logger.info(f'Graabing a list of all channels. ' \
f'This could take a moment depending on the number of channels.')
# Add bot to all public channels
for channel in self.get_all_channels():
if not channel['is_member']:
self.join_channel(channel['name'], channel['id'])
self.logger.info(f'Checking if the bot is in #{channel["name"]}...')
if not channel['is_member']:
self.join_channel(channel)

# Only able to archive channels that the bot is a member of
for channel in self.get_all_channels():
Expand Down