Last active
September 20, 2017 13:09
-
-
Save rcoup/2970a5370e28e1835ba7cd394af4a28c to your computer and use it in GitHub Desktop.
WIP Standup Plugin for Errbot
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Core] | |
Name = StandupFlows | |
Module = standup_flows.py | |
[Documentation] | |
Description = Standup Flows | |
[Python] | |
version = 3 | |
[Errbot] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Core] | |
name = Standup | |
module = standup | |
[Documentation] | |
description = Daily Standup Helper | |
[Python] | |
version = 3 | |
[Errbot] | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from datetime import datetime, timedelta | |
import pytz | |
from croniter import croniter | |
from errbot import BotPlugin, botcmd, arg_botcmd, botmatch, Message | |
from .utils import SlackHelperMixin | |
class Standup(SlackHelperMixin, BotPlugin): | |
""" | |
Daily Standups | |
""" | |
def activate(self): | |
super(Standup, self).activate() | |
if 'SKIP' not in self: | |
self['SKIP'] = {} | |
self.start_poller(60, self.scheduler) | |
def deactivate(self): | |
""" | |
Triggers on plugin deactivation | |
You should delete it if you're not using it to override any default behaviour | |
""" | |
super(Standup, self).deactivate() | |
def get_configuration_template(self): | |
""" | |
Defines the configuration structure this plugin supports | |
You should delete it if your plugin doesn't use any configuration like this | |
""" | |
return { | |
'STANDUPS': [ | |
{ | |
"name": "Engineering", | |
"schedule": "0 10 * * mon,tue,wed,thu,fri", | |
"timezone": "Pacific/Auckland", | |
"channel": "#engineering", | |
"users": ["rcoup"], | |
"questions": [ | |
"What did you accomplish yesterday?", | |
"What will you do today?", | |
"What obstacles are blocking your progress?", | |
], | |
"colors": [ | |
"green", #"26a65b", | |
"blue", #"3a539b", | |
"red", #"#cf000f" | |
], | |
} | |
], | |
} | |
def scheduler(self): | |
for standup in self.config['STANDUPS']: | |
tz = pytz.timezone(standup['timezone']) | |
local_time = datetime.utcnow().astimezone(tz) | |
schedule = croniter(standup['schedule'], local_time) | |
if local_time - schedule.next(datetime) < timedelta(minutes=1): | |
self.log.info("Schedule matches %s standup '%s' (%s)", standup['name'], standup['schedule'], standup['timezone']) | |
if local_time.date() in self['SKIP'].get(standup['name'], []): | |
self.log.info("Standup %s is marked to skip today", standup['name']) | |
continue | |
self.log.info("Scheduled start of standup: %s", standup['name']) | |
self._start_standup(standup) | |
else: | |
self.log.info("no match %s %s", local_time.isoformat(), schedule.cur(datetime)) | |
def check_configuration(self, configuration): | |
""" | |
Triggers when the configuration is checked, shortly before activation | |
Raise a errbot.utils.ValidationException in case of an error | |
You should delete it if you're not using it to override any default behaviour | |
""" | |
super(Standup, self).check_configuration(configuration) | |
names = set([s['name'] for s in configuration['STANDUPS']]) | |
if len(names) != len(configuration['STANDUPS']): | |
raise ValidationException("Duplicate Standup names!") | |
def get_standup(self, name): | |
for standup in self.config['STANDUPS']: | |
if standup['name'].lower() == name.lower(): | |
return standup | |
raise KeyError("Standup %s not found in configuration" % name) | |
# Passing split_args_with=None will cause arguments to be split on any kind | |
# of whitespace, just like Python's split() does | |
@arg_botcmd('dates', nargs='*', type=lambda d: datetime.strptime(d, '%Y-%m-%d').date(), help="Dates to skip (YYYY-MM-DD)") | |
@arg_botcmd('--clear', action='store_true', help="Clear all scheduled skips for Standups") | |
@arg_botcmd('name', nargs='?', default='*', help="Standup name, * for all") | |
def standup_skip(self, message, name, clear, dates): | |
""" | |
Schedule standups to be skipped. | |
""" | |
all_standups = [s['name'] for s in self.config['STANDUPS']] | |
if name == '*': | |
standups = all_standups | |
else: | |
standups = [name] | |
if name not in all_standups: | |
return "Standup '%s' not found" % name | |
skip_list = self['SKIP'] | |
if dates or clear: | |
for sname in standups: | |
if clear: | |
skip_list[sname] = [] | |
else: | |
skip_list[sname] = skip_list.get(sname, []) + dates | |
# prune past dates | |
for sname, skips in skip_list.items(): | |
try: | |
standup = self.get_standup(sname) | |
except KeyError: | |
del skip_list[sname] | |
continue | |
today = datetime.now(pytz.timezone(standup['timezone'])).date() | |
skip_list[sname] = sorted([d for d in set(skips) if d >= today]) | |
self['SKIP'] = skip_list | |
self.log.info("Full Skip list: %s", skip_list) | |
msg = [] | |
for sname in standups: | |
formatted = ["{:%a %d %b}".format(d) for d in sorted(skip_list.get(sname, []))] | |
msg.append("{}: {}".format(sname, ", ".join(formatted) or 'None')) | |
return "\n".join(msg) or "No skipped standups scheduled" | |
@botcmd | |
def standup(self, msg, name): | |
""" Manually Start a team standup """ | |
standup = self.get_standup(name) | |
self.log.info("Manually starting %s standup", name) | |
self.send(identifier=msg.frm, in_reply_to=msg, text="Kicking off the %s standup for %s :)" % (standup['name'], ', '.join(standup['users']))) | |
self._start_standup(standup) | |
def _start_standup(self, standup): | |
""" Actually start a standup flow for assigned users """ | |
name = standup['name'] | |
users = standup['users'] | |
self.log.info("Starting standup %s for %s", name, users) | |
for username in users: | |
# triggers a flow for each user | |
m = Message(frm=self.build_identifier(username)) | |
self._bot._execute_and_send('standup_user_start', | |
args=name, | |
match=None, | |
mess=m, | |
template_name=None | |
) | |
def _question(self, ctx): | |
standup = self.get_standup(ctx['standup']) | |
question_idx = len(ctx['answers']) | |
question = standup['questions'][question_idx] | |
text = "%d. %s" % (question_idx+1, question) | |
return text | |
@botcmd | |
def standup_user_start(self, msg, name): | |
self.log.info("standup-user-start: %s -- %s", name, msg.__dict__) | |
if msg.flow: | |
self.log.warning("Stopping old flow!") | |
msg.flow.stop_flow() | |
try: | |
standup = self.get_standup(name) | |
name = standup['name'] | |
except KeyError: | |
msg.ctx['done'] = True | |
return "Standup %s not found" % name | |
self.log.info("Starting standup %s for user %s", name, msg.frm) | |
msg.ctx['standup'] = name | |
msg.ctx['started_at'] = datetime.utcnow() | |
msg.ctx['done'] = False | |
msg.ctx['answers'] = [] | |
self.send(identifier=msg.frm, text="%s standup time!" % name) | |
self.send(identifier=msg.frm, text=self._question(msg.ctx)) | |
@botmatch(r'^.*$', flow_only=True) | |
def standup_user_qa(self, msg, match): | |
self.log.info("standup-user-qa: [%s] %s", match, msg.__dict__) | |
if 'standup' not in msg.ctx: | |
return | |
standup = self.get_standup(msg.ctx['standup']) | |
question_idx = len(msg.ctx['answers']) | |
if question_idx == 0 and match.string.lower() == 'skip': | |
self.log.info("Quitting via 'skip'") | |
self._summarise_public(standup, msg.frm, msg.ctx) | |
self._bot.flow_executor.stop_flow("standup_user", requestor=msg.frm) | |
return "No worries, have a great day!" | |
msg.ctx['answers'].append(match.string) | |
if len(msg.ctx['answers']) == len(standup['questions']): | |
# summary | |
self._summarise_public(standup, msg.frm, msg.ctx) | |
return "Thanks! :)" | |
else: | |
self.log.info("Next Question!") | |
return self._question(msg.ctx) | |
def _summarise_public(self, standup, person, ctx): | |
self.log.info("Summary time") | |
ctx['done'] = True | |
attachments = self._build_summary(person, ctx) | |
if self._bot.mode == 'slack': | |
m = Message(frm=self.build_identifier(standup['channel'])) | |
self._slack_send_attachments(m, attachments) | |
else: | |
for attachment in attachments: | |
card = self.build_card(attachment) | |
to = self.build_identifier(standup['channel']) | |
self.log.debug("Sending card: %s", card) | |
self.send_card(to=to, **card) | |
def _build_summary(self, person, ctx): | |
standup = self.get_standup(ctx['standup']) | |
tz = pytz.timezone(standup['timezone']) | |
local_time = ctx['started_at'].astimezone(tz) | |
attachments = [] | |
if len(ctx['answers']) != len(standup['questions']): | |
# skipped | |
attachments.append({ | |
"fallback": "{who} skipped the {standup} standup".format( | |
who=person.nick, | |
standup=standup['name'], | |
), | |
"text": "{who} skipped the {standup} for {date:%a %d %b}".format( | |
who=person.nick, | |
standup=standup['name'], | |
date=local_time, | |
), | |
"mrkdwn_in": ["text"] | |
}) | |
else: | |
for idx, question in enumerate(standup['questions']): | |
attachment = { | |
"fallback": "{who}'s {standup} standup: {question}".format( | |
who=person.nick, | |
standup=standup['name'], | |
question=question, | |
), | |
"color": standup['colors'][idx], | |
"pretext": question, | |
"text": ctx['answers'][idx], | |
"mrkdwn_in": ["text", "pretext"] | |
} | |
if idx == 0: | |
attachment["pretext"] = "*{who}'s* {standup} standup for {date:%a %d %b}\n\n{question}".format( | |
who=person.nick, | |
standup=standup['name'], | |
date=local_time, | |
question=question, | |
) | |
attachments.append(attachment) | |
return attachments |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from errbot import botflow, FlowRoot, BotFlow, FLOW_END | |
class StandupFlows(BotFlow): | |
""" Conversation flows related to Standups """ | |
# Flow [standup_user] | |
# ↪ standup_user_start | |
# ↪ standup_user_qa | |
# ↺ | |
# ↪ END | |
@botflow | |
def standup_user(self, flow: FlowRoot): | |
""" This is a flow for a Standup """ | |
s0 = flow.connect('standup_user_start', auto_trigger=True) | |
s1 = s0.connect('standup_user_qa') | |
# loop on itself | |
s2 = s1.connect(s1) | |
s2.connect(FLOW_END, predicate=lambda ctx: ctx['done']) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import json | |
from errbot.backends.base import Card, RoomOccupant | |
class SlackHelperMixin(object): | |
def build_card(self, slack_attachment, footer_field=None): | |
""" | |
Builds an Errbot card from a Slack attachment | |
Use for debugging in the Text console. | |
""" | |
card = { | |
'summary': slack_attachment.get('pretext', ''), | |
'title': slack_attachment.get('title', ''), | |
'link': slack_attachment.get('title_link', ''), | |
'body': slack_attachment.get('text', ''), | |
'color': slack_attachment.get('color', ''), | |
'fields': [(f['title'], f['value']) for f in slack_attachment.get('fields', [])], | |
} | |
if 'footer' in slack_attachment and footer_field: | |
card['fields'].append((footer_field, slack_attachment['footer'])) | |
self.log.info("Built card: %s", json.dumps(card)) | |
return card | |
def _slack_send_attachments(self, message, attachments, private=False): | |
""" Wrap up messiness of not being able to send Slack attachments easily """ | |
card_params = self.build_card(attachments[0]) | |
if private: | |
to = message.frm | |
else: | |
to = message.frm.room if isinstance(message.frm, RoomOccupant) else message.frm | |
card = Card(frm=self._bot.bot_identifier, to=to, **card_params) | |
to_humanreadable, to_channel_id = self._bot._prepare_message(card) | |
data = { | |
'channel': to_channel_id, | |
'attachments': json.dumps(attachments), | |
'link_names': '1', | |
'as_user': 'true' | |
} | |
try: | |
self.log.debug('Sending data:\n%s', data) | |
self._bot.api_call('chat.postMessage', data=data) | |
except Exception: | |
self.log.exception( | |
"An exception occurred while trying to send a card to %s.[%s]" % (to_humanreadable, card) | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
WIP Errbot standup plugin
The idea is:
skip
from a user will break out.Current Status
!standup user start Engineering
starts the "Engineering" standup flow for a specific user, collects the replies and summarises to the public channel ok.!standup Engineering
or via schedule) isn't triggering the flows properly for each team memberDesign issues
Because there's only 1x flow, means there can only be 1x active standup per user at a time. I think it will need to be refactored to dynamically create flows based on the configuration, so that for example the "Engineering" flow is a separate flow from the "Design" flow, for when a user is in both teams.
License: BSD