From 4e2a323c85cf16b4a76eccb3824db4b38067187d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Fri, 17 Sep 2021 15:27:37 +0200 Subject: [PATCH] Add Slack integration --- backend/cw_backend/configuration.py | 21 +++- backend/cw_backend/courses/courses.py | 7 ++ backend/cw_backend/integration/slack.py | 127 ++++++++++++++++++++++++ backend/cw_backend/main.py | 19 ++++ backend/cw_backend/views/events.py | 23 +++++ backend/cw_backend/views/tasks.py | 22 ++++ 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 backend/cw_backend/integration/slack.py create mode 100644 backend/cw_backend/views/events.py diff --git a/backend/cw_backend/configuration.py b/backend/cw_backend/configuration.py index b1ba2a2e..d4a6015c 100644 --- a/backend/cw_backend/configuration.py +++ b/backend/cw_backend/configuration.py @@ -1,8 +1,9 @@ import logging import os from pathlib import Path +from typing import Optional + import pymongo -from secrets import token_hex from yaml import safe_load as yaml_load @@ -32,6 +33,7 @@ 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 = Slack.from_cfg(cfg) self.mongodb = MongoDB(cfg) @@ -63,5 +65,22 @@ def __init__(self, cfg): or 'courseware_dev' +class Slack: + + @staticmethod + def from_cfg(cfg) -> Optional["Slack"]: + user_auth = cfg.get('slack_user_token') + bot_auth = cfg.get('slack_bot_token') + web_url = cfg.get('slack_web_url') + if user_auth is None or bot_auth is None or web_url is None: + return None + return Slack(user_auth, bot_auth, web_url) + + def __init__(self, user_auth: str, bot_auth: str, web_url: str): + self.user_auth = user_auth + self.bot_auth = bot_auth + self.web_url = web_url + + def db_name_from_uri(mongo_uri): return pymongo.uri_parser.parse_uri(mongo_uri)['database'] diff --git a/backend/cw_backend/courses/courses.py b/backend/cw_backend/courses/courses.py index 662a52bb..80f92e40 100644 --- a/backend/cw_backend/courses/courses.py +++ b/backend/cw_backend/courses/courses.py @@ -159,6 +159,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..aa4d1535 --- /dev/null +++ b/backend/cw_backend/integration/slack.py @@ -0,0 +1,127 @@ +import logging +import traceback +from typing import Optional, Tuple + +import aiohttp + +from ..courses.courses import Course +from ..courses.session import Session +from ..model.users import User +from ..views.events import TaskSolutionCommentAddedEvent, TaskSolutionUpdatedEvent + +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: + if isinstance(event, TaskSolutionUpdatedEvent): + await self.notify_task_update(event.user, event.initiator, + event.solved, event.course, + event.solution.task_id) + elif isinstance(event, TaskSolutionCommentAddedEvent): + if event.user.id != event.initiator.id: + await self.notify_task_comment(event.user, event.initiator, + event.comment, event.course, + event.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: User, initiator: User, solved: bool, course: Course, + task_id: str): + 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: User, initiator: User, comment: str, course: Course, + task_id: str): + 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: Course, session: Session, task_id: str) -> str: + return f'{self.web_url}session?course={course.id}&session={session.slug}#task-{task_id}' + + async def format_initiator(self, initiator: User) -> str: + user_id = await self.resolve_slack_user(initiator.email) + if user_id: + return f'<@{user_id}>' + return initiator.name + + async def get_context(self, email: Optional[str], course: Course, task_id: str) -> \ + Optional[Tuple[str, Session]]: + """ + Try to find a Slack user with the given e-mail. + """ + if not email: + return None + + user_id = await self.resolve_slack_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: str, message: str): + return await self.bot_request('POST', 'chat.postMessage', json={ + 'channel': user_id, + 'text': message + }) + + async def resolve_slack_user(self, email: str) -> str: + 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: Optional[str]) -> Optional[str]: + if email is not None: + async with await self.user_request('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_request(self, method: str, url: str, params=None, json=None): + return await self.slack_request(method, url, params, json, self.user_token) + + async def bot_request(self, method: str, url: str, params=None, json=None): + return await self.slack_request(method, url, params, json, self.bot_token) + + async def slack_request(self, method: str, url: str, 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 97c2521f..c6a405f8 100644 --- a/backend/cw_backend/main.py +++ b/backend/cw_backend/main.py @@ -7,6 +7,7 @@ from .configuration import Configuration from .courses import load_courses +from .integration.slack import Slackbot from .model import Model from .views import all_routes @@ -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 is not None: + 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,21 @@ 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*365*10)) + 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/events.py b/backend/cw_backend/views/events.py new file mode 100644 index 00000000..914f269c --- /dev/null +++ b/backend/cw_backend/views/events.py @@ -0,0 +1,23 @@ +from ..courses.courses import Course +from ..model.task_solutions import TaskSolution +from ..model.users import User + + +class TaskSolutionUpdatedEvent: + def __init__(self, course: Course, solved: bool, user: User, initiator: User, + solution: TaskSolution): + self.course = course + self.solved = solved + self.user = user + self.initiator = initiator + self.solution = solution + + +class TaskSolutionCommentAddedEvent: + def __init__(self, course: Course, comment: str, user: User, initiator: User, + solution: TaskSolution): + self.course = course + self.comment = comment + self.user = user + self.initiator = initiator + self.solution = solution diff --git a/backend/cw_backend/views/tasks.py b/backend/cw_backend/views/tasks.py index c63bf4f5..2f61181b 100644 --- a/backend/cw_backend/views/tasks.py +++ b/backend/cw_backend/views/tasks.py @@ -6,6 +6,7 @@ import logging from pathlib import Path +from .events import TaskSolutionCommentAddedEvent, TaskSolutionUpdatedEvent logger = logging.getLogger(__name__) @@ -116,6 +117,15 @@ 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'](TaskSolutionUpdatedEvent( + course=req.app['courses'].get().get_by_course_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 +148,22 @@ 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'](TaskSolutionCommentAddedEvent( + course=req.app['courses'].get().get_by_course_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),