Skip to content

Instantly share code, notes, and snippets.

@rcoup
Last active September 20, 2017 13:09
Show Gist options
  • Save rcoup/2970a5370e28e1835ba7cd394af4a28c to your computer and use it in GitHub Desktop.
Save rcoup/2970a5370e28e1835ba7cd394af4a28c to your computer and use it in GitHub Desktop.
WIP Standup Plugin for Errbot
[Core]
Name = StandupFlows
Module = standup_flows.py
[Documentation]
Description = Standup Flows
[Python]
version = 3
[Errbot]
[Core]
name = Standup
module = standup
[Documentation]
description = Daily Standup Helper
[Python]
version = 3
[Errbot]
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)
# print
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
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'])
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)
)
@rcoup
Copy link
Author

rcoup commented May 22, 2017

WIP Errbot standup plugin

The idea is:

  • schedule a Standup in a timezone via a cron-style entry (eg. Mon-Fri 10am Sydney time)
  • you can skip specific standups via admin command (eg. for holidays)
  • at the time, it DMs everyone in the team and asks the questions, collecting the answers
  • skip from a user will break out.
  • if a user didn't finish the previous standup, a new one will reset it
  • each user's replies are summarised and posted to a public channel

Current Status

  1. I've been testing in Text mode only, but it's designed for Slack
  2. !standup user start Engineering starts the "Engineering" standup flow for a specific user, collects the replies and summarises to the public channel ok.
  3. triggering a standup for a team (!standup Engineering or via schedule) isn't triggering the flows properly for each team member
  4. Scheduling works
  5. Skipping a standup or having a new one start-over needs more testing

Design 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment