From bf06fd8596121e6e8523a0baa5f141ea058d80ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Sat, 11 Jan 2020 14:19:55 +0100 Subject: [PATCH] Add initial prototype of Slack integration --- backend/cw_backend/configuration.py | 3 + backend/cw_backend/courses/courses.py | 7 ++ backend/cw_backend/integration/slack.py | 117 ++++++++++++++++++++++++ backend/cw_backend/main.py | 24 ++++- backend/cw_backend/views/tasks.py | 27 ++++++ 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 backend/cw_backend/integration/slack.py diff --git a/backend/cw_backend/configuration.py b/backend/cw_backend/configuration.py index b1ba2a2e..c46aa30c 100644 --- a/backend/cw_backend/configuration.py +++ b/backend/cw_backend/configuration.py @@ -32,6 +32,9 @@ def __init__(self): self.fb_oauth2 = OAuth2('fb', cfg) self.google_oauth2 = OAuth2('google', cfg) self.allow_dev_login = bool(os.environ.get('ALLOW_DEV_LOGIN')) + self.slack_user_auth = cfg.get('slack_user_token') + self.slack_bot_auth = cfg.get('slack_bot_token') + self.slack_web_url = cfg.get('slack_web_url') self.mongodb = MongoDB(cfg) diff --git a/backend/cw_backend/courses/courses.py b/backend/cw_backend/courses/courses.py index b93136f5..84c28623 100644 --- a/backend/cw_backend/courses/courses.py +++ b/backend/cw_backend/courses/courses.py @@ -142,6 +142,13 @@ def get_session_by_slug(self, slug): return session raise Exception(f'Session with slug {slug!r} not found in {self}') + def get_session_by_task_id(self, task_id): + for session in self.sessions: + for task in session.task_items: + if task.id == task_id: + return session + return None + def export(self, sessions=False, tasks=False): d = { **self.data, diff --git a/backend/cw_backend/integration/slack.py b/backend/cw_backend/integration/slack.py new file mode 100644 index 00000000..79782aad --- /dev/null +++ b/backend/cw_backend/integration/slack.py @@ -0,0 +1,117 @@ +import logging +import traceback + +import aiohttp + +logger = logging.getLogger(__name__) + +SLACK_URL_BASE = 'https://slack.com/api/' + + +class Slackbot: + def __init__(self, user_token, bot_token, web_url): + self.user_token = user_token + self.bot_token = bot_token + self.user_email_to_id = {} + self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) + self.web_url = web_url if web_url.endswith('/') else f'{web_url}/' + + async def handle_event(self, event): + try: + type = event.get('type') + data = event.get('data') + + if type == 'task-solution-update': + await self.notify_task_update(data['user'], data['initiator'], + data['solved'], data['course'], + data['solution'].task_id) + elif type == 'task-solution-comment': + if data['user'].id != data['initiator'].id: + await self.notify_task_comment(data['user'], data['initiator'], + data['comment'], data['course'], + data['solution'].task_id) + except: + # exception boundary, exceptions raised here should not kill the request + logger.warning(f'Slack event handling error: {traceback.format_exc()}') + + async def notify_task_update(self, user, initiator, solved, course, task_id): + ctx = await self.get_context(user.email, course, task_id) + if not ctx: + return + user_id, session = ctx + + status = 'vyřešený! :tada:' if solved else 'nevyřešený! :thinking_face:' + link = self.get_task_link(course, session, task_id) + task_name = f'{session.slug}/{task_id}' + initiator_name = await self.format_initiator(initiator) + message = f'{initiator_name} označil/a tvůj úkol <{link}|{task_name}> jako {status}' + await self.send_user_message(user_id, message) + + async def notify_task_comment(self, user, initiator, comment, course, task_id): + ctx = await self.get_context(user.email, course, task_id) + if not ctx: + return + user_id, session = ctx + + task_name = f'{session.slug}/{task_id}' + link = self.get_task_link(course, session, task_id) + initiator_name = await self.format_initiator(initiator) + message = f'{initiator_name} přidal komentář k tvému úkolu <{link}|{task_name}>: {comment}' + await self.send_user_message(user_id, message) + + def get_task_link(self, course, session, task_id): + return f'{self.web_url}session?course={course.id}&session={session.slug}#task-{task_id}' + + async def format_initiator(self, initiator): + user_id = await self.resolve_user(initiator.email) + if user_id: + return f'<@{user_id}>' + return initiator.name + + async def get_context(self, email, course, task_id): + if not email: + return None + + user_id = await self.resolve_user(email) + if not user_id: + return None + + session = course.get_session_by_task_id(task_id) + if not session: + return None + + return (user_id, session) + + async def send_user_message(self, user_id, message): + return await self.bot_req('POST', 'chat.postMessage', json={ + 'channel': user_id, + 'text': message + }) + + async def resolve_user(self, email): + if email not in self.user_email_to_id: + self.user_email_to_id[email] = await self.fetch_user_id(email) + + return self.user_email_to_id.get(email) + + async def fetch_user_id(self, email): + async with await self.user_req('GET', 'users.lookupByEmail', params={ + 'email': email + }) as req: + if req.status == 200: + data = await req.json() + return data['user']['id'] + return None + + async def user_req(self, method, url, params=None, json=None): + return await self.slack_req(method, url, params, json, self.user_token) + + async def bot_req(self, method, url, params=None, json=None): + return await self.slack_req(method, url, params, json, self.bot_token) + + async def slack_req(self, method, url, params, json, token): + url = f'{SLACK_URL_BASE}{url}' + return await self.session.request(method, url, params=params, json=json, headers={ + 'Authorization': f'Bearer {token}', + 'Content-type': 'application/json; charset=utf-8' + }) diff --git a/backend/cw_backend/main.py b/backend/cw_backend/main.py index a48506e7..ef08f94c 100644 --- a/backend/cw_backend/main.py +++ b/backend/cw_backend/main.py @@ -1,16 +1,17 @@ +import argparse +import logging + from aiohttp import web from aiohttp_session import setup as session_setup from aiohttp_session_mongo import MongoStorage -import argparse -import logging from motor.motor_asyncio import AsyncIOMotorClient from .configuration import Configuration from .courses import load_courses +from .integration.slack import Slackbot from .model import Model from .views import all_routes - logger = logging.getLogger(__name__) log_format = '%(asctime)s %(name)-31s %(levelname)5s: %(message)s' @@ -25,6 +26,13 @@ def cw_backend_main(): web.run_app(get_app(conf), port=args.port) +def setup_event_listeners(event_listeners, conf): + if conf.slack_user_auth and conf.slack_bot_auth and conf.slack_web_url: + event_listeners.append(Slackbot(conf.slack_user_auth, conf.slack_bot_auth, conf.slack_web_url).handle_event) + else: + logger.warning("Slack tokens missing") + + async def get_app(conf): mongo_client = AsyncIOMotorClient(conf.mongodb.connection_uri) mongo_db = mongo_client[conf.mongodb.db_name] @@ -37,10 +45,20 @@ async def close_mongo(app): app.on_cleanup.append(close_mongo) + event_listeners = [] + + async def send_event(e): + for listener in event_listeners: + await listener(e) + session_setup(app, MongoStorage(mongo_db['sessions'], max_age=3600*24*90)) app['conf'] = conf app['courses'] = load_courses(conf.courses_file) app['model'] = model + app['event'] = send_event + + setup_event_listeners(event_listeners, conf) + app.add_routes(all_routes) return app diff --git a/backend/cw_backend/views/tasks.py b/backend/cw_backend/views/tasks.py index c63bf4f5..491832a0 100644 --- a/backend/cw_backend/views/tasks.py +++ b/backend/cw_backend/views/tasks.py @@ -116,6 +116,18 @@ async def mark_solution_solved(req): raise web.HTTPForbidden() await solution.set_marked_as_solved(data['solved'], user) await solution.set_last_action('coach', user) + + await req.app['event']({ + 'type': 'task-solution-update', + 'data': { + 'course': req.app['courses'].get().get_by_id(solution.course_id), + 'solved': data['solved'], + 'user': await model.users.get_by_id(solution.user_id), + 'initiator': user, + 'solution': solution + } + }) + return web.json_response({ 'task_solution': await solution.export(with_code=True), }) @@ -138,10 +150,25 @@ async def add_solution_comment(req): reply_to_comment_id=data['reply_to_comment_id'], author_user=user, body=data['body']) + + event_user = user if user.id == solution.user_id: await solution.set_last_action('student', user) elif user.can_review_course(course_id=solution.course_id): await solution.set_last_action('coach', user) + event_user = await model.users.get_by_id(solution.user_id) + + await req.app['event']({ + 'type': 'task-solution-comment', + 'data': { + 'course': req.app['courses'].get().get_by_id(solution.course_id), + 'comment': data['body'], + 'user': event_user, + 'initiator': user, + 'solution': solution + } + }) + task_comments = await model.task_solution_comments.find_by_task_solution_id(solution.id) return web.json_response({ 'task_solution': await solution.export(with_code=True),