From c4b19062140edebb343e64445d3b24b5b7353737 Mon Sep 17 00:00:00 2001 From: rod-lin Date: Sun, 27 Jan 2019 05:01:15 +0800 Subject: [PATCH 1/8] add basic --- src/__init__.py | 2 + src/bw_api.py | 10 +++-- src/routes_bot.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 src/routes_bot.py diff --git a/src/__init__.py b/src/__init__.py index 03067f2..ecbdcbb 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -6,6 +6,7 @@ from src.routes_staff import StaffRoutes from src.routes_student import StudentRoutes from src.routes_system import SystemRoutes +from src.routes_bot import SlackBot from src.template_filters import TemplateFilters app = Flask(__name__) @@ -64,3 +65,4 @@ def root(netid): StudentRoutes(app) StaffRoutes(app) TemplateFilters(app) +SlackBot(app) diff --git a/src/bw_api.py b/src/bw_api.py index 2f431c6..e13b0a0 100644 --- a/src/bw_api.py +++ b/src/bw_api.py @@ -6,20 +6,24 @@ HEADERS = {"Authorization": "Bearer %s" % BROADWAY_API_TOKEN} -def start_grading_run(cid, aid, netid, timestamp): +def start_grading_run(cid, aid, netids, timestamp): """ Attempt to start a grading run. :param cid: the course ID. :param aid: the assignment ID within the course. - :param netid: the student's NetID. + :param netid: the student's NetID or a list of student NetIDs. :param timestamp: the UNIX timestamp for the run due date. :return: a run_id string if successful, or None otherwise. """ + + if not isinstance(netids, list): + netids = [netids] + data = { "students_env": [{ "STUDENT_ID": netid, "DUE_DATE": timestamp_to_bw_api_format(timestamp) - }] + } for netid in netids] } try: resp = requests.post(url="%s/grading_run/%s/%s" % (BROADWAY_API_URL, cid, aid), headers=HEADERS, json=data) diff --git a/src/routes_bot.py b/src/routes_bot.py new file mode 100644 index 0000000..f9d8e89 --- /dev/null +++ b/src/routes_bot.py @@ -0,0 +1,108 @@ +import os +import json + +from slackclient import SlackClient +from slackeventsapi import SlackEventAdapter + +from flask import Flask +from flask import request +from flask import jsonify + +from src import util, common, bw_api + +class SlackBot: + def __init__(self, app): + cmd_dict = {} + + slack_token = os.environ["SLACK_API_TOKEN"] + slack_secret = os.environ["SLACK_SIGNING_SECRET"] + + slack_client = SlackClient(slack_token) + slack_events_adapter = SlackEventAdapter(slack_secret) + + def get_user_email(user_id): + res = slack_client.api_call("users.info", user=user_id) + + if not res["ok"]: + raise Exception("failed to get uset email: " + res["error"]) + + return res["user"]["profile"]["email"] + + def command(name): + def proc(f): + cmd_dict[name] = f + + return proc + + def in_channel(user, msg): + return jsonify({ + "response_type": "in_channel", + "text": "<@" + user + "> " + msg + }) + + def error(user, msg): + return jsonify({ + "response_type": "ephemeral", + "text": "<@" + user + "> " + msg + }) + + # add extra commands here + @command("grade") + def cmd_grade(user, netid, args): + """ + argument: [netid_2] ... + """ + + if len(args) < 3: + return error(user, "usage: grade [netid_2] ...") + + cid = args[0] + aid = args[1] + netids = args[2:] + + # check netid is admin + if not common.verify_admin(cid, netid): + return error(user, "you don't have the privilege to do that") + + # check netids are students + for student in netids: + if not common.verify_student(cid, student): + return error(user, "netid `{}` is not a student of the course `{}`".format(student, cid)) + + # request grading run + run_id = bw_api.start_grading_run(cid, aid, netids, now_timestamp()) + + if run_id is None: + return in_channel(user, "failed to request grading run") + + return in_channel(user, "grading run requested, run_id `{}`".format(run_id)) + # return in_channel(user, "grading {} for {}:{}".format(", ".join(netids), course, assignment)) + + @app.route("/slack/cmd", methods=["POST"]) + def slack_cmd(): + cmd = request.form.get("command") + args = request.form.get("text").split() + user = request.form.get("user_id") + + ts = request.headers.get("X-Slack-Request-Timestamp") + sig = request.headers.get("X-Slack-Signature") + print("{}, {}".format(ts, sig)) + # request.data = request.get_data() + result = slack_events_adapter.server.verify_signature(ts, sig) + + email = get_user_email(user) + netid = email.split("@")[0] + + if not len(args): + return error(user, "wrong argument") + else: + cmd = args[0] + if cmd in cmd_dict: + return cmd_dict[cmd](user, netid, args[1:]) + else: + return error(user, "command `{}` does not exist".format(cmd)) + +if __name__ == "__main__": + app = Flask(__name__) + SlackBot(app) + app.run(host="0.0.0.0", port=3133) From 9e15d72233039a68b584ec1c11f9d61fbc3749ea Mon Sep 17 00:00:00 2001 From: rod-lin Date: Sun, 27 Jan 2019 06:14:45 +0800 Subject: [PATCH 2/8] add grade & status command --- src/bw_api.py | 1 + src/common.py | 8 +++ src/routes_bot.py | 140 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/bw_api.py b/src/bw_api.py index e13b0a0..8f236ad 100644 --- a/src/bw_api.py +++ b/src/bw_api.py @@ -25,6 +25,7 @@ def start_grading_run(cid, aid, netids, timestamp): "DUE_DATE": timestamp_to_bw_api_format(timestamp) } for netid in netids] } + try: resp = requests.post(url="%s/grading_run/%s/%s" % (BROADWAY_API_URL, cid, aid), headers=HEADERS, json=data) run_id = resp.json()["data"]["grading_run_id"] diff --git a/src/common.py b/src/common.py index 0ad930b..ec85ba8 100644 --- a/src/common.py +++ b/src/common.py @@ -72,3 +72,11 @@ def verify_staff(netid, cid): :return: a boolean value. """ return netid in db.get_course(cid)["staff_ids"] + + +def verify_cid(cid): + return db.get_course(cid) is not None + + +def verify_aid(cid, aid): + return db.get_assignment(cid, aid) is not None diff --git a/src/routes_bot.py b/src/routes_bot.py index f9d8e89..c68ff0c 100644 --- a/src/routes_bot.py +++ b/src/routes_bot.py @@ -7,18 +7,20 @@ from flask import Flask from flask import request from flask import jsonify +from flask import abort -from src import util, common, bw_api +from src import db, util, common, bw_api +from src import config + +import requests +import threading class SlackBot: def __init__(self, app): cmd_dict = {} - slack_token = os.environ["SLACK_API_TOKEN"] - slack_secret = os.environ["SLACK_SIGNING_SECRET"] - - slack_client = SlackClient(slack_token) - slack_events_adapter = SlackEventAdapter(slack_secret) + slack_client = SlackClient(config.SLACK_API_TOKEN) + slack_events_adapter = SlackEventAdapter(config.SLACK_SIGNING_SECRET) def get_user_email(user_id): res = slack_client.api_call("users.info", user=user_id) @@ -34,73 +36,151 @@ def proc(f): return proc - def in_channel(user, msg): + def public(user, msg): return jsonify({ "response_type": "in_channel", "text": "<@" + user + "> " + msg }) - def error(user, msg): + def private(user, msg): return jsonify({ "response_type": "ephemeral", "text": "<@" + user + "> " + msg }) + def delayed(user, msg): + response_url = request.form.get("response_url") + + obj = { + "response_type": "in_channel", + "text": "<@" + user + "> " + msg + } + + requests.post(response_url, data=obj) + + def delayed(user): + response_url = request.form.get("response_url") + + def proc(f): + def wrapper(): + res = f() + + obj = { + "response_type": "in_channel", + "text": "<@" + user + "> " + res + } + + # print("responding to "+ response_url) + + res = requests.post(response_url, data=json.dumps(obj)) + + if res.status_code != 200: + raise Exception("callback failed: " + res.text) + + thread = threading.Thread(target=wrapper, args=()) + thread.daemon = True + thread.start() + + return proc + + @command("status") + def cmd_status(user, netid, args): + """usage: status """ + + if not len(args): + return private(user, "usage: status ") + + run_id = args[0] + run = db.get_grading_run(run_id) + + if run is None: + return public(user, "grading run `{}` does not exist".format(run_id)) + + @delayed(uesr) + def cont(): + status = bw_api.get_grading_run_status(run["cid"], run["aid"], run_id) + + if status is None: + return "failed to get status for run `{}`". format(run_id) + + return "run `{}`: {}".format(run_id, status) + + return public("fetching") + # add extra commands here @command("grade") def cmd_grade(user, netid, args): - """ - argument: [netid_2] ... - """ + """usage: [netid-2] ...""" if len(args) < 3: - return error(user, "usage: grade [netid_2] ...") + return private(user, ) cid = args[0] aid = args[1] netids = args[2:] + # check course exists + if not common.verify_cid(cid): + return private(user, "course `{}` does not exist".format(cid)) + + # check assignment exists + if not common.verify_aid(cid, aid): + return private(user, "assignment `{}` does not exist in courses `{}`".format(aid, cid)) + # check netid is admin - if not common.verify_admin(cid, netid): - return error(user, "you don't have the privilege to do that") + if not common.verify_admin(netid, cid): + return private(user, "you don't have the privilege to do that") # check netids are students for student in netids: - if not common.verify_student(cid, student): - return error(user, "netid `{}` is not a student of the course `{}`".format(student, cid)) + if not common.verify_student(student, cid): + return private(user, "netid `{}` is not a student of the course `{}`".format(student, cid)) + + @delayed(user) + def cont(): + # request grading run + ts = util.now_timestamp() - # request grading run - run_id = bw_api.start_grading_run(cid, aid, netids, now_timestamp()) + try: + run_id = bw_api.start_grading_run(cid, aid, netids, ts) + except: + run_id = None - if run_id is None: - return in_channel(user, "failed to request grading run") + if run_id is None: + return "failed to request grading run" + else: + # run created + for student in netids: + db.add_grading_run(cid, aid, student, ts, run_id) + + return "grading run requested, run_id `{}`".format(run_id) - return in_channel(user, "grading run requested, run_id `{}`".format(run_id)) - # return in_channel(user, "grading {} for {}:{}".format(", ".join(netids), course, assignment)) + return public(user, "processing") @app.route("/slack/cmd", methods=["POST"]) def slack_cmd(): + ts = request.headers.get("X-Slack-Request-Timestamp") + sig = request.headers.get("X-Slack-Signature") + signed = slack_events_adapter.server.verify_signature(ts, sig) + + if not signed: + return abort(403) + cmd = request.form.get("command") args = request.form.get("text").split() user = request.form.get("user_id") - ts = request.headers.get("X-Slack-Request-Timestamp") - sig = request.headers.get("X-Slack-Signature") - print("{}, {}".format(ts, sig)) - # request.data = request.get_data() - result = slack_events_adapter.server.verify_signature(ts, sig) - email = get_user_email(user) netid = email.split("@")[0] if not len(args): - return error(user, "wrong argument") + return private(user, "wrong argument") else: cmd = args[0] if cmd in cmd_dict: return cmd_dict[cmd](user, netid, args[1:]) else: - return error(user, "command `{}` does not exist".format(cmd)) + return private(user, "command `{}` does not exist".format(cmd)) if __name__ == "__main__": app = Flask(__name__) From d17fedab62a054c1bfa4c095a65b199580352a98 Mon Sep 17 00:00:00 2001 From: rod-lin Date: Sun, 27 Jan 2019 07:24:04 +0800 Subject: [PATCH 3/8] finish basic utils --- src/routes_bot.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/routes_bot.py b/src/routes_bot.py index c68ff0c..51ce73a 100644 --- a/src/routes_bot.py +++ b/src/routes_bot.py @@ -96,24 +96,24 @@ def cmd_status(user, netid, args): if run is None: return public(user, "grading run `{}` does not exist".format(run_id)) - @delayed(uesr) + @delayed(user) def cont(): - status = bw_api.get_grading_run_status(run["cid"], run["aid"], run_id) + status = bw_api.get_grading_run_status(run["course_id"], run["assignment_id"], run_id) if status is None: return "failed to get status for run `{}`". format(run_id) return "run `{}`: {}".format(run_id, status) - return public("fetching") + return public(user, "fetching") # add extra commands here @command("grade") def cmd_grade(user, netid, args): - """usage: [netid-2] ...""" + """usage: grade [netid-2] ...""" if len(args) < 3: - return private(user, ) + return private(user, "usage: grade [netid-2] ...") cid = args[0] aid = args[1] @@ -125,7 +125,7 @@ def cmd_grade(user, netid, args): # check assignment exists if not common.verify_aid(cid, aid): - return private(user, "assignment `{}` does not exist in courses `{}`".format(aid, cid)) + return private(user, "assignment `{}` does not exist in course `{}`".format(aid, cid)) # check netid is admin if not common.verify_admin(netid, cid): @@ -181,8 +181,3 @@ def slack_cmd(): return cmd_dict[cmd](user, netid, args[1:]) else: return private(user, "command `{}` does not exist".format(cmd)) - -if __name__ == "__main__": - app = Flask(__name__) - SlackBot(app) - app.run(host="0.0.0.0", port=3133) From 9e8e4b78b37c0330c4388d6da3d963fcb02849f9 Mon Sep 17 00:00:00 2001 From: rod-lin Date: Sun, 27 Jan 2019 07:57:10 +0800 Subject: [PATCH 4/8] update requirements.txt --- src/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/requirements.txt b/src/requirements.txt index 8885e60..e1801d9 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -14,3 +14,5 @@ pytz==2018.9 requests==2.21.0 urllib3==1.24.1 Werkzeug==0.14.1 +slackclient +slackeventsapi From 36de814146db268619b8795bd11f0f7cb996828c Mon Sep 17 00:00:00 2001 From: rod-lin Date: Mon, 28 Jan 2019 02:31:00 +0800 Subject: [PATCH 5/8] refactor slack bot --- src/__init__.py | 4 +- src/routes_bot.py | 265 ++++++++++++++++++++++++++++------------------ 2 files changed, 164 insertions(+), 105 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index ecbdcbb..62885ae 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -6,7 +6,7 @@ from src.routes_staff import StaffRoutes from src.routes_student import StudentRoutes from src.routes_system import SystemRoutes -from src.routes_bot import SlackBot +from src.routes_bot import BroadwayBot from src.template_filters import TemplateFilters app = Flask(__name__) @@ -65,4 +65,4 @@ def root(netid): StudentRoutes(app) StaffRoutes(app) TemplateFilters(app) -SlackBot(app) +BroadwayBot(app) diff --git a/src/routes_bot.py b/src/routes_bot.py index 51ce73a..1aa2952 100644 --- a/src/routes_bot.py +++ b/src/routes_bot.py @@ -1,6 +1,9 @@ import os import json +from random import choice +from functools import wraps + from slackclient import SlackClient from slackeventsapi import SlackEventAdapter @@ -15,128 +18,204 @@ import requests import threading -class SlackBot: - def __init__(self, app): - cmd_dict = {} +class Command: + """Wrapper class for a command""" + + def __init__(self, bot, form): + self.cmd = form.get("command") + self.args = form.get("text").split() + self.user = form.get("user_id") + + self.email = bot.get_user_email(self.user) + self.netid = self.email.split("@")[0] + self.response_url = request.form.get("response_url") + + def public(self, msg, attach=[]): + """ + Visible to everyone in the channel + """ + + return { + "response_type": "in_channel", + "text": "<@" + self.user + "> " + msg, + "attachments": attach + } + + def private(self, msg, attach=[]): + """ + Only visible to the command sender + """ + + return { + "response_type": "ephemeral", + "text": "<@" + self.user + "> " + msg, + "attachments": attach + } + + def delayed(self, f): + """ + Delayed command handler + Used for response after 3000ms + """ + + def wrapper(): + res = requests.post(self.response_url, data=json.dumps(f())) + + if res.status_code != 200: + raise Exception("Callback failed: " + res.text) + + thread = threading.Thread(target=wrapper) + thread.daemon = True + thread.start() + + return + +class SlackSigner: + """ + Check signatures of slack requests + """ + + def __init__(self, secret): + self.adapter = SlackEventAdapter(secret) + + # decorator + def check_sig(self, f): + @wraps(f) + def wrapper(): + ts = request.headers.get("X-Slack-Request-Timestamp") + sig = request.headers.get("X-Slack-Signature") + signed = self.adapter.server.verify_signature(ts, sig) + + if not signed: + return abort(403) + else: + return f() - slack_client = SlackClient(config.SLACK_API_TOKEN) - slack_events_adapter = SlackEventAdapter(config.SLACK_SIGNING_SECRET) + return wrapper - def get_user_email(user_id): - res = slack_client.api_call("users.info", user=user_id) +class SlackBot(SlackClient): + """ + Basic slack bot class + Handles incoming commands and dispatch of command handlers + """ - if not res["ok"]: - raise Exception("failed to get uset email: " + res["error"]) + colors = [ "#1abc9c", "#2ecc71", "#3498db", "#9b59b6", "#f1c40f", "#e67e22", "#e74c3c" ] - return res["user"]["profile"]["email"] + def get_user_email(self, user_id): + res = self.api_call("users.info", user=user_id) - def command(name): - def proc(f): - cmd_dict[name] = f + if not res["ok"]: + raise Exception("Failed to get uset email: " + res["error"]) - return proc + return res["user"]["profile"]["email"] - def public(user, msg): - return jsonify({ - "response_type": "in_channel", - "text": "<@" + user + "> " + msg - }) + def command(self, name, help_msg): + def decorator(f): + @wraps(f) + def wrapper(cmd): + try: + # try match function parameters + return f(self, cmd, *cmd.args[1:]) + except TypeError: + return cmd.private("Wrong argument", attach = [ + { + "color": choice(SlackBot.colors), + "text": "{}\nUsage: `{} {}`".format(help_msg, cmd.cmd, f.__doc__) + } + ]) # return usage + + self.cmd_dict[name] = (wrapper, help_msg) - def private(user, msg): - return jsonify({ - "response_type": "ephemeral", - "text": "<@" + user + "> " + msg - }) + return f # preseve docstring - def delayed(user, msg): - response_url = request.form.get("response_url") - - obj = { - "response_type": "in_channel", - "text": "<@" + user + "> " + msg - } + return decorator - requests.post(response_url, data=obj) + def print_help(self, cmd): + ret = [] # prepend an empty line - def delayed(user): - response_url = request.form.get("response_url") + for _, (f, msg) in self.cmd_dict.items(): + ret.append("{}\n`{} {}`".format(msg, cmd.cmd, f.__doc__)) - def proc(f): - def wrapper(): - res = f() + return cmd.public("Some help?", attach=[ + { + "color": choice(SlackBot.colors), + "text": cont + } for cont in ret + ]) - obj = { - "response_type": "in_channel", - "text": "<@" + user + "> " + res - } + def __init__(self, app): + self.cmd_dict = {} - # print("responding to "+ response_url) + SlackClient.__init__(self, config.SLACK_API_TOKEN) - res = requests.post(response_url, data=json.dumps(obj)) + signer = SlackSigner(config.SLACK_SIGNING_SECRET) - if res.status_code != 200: - raise Exception("callback failed: " + res.text) + @app.route("/slack/cmd", methods=["POST"]) + @signer.check_sig + def slack_cmd(): + """Parse and dispatch command handlers""" - thread = threading.Thread(target=wrapper, args=()) - thread.daemon = True - thread.start() + cmd = Command(self, request.form) - return proc + if not len(cmd.args): + return jsonify(self.print_help(cmd)) + else: + if cmd.args[0] in self.cmd_dict: + return jsonify(self.cmd_dict[cmd.args[0]][0](cmd)) + else: + return jsonify(cmd.private("Command `{}` does not exist".format(cmd.args[0]))) - @command("status") - def cmd_status(user, netid, args): - """usage: status """ +class BroadwayBot(SlackBot): + """ + Class that implements all broadway related commands + """ - if not len(args): - return private(user, "usage: status ") + def __init__(self, app): + super().__init__(app) + + @self.command("status", "Check status of a grading run") + def cmd_status(bot, cmd, run_id): + """status """ - run_id = args[0] run = db.get_grading_run(run_id) if run is None: - return public(user, "grading run `{}` does not exist".format(run_id)) + return cmd.public("Grading run `{}` does not exist".format(run_id)) - @delayed(user) + @cmd.delayed def cont(): status = bw_api.get_grading_run_status(run["course_id"], run["assignment_id"], run_id) if status is None: - return "failed to get status for run `{}`". format(run_id) + return cmd.public("Failed to get status for run `{}`". format(run_id)) - return "run `{}`: {}".format(run_id, status) + return cmd.public("Run `{}`: {}".format(run_id, status)) - return public(user, "fetching") + return cmd.public("Fetching") # add extra commands here - @command("grade") - def cmd_grade(user, netid, args): - """usage: grade [netid-2] ...""" - - if len(args) < 3: - return private(user, "usage: grade [netid-2] ...") - - cid = args[0] - aid = args[1] - netids = args[2:] + @self.command("grade", "Request grading run") + def cmd_grade(bot, cmd, cid, aid, *netids): + """grade [netid-2] ...""" # check course exists if not common.verify_cid(cid): - return private(user, "course `{}` does not exist".format(cid)) + return cmd.public("Course `{}` does not exist".format(cid)) # check assignment exists if not common.verify_aid(cid, aid): - return private(user, "assignment `{}` does not exist in course `{}`".format(aid, cid)) + return cmd.public("Assignment `{}` does not exist in course `{}`".format(aid, cid)) # check netid is admin - if not common.verify_admin(netid, cid): - return private(user, "you don't have the privilege to do that") + if not common.verify_admin(cmd.netid, cid): + return cmd.public("You don't have the privilege to do that") # check netids are students for student in netids: if not common.verify_student(student, cid): - return private(user, "netid `{}` is not a student of the course `{}`".format(student, cid)) + return cmd.public("NetID `{}` is not a student of the course `{}`".format(student, cid)) - @delayed(user) + @cmd.delayed def cont(): # request grading run ts = util.now_timestamp() @@ -147,37 +226,17 @@ def cont(): run_id = None if run_id is None: - return "failed to request grading run" + return cmd.public("Failed to request grading run") else: # run created for student in netids: db.add_grading_run(cid, aid, student, ts, run_id) - return "grading run requested, run_id `{}`".format(run_id) - - return public(user, "processing") + return cmd.public("Grading run requested, run_id `{}`".format(run_id)) - @app.route("/slack/cmd", methods=["POST"]) - def slack_cmd(): - ts = request.headers.get("X-Slack-Request-Timestamp") - sig = request.headers.get("X-Slack-Signature") - signed = slack_events_adapter.server.verify_signature(ts, sig) + return cmd.public("Processing") - if not signed: - return abort(403) - - cmd = request.form.get("command") - args = request.form.get("text").split() - user = request.form.get("user_id") - - email = get_user_email(user) - netid = email.split("@")[0] - - if not len(args): - return private(user, "wrong argument") - else: - cmd = args[0] - if cmd in cmd_dict: - return cmd_dict[cmd](user, netid, args[1:]) - else: - return private(user, "command `{}` does not exist".format(cmd)) + @self.command("help", "Print help message") + def cmd_help(bot, cmd): + """help""" + return bot.print_help(cmd) From f1e88d7c26ab2f550944b4befcc10154517081af Mon Sep 17 00:00:00 2001 From: rod-lin Date: Mon, 28 Jan 2019 03:51:17 +0800 Subject: [PATCH 6/8] add list command --- src/common.py | 4 +- src/db.py | 5 ++ src/routes_bot.py | 126 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 108 insertions(+), 27 deletions(-) diff --git a/src/common.py b/src/common.py index ec85ba8..157c5b0 100644 --- a/src/common.py +++ b/src/common.py @@ -74,9 +74,9 @@ def verify_staff(netid, cid): return netid in db.get_course(cid)["staff_ids"] -def verify_cid(cid): +def verify_course(cid): return db.get_course(cid) is not None -def verify_aid(cid, aid): +def verify_assignment(cid, aid): return db.get_assignment(cid, aid) is not None diff --git a/src/db.py b/src/db.py index fbdb778..699a660 100644 --- a/src/db.py +++ b/src/db.py @@ -45,6 +45,11 @@ def get_courses_for_staff(netid): return list(courses) +def get_all_courses(): + courses = mongo.db.courses.find({}) + return list(courses) + + def get_course(cid): return mongo.db.courses.find_one({"_id": cid}) diff --git a/src/routes_bot.py b/src/routes_bot.py index 1aa2952..ad2da28 100644 --- a/src/routes_bot.py +++ b/src/routes_bot.py @@ -30,26 +30,28 @@ def __init__(self, bot, form): self.netid = self.email.split("@")[0] self.response_url = request.form.get("response_url") - def public(self, msg, attach=[]): + def public(self, msg, *attach_l, attach=[]): """ Visible to everyone in the channel """ return { "response_type": "in_channel", - "text": "<@" + self.user + "> " + msg, - "attachments": attach + "text": + "<@" + self.user + "> " + msg if msg is not None else None, + "attachments": list(attach_l) + attach } - def private(self, msg, attach=[]): + def private(self, msg, *attach_l, attach=[]): """ Only visible to the command sender """ return { "response_type": "ephemeral", - "text": "<@" + self.user + "> " + msg, - "attachments": attach + "text": + "<@" + self.user + "> " + msg if msg is not None else None, + "attachments": list(attach_l) + attach } def delayed(self, f): @@ -93,14 +95,41 @@ def wrapper(): return wrapper +class AI: + """just passed the Turing test""" + + colors = [ "#1abc9c", "#2ecc71", "#3498db", "#9b59b6", "#f1c40f", "#e67e22", "#e74c3c" ] + + positive_prompts = [ "Here you go" ] + negative_prompts = [ "Uh-oh" ] + + @staticmethod + def color(): + return choice(AI.colors) + + @staticmethod + def positive(): + return choice(AI.positive_prompts) + + @staticmethod + def negative(): + return choice(AI.negative_prompts) + +class Attachment: + @staticmethod + def random_color(msg): + return { + "color": AI.color(), + "text": msg, + "mrkdwn_in": ["text"] + } + class SlackBot(SlackClient): """ Basic slack bot class Handles incoming commands and dispatch of command handlers """ - colors = [ "#1abc9c", "#2ecc71", "#3498db", "#9b59b6", "#f1c40f", "#e67e22", "#e74c3c" ] - def get_user_email(self, user_id): res = self.api_call("users.info", user=user_id) @@ -117,11 +146,8 @@ def wrapper(cmd): # try match function parameters return f(self, cmd, *cmd.args[1:]) except TypeError: - return cmd.private("Wrong argument", attach = [ - { - "color": choice(SlackBot.colors), - "text": "{}\nUsage: `{} {}`".format(help_msg, cmd.cmd, f.__doc__) - } + return cmd.private("Wrong argument", attach=[ + Attachment.random_color("{}\nUsage: `{} {}`".format(help_msg, cmd.cmd, f.__doc__)) ]) # return usage self.cmd_dict[name] = (wrapper, help_msg) @@ -137,10 +163,7 @@ def print_help(self, cmd): ret.append("{}\n`{} {}`".format(msg, cmd.cmd, f.__doc__)) return cmd.public("Some help?", attach=[ - { - "color": choice(SlackBot.colors), - "text": cont - } for cont in ret + Attachment.random_color(cont) for cont in ret ]) def __init__(self, app): @@ -173,6 +196,59 @@ class BroadwayBot(SlackBot): def __init__(self, app): super().__init__(app) + @self.command("list", "List courses/assignments") + def cmd_list(bot, cmd, *courses): + """list [course-1] [course-2] ...""" + + if len(courses): + # list assignments in courses + + prompt_list = [] + + for course in courses: + if not common.verify_course(course): + return cmd.public(AI.negative(), attach=[ + Attachment.random_color("Course `{}` does not exist".format(course)) + ]) + + assigns = db.get_assignments_for_course(course) + + if assigns is not None: + if len(assigns): + ids = [ "`" + assign["assignment_id"] + "`" for assign in assigns ] + + prompt_list.append(Attachment.random_color( + "Course `{}` has the following assignment(s)\n{}" \ + .format(course, ", ".join(ids)) + )) + else: + prompt_list.append(Attachment.random_color( + "Course `{}` has no assignment" + )) + else: + prompt_list.append(Attachment.random_color( + "Course `{}` does not exist" + )) + + return cmd.public(AI.positive(), attach=prompt_list) + + else: + # assuming admin_ids is a subset of staff_ids + courses = db.get_all_courses() + course_names = [ course["_id"] for course in courses ] + + if len(courses): + return cmd.public(AI.positive(), attach=[ + Attachment.random_color( + "You have access to the following course(s)\n{}" \ + .format(", ".join([ "`" + name + "`" for name in course_names ])) + ) + ]) + else: + return cmd.public(AI.negative(), attach=[ + Attachment.random_color("There is no course") + ]) + @self.command("status", "Check status of a grading run") def cmd_status(bot, cmd, run_id): """status """ @@ -187,11 +263,11 @@ def cont(): status = bw_api.get_grading_run_status(run["course_id"], run["assignment_id"], run_id) if status is None: - return cmd.public("Failed to get status for run `{}`". format(run_id)) + return cmd.public(None, Attachment.random_color("Failed to get status for run `{}`". format(run_id))) - return cmd.public("Run `{}`: {}".format(run_id, status)) + return cmd.public(None, Attachment.random_color("Run `{}`: {}".format(run_id, status))) - return cmd.public("Fetching") + return cmd.public("Just a sec") # add extra commands here @self.command("grade", "Request grading run") @@ -199,11 +275,11 @@ def cmd_grade(bot, cmd, cid, aid, *netids): """grade [netid-2] ...""" # check course exists - if not common.verify_cid(cid): + if not common.verify_course(cid): return cmd.public("Course `{}` does not exist".format(cid)) # check assignment exists - if not common.verify_aid(cid, aid): + if not common.verify_assignment(cid, aid): return cmd.public("Assignment `{}` does not exist in course `{}`".format(aid, cid)) # check netid is admin @@ -226,15 +302,15 @@ def cont(): run_id = None if run_id is None: - return cmd.public("Failed to request grading run") + return cmd.public(None, Attachment.random_color("Failed to request grading run")) else: # run created for student in netids: db.add_grading_run(cid, aid, student, ts, run_id) - return cmd.public("Grading run requested, run_id `{}`".format(run_id)) + return cmd.public(None, Attachment.random_color("Grading run requested, run_id `{}`".format(run_id))) - return cmd.public("Processing") + return cmd.public("Requesting") @self.command("help", "Print help message") def cmd_help(bot, cmd): From 14f928475e0cb3e18dff9a5809c15b2578380e27 Mon Sep 17 00:00:00 2001 From: rod-lin Date: Tue, 29 Jan 2019 07:58:45 +0800 Subject: [PATCH 7/8] add status check action --- src/routes_bot.py | 102 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/src/routes_bot.py b/src/routes_bot.py index ad2da28..8459f63 100644 --- a/src/routes_bot.py +++ b/src/routes_bot.py @@ -1,5 +1,6 @@ import os import json +import re from random import choice from functools import wraps @@ -18,17 +19,15 @@ import requests import threading -class Command: - """Wrapper class for a command""" - - def __init__(self, bot, form): - self.cmd = form.get("command") - self.args = form.get("text").split() - self.user = form.get("user_id") +RUN_ID_PATTERN = r"[0-9a-f]{24}" +class SlackRequest: + def __init__(self, bot, user, form): + self.user = user self.email = bot.get_user_email(self.user) self.netid = self.email.split("@")[0] - self.response_url = request.form.get("response_url") + + self.response_url = form.get("response_url") def public(self, msg, *attach_l, attach=[]): """ @@ -49,8 +48,7 @@ def private(self, msg, *attach_l, attach=[]): return { "response_type": "ephemeral", - "text": - "<@" + self.user + "> " + msg if msg is not None else None, + "text": msg if msg is not None else None, "attachments": list(attach_l) + attach } @@ -72,6 +70,38 @@ def wrapper(): return +class Command(SlackRequest): + """Wrapper class for a command""" + + def __init__(self, bot, form): + self.cmd = form.get("command") + self.args = form.get("text").split() + + SlackRequest.__init__(self, bot, form.get("user_id"), form) + +class Action(SlackRequest): + """wrapper class for an action""" + + def __init__(self, bot, form): + self.callback_id = form.get("callback_id") + self.message = form.get("message") + + SlackRequest.__init__(self, bot, form["user"]["id"], form) + + def private(self, *args, **kargs): + obj = super().private(*args, **kargs) + res = requests.post(self.response_url, data=json.dumps(obj)) + + if res.status_code != 200: + raise Exception("Callback failed: " + res.text) + + def public(self, *args, **kargs): + obj = super().public(*args, **kargs) + res = requests.post(self.response_url, data=json.dumps(obj)) + + if res.status_code != 200: + raise Exception("Callback failed: " + res.text) + class SlackSigner: """ Check signatures of slack requests @@ -156,6 +186,13 @@ def wrapper(cmd): return decorator + def action(self, name): + def decorator(f): + self.action_dict[name] = f + return f # preseve docstring + + return decorator + def print_help(self, cmd): ret = [] # prepend an empty line @@ -168,6 +205,7 @@ def print_help(self, cmd): def __init__(self, app): self.cmd_dict = {} + self.action_dict = {} SlackClient.__init__(self, config.SLACK_API_TOKEN) @@ -188,6 +226,20 @@ def slack_cmd(): else: return jsonify(cmd.private("Command `{}` does not exist".format(cmd.args[0]))) + @app.route("/slack/action", methods=["POST"]) + @signer.check_sig + def slack_action(): + """Parse and dispatch action handlers""" + + action = Action(self, json.loads(request.form.get("payload"))) + + if action.callback_id in self.action_dict: + self.action_dict[action.callback_id](self, action) + return "" + else: + action.private("Action `{}` does not exist".format(action.callback_id)) + return "" + class BroadwayBot(SlackBot): """ Class that implements all broadway related commands @@ -196,6 +248,36 @@ class BroadwayBot(SlackBot): def __init__(self, app): super().__init__(app) + @self.action("run_status") + def action_run_status(bot, action): + text = action.message.get("text") + attachments = action.message.get("attachments", []) + + text += "\n".join([ attach.get("text", "") for attach in attachments ]) + + result = re.search(RUN_ID_PATTERN, text) + + if result is None: + action.private("This message contains no run id") + return + + run_id = result.group(0) + + run = db.get_grading_run(run_id) + + if run is None: + action.private("Grading run `{}` does not exist".format(run_id)) + return + + status = bw_api.get_grading_run_status(run["course_id"], run["assignment_id"], run_id) + + if status is None: + action.private(None, Attachment.random_color("Failed to get status for run `{}`". format(run_id))) + return + + action.private(None, Attachment.random_color("Run `{}`: {}".format(run_id, status))) + return + @self.command("list", "List courses/assignments") def cmd_list(bot, cmd, *courses): """list [course-1] [course-2] ...""" From d3ea9d05ccd76b906c15507a38d13c631aef29db Mon Sep 17 00:00:00 2001 From: rod-lin Date: Tue, 29 Jan 2019 09:46:23 +0800 Subject: [PATCH 8/8] update attachment color --- src/routes_bot.py | 50 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/routes_bot.py b/src/routes_bot.py index 8459f63..836c247 100644 --- a/src/routes_bot.py +++ b/src/routes_bot.py @@ -147,13 +147,33 @@ def negative(): class Attachment: @staticmethod - def random_color(msg): + def color(msg, color=None): return { - "color": AI.color(), + "color": color, "text": msg, "mrkdwn_in": ["text"] } + @staticmethod + def random_color(msg): + return Attachment.color(msg, color=AI.color()) + + @staticmethod + def info(msg): + return Attachment.color(msg, color="#ecf0f1") + + @staticmethod + def warning(msg): + return Attachment.color(msg, color="#f39c12") + + @staticmethod + def error(msg): + return Attachment.color(msg, color="#e74c3c") + + @staticmethod + def success(msg): + return Attachment.color(msg, color="#2ecc71") + class SlackBot(SlackClient): """ Basic slack bot class @@ -177,7 +197,7 @@ def wrapper(cmd): return f(self, cmd, *cmd.args[1:]) except TypeError: return cmd.private("Wrong argument", attach=[ - Attachment.random_color("{}\nUsage: `{} {}`".format(help_msg, cmd.cmd, f.__doc__)) + Attachment.warning("{}\nUsage: `{} {}`".format(help_msg, cmd.cmd, f.__doc__)) ]) # return usage self.cmd_dict[name] = (wrapper, help_msg) @@ -199,8 +219,8 @@ def print_help(self, cmd): for _, (f, msg) in self.cmd_dict.items(): ret.append("{}\n`{} {}`".format(msg, cmd.cmd, f.__doc__)) - return cmd.public("Some help?", attach=[ - Attachment.random_color(cont) for cont in ret + return cmd.private("Some help?", attach=[ + Attachment.info(cont) for cont in ret ]) def __init__(self, app): @@ -272,10 +292,10 @@ def action_run_status(bot, action): status = bw_api.get_grading_run_status(run["course_id"], run["assignment_id"], run_id) if status is None: - action.private(None, Attachment.random_color("Failed to get status for run `{}`". format(run_id))) + action.private(None, Attachment.error("Failed to get status for run `{}`". format(run_id))) return - action.private(None, Attachment.random_color("Run `{}`: {}".format(run_id, status))) + action.private(None, Attachment.success("Run `{}`: {}".format(run_id, status))) return @self.command("list", "List courses/assignments") @@ -290,7 +310,7 @@ def cmd_list(bot, cmd, *courses): for course in courses: if not common.verify_course(course): return cmd.public(AI.negative(), attach=[ - Attachment.random_color("Course `{}` does not exist".format(course)) + Attachment.warning("Course `{}` does not exist".format(course)) ]) assigns = db.get_assignments_for_course(course) @@ -308,7 +328,7 @@ def cmd_list(bot, cmd, *courses): "Course `{}` has no assignment" )) else: - prompt_list.append(Attachment.random_color( + prompt_list.append(Attachment.warning( "Course `{}` does not exist" )) @@ -321,14 +341,14 @@ def cmd_list(bot, cmd, *courses): if len(courses): return cmd.public(AI.positive(), attach=[ - Attachment.random_color( + Attachment.info( "You have access to the following course(s)\n{}" \ .format(", ".join([ "`" + name + "`" for name in course_names ])) ) ]) else: return cmd.public(AI.negative(), attach=[ - Attachment.random_color("There is no course") + Attachment.warning("There is no course") ]) @self.command("status", "Check status of a grading run") @@ -345,9 +365,9 @@ def cont(): status = bw_api.get_grading_run_status(run["course_id"], run["assignment_id"], run_id) if status is None: - return cmd.public(None, Attachment.random_color("Failed to get status for run `{}`". format(run_id))) + return cmd.public(None, Attachment.error("Failed to get status for run `{}`". format(run_id))) - return cmd.public(None, Attachment.random_color("Run `{}`: {}".format(run_id, status))) + return cmd.public(None, Attachment.success("Run `{}`: {}".format(run_id, status))) return cmd.public("Just a sec") @@ -384,13 +404,13 @@ def cont(): run_id = None if run_id is None: - return cmd.public(None, Attachment.random_color("Failed to request grading run")) + return cmd.public(None, Attachment.error("Failed to request grading run")) else: # run created for student in netids: db.add_grading_run(cid, aid, student, ts, run_id) - return cmd.public(None, Attachment.random_color("Grading run requested, run_id `{}`".format(run_id))) + return cmd.public(None, Attachment.success("Grading run requested, run_id `{}`".format(run_id))) return cmd.public("Requesting")