From 23385c2f01468c20e0f51a7f9cf67e5080d562e4 Mon Sep 17 00:00:00 2001 From: Seb Seager Date: Mon, 21 Dec 2020 13:48:55 -0600 Subject: [PATCH] Initial commit --- .gitignore | 1 + README.md | 5 ++ bot.py | 194 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + 4 files changed, 204 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f10862a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..9db26ee --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# slack-exporter + +A Slack app for exporting messages and file attachments from public and private channels. + +Note that Slack provides a similar service for workspace admins at [https://my.slack.com/services/export](https://my.slack.com/services/export) (where `my` can be replaced with your full workspace name to access workspace different than your default). However, it can only access public channels, while `slack-exporter` can be added to any channel. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..f628c8b --- /dev/null +++ b/bot.py @@ -0,0 +1,194 @@ +import os +import ssl +import slack +from slack.errors import SlackApiError +import requests +from dotenv import load_dotenv +from flask import Flask, request, Response +from urllib.parse import urljoin +from uuid import uuid4 +import json +from datetime import datetime + +ssl_context = ssl.create_default_context() +ssl_context.check_hostname = False +ssl_context.verify_mode = ssl.CERT_NONE + +app = Flask(__name__) +load_dotenv(os.path.join(app.root_path, '.env')) +client = slack.WebClient(token=os.environ['SLACK_BOT_TOKEN'], ssl=ssl_context) + + +# chat write interactions + +def send_to_channel(channel_id, text): + try: + client.chat_postMessage(channel=channel_id, text=text) + except SlackApiError as e: + print(e) + pass + + +def send_to_user(user_id, text): + try: + client.chat_postMessage(channel=user_id, text=text, as_user=True) + except SlackApiError as e: + print(e) + pass + + +def post_response(response_url, text): + requests.post(response_url, json={'text': text}) + + +# pagination handling + +def get_at_cursor(url, params, response_url, cursor=None): + if cursor is not None: + params['cursor'] = cursor + + r = requests.get(url, params=params) + data = r.json() + + try: + if data['ok'] is False: + post_response(response_url, "I encountered an error: %s" % data['error']) + + next_cursor = None + if 'response_metadata' in data and 'next_cursor' in data['response_metadata']: + next_cursor = data['response_metadata']['next_cursor'] + if str(next_cursor).strip() == '': + next_cursor = None + + return next_cursor, data + + except KeyError as e: + post_response(response_url, "Something went wrong: %s." % e) + return None, [] + + +def paginated_get(url, params, response_url, combine_key=None): + next_cursor = None + result = [] + while True: + next_cursor, data = get_at_cursor(url, params, response_url, cursor=next_cursor) + result.extend(data) if combine_key is None else result.extend(data[combine_key]) + if next_cursor is None: + break + + return result + + +# GET requests + +def channel_history(channel_id, response_url): + params = { + 'token': os.environ['SLACK_USER_TOKEN'], + 'channel': channel_id, + 'limit': 200 + } + + return paginated_get('https://slack.com/api/conversations.history', params, response_url, combine_key='messages') + + +def user_list(team_id, response_url): + params = { + 'token': os.environ['SLACK_USER_TOKEN'], + 'limit': 200, + 'team_id': team_id + } + + return paginated_get('https://slack.com/api/users.list', params, response_url, combine_key='members') + + +# parsing + +def user_list_to_names(user_dict): + return {x['id']: {'name': x['name'], 'real_name': x['real_name']} for x in user_dict} + + +def channel_history_to_text(msgs_dict, users): + messages = [x for x in msgs_dict['messages'] if x['type'] == 'message'] # files are also messages + body = 'Team ID: %s\nTeam Domain: %s\nChannel ID: %s\nChannel Name: %s\n\n' % \ + (msgs_dict['team_id'], msgs_dict['team_domain'], msgs_dict['channel_id'], msgs_dict['channel_name']) + body += '%s\n %s Messages\n%s\n\n' % ('=' * 16, len(messages), '=' * 16) + for msg in messages: + usr = users[msg['user']] if 'user' in msg else {'name': '', 'real_name': 'none'} + ts = datetime.fromtimestamp(round(float(msg['ts']))).strftime('%m-%d-%Y %H:%M:%S') + text = msg['text'] if msg['text'].strip() != "" else "[no message content]" + for u in users.keys(): + # if u in text: + # print(u) + text = str(text).replace('<@%s>' % u, '<@%s> (%s)' % (u, users[u]['name'])) + entry = "Message at %s\nUser: %s (%s)\n%s" % (ts, usr['name'], usr['real_name'], text) + if 'reactions' in msg: + rxns = msg['reactions'] + entry += "\nReactions: " + ', '.join('%s (%s)' % (x['name'], ', '.join( + users[u]['name'] for u in x['users'])) for x in rxns) + if 'files' in msg: + files = msg['files'] + entry += "\nFiles:\n" + '\n'.join(' - %s, %s' % (f['name'], f['url_private_download']) for f in files) + + body += entry.strip() + '\n\n%s\n\n' % ('=' * 16) + + return body + + +# Flask routes + +@app.route('/slack/export-channel', methods=['POST']) +def export_channel(): + data = request.form + + try: + team_id = data['team_id'] + team_domain = data['team_domain'] + channel_id = data['channel_id'] + channel_name = data['channel_name'] + response_url = data['response_url'] + command_args = data['text'] + except KeyError: + return Response("Sorry! I got an unexpected response from Slack (KeyError)."), 200 + + post_response(response_url, "Retrieving history for this channel...") + all_messages = { + 'team_id': team_id, + 'team_domain': team_domain, + 'channel_id': channel_id, + 'channel_name': channel_name, + 'messages': channel_history(channel_id, response_url) + } + + filename = "%s-%s-%s.json" % (team_domain, channel_id, str(uuid4().hex)[:6]) + filepath = os.path.join(app.root_path, 'exports', filename) + loc = urljoin(request.url_root, 'download/%s' % filename) + + with open(filepath, mode='w') as f: + if str(command_args).lower() == 'text': + users = user_list_to_names(user_list(team_id, response_url)) + f.write(channel_history_to_text(all_messages, users)) + else: + json.dump(all_messages, f, indent=4) + + post_response(response_url, "Done! This channel's history is available for download here (note that this link " + "is single-use): %s" % loc) + + return Response(), 200 + + +@app.route('/download/') +def download(filename, mimetype='application/json'): + path = os.path.join(app.root_path, 'exports', filename) + + def generate(): + with open(path) as f: + yield from f + os.remove(path) + + r = app.response_class(generate(), mimetype=mimetype) + r.headers.set('Content-Disposition', 'attachment', filename=filename) + return r + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a4d6ba2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests==2.24.0 +Flask==1.1.2 +python-dotenv==0.15.0 +slackclient==2.9.3