Skip to content

Commit

Permalink
Add Slack integration
Browse files Browse the repository at this point in the history
  • Loading branch information
Kobzol committed Oct 21, 2021
1 parent 024eb46 commit 4e2a323
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 1 deletion.
21 changes: 20 additions & 1 deletion backend/cw_backend/configuration.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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']
7 changes: 7 additions & 0 deletions backend/cw_backend/courses/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions backend/cw_backend/integration/slack.py
Original file line number Diff line number Diff line change
@@ -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'
})
19 changes: 19 additions & 0 deletions backend/cw_backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]
Expand All @@ -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

Expand Down
23 changes: 23 additions & 0 deletions backend/cw_backend/views/events.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions backend/cw_backend/views/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
from pathlib import Path

from .events import TaskSolutionCommentAddedEvent, TaskSolutionUpdatedEvent

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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),
})
Expand All @@ -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),
Expand Down

0 comments on commit 4e2a323

Please sign in to comment.