Skip to content

Commit

Permalink
Add initial prototype of Slack integration
Browse files Browse the repository at this point in the history
  • Loading branch information
Kobzol committed Jan 11, 2020
1 parent 183f4c5 commit bf06fd8
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 3 deletions.
3 changes: 3 additions & 0 deletions backend/cw_backend/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


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

Expand Down
27 changes: 27 additions & 0 deletions backend/cw_backend/views/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand All @@ -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),
Expand Down

0 comments on commit bf06fd8

Please sign in to comment.