Semi-stable rework of signoff procedure

This commit is contained in:
Jacob Henry 2018-11-12 12:30:10 -05:00
parent 5ae139fde0
commit 716ec52a98
10 changed files with 657 additions and 456 deletions

115
client_wrapper.py Normal file
View File

@ -0,0 +1,115 @@
import asyncio
from typing import List, Any, AsyncGenerator
from slackclient import SlackClient # Obvious
import channel_util
import slack_util
from dummy import FakeClient
# Read the API token
api_file = open("apitoken.txt", 'r')
SLACK_API = next(api_file).strip()
api_file.close()
# Enable to use dummy
DEBUG_MODE = False
class ClientWrapper(object):
"""
Essentially the main state object.
We only ever expect one of these.
Holds a slack client, and handles messsages.
"""
def __init__(self):
# Init slack
if DEBUG_MODE:
self.slack = FakeClient()
else:
self.slack = SlackClient(SLACK_API)
# Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[slack_util.AbsHook] = []
# Periodicals are just wrappers around an iterable, basically
self.passives: List[slack_util.Passive] = []
# Scheduled events handling
def add_passive(self, per: slack_util.Passive) -> None:
self.passives.append(per)
async def run_passives(self) -> None:
# Make a task to repeatedly spawn each event
awaitables = [p.run(self.slack) for p in self.passives]
await asyncio.gather(*awaitables)
# Message handling
def add_hook(self, hook: slack_util.AbsHook) -> None:
self.hooks.append(hook)
async def respond_messages(self) -> None:
"""
Asynchronous tasks that eternally reads and responds to messages.
"""
async for _ in self.spool_tasks():
print("Handling a message...!")
async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]:
async for msg in self.async_message_feed():
# Preprocess msg
# We only care about standard messages, not subtypes, as those usually just channel activity
if msg.get("subtype") is not None:
continue
# Never deal with general, EVER!
if msg.get("channel") == channel_util.GENERAL:
continue
# Strip garbage
msg['text'] = msg['text'].strip()
print("Recv: \"{}\"".format(msg['text']))
# Msg is good
# Find which hook, if any, satisfies
for hook in self.hooks:
# Try invoking each
try:
# Try to make a coroutine handling the message
coro = hook.try_apply(self.slack, msg)
# If we get a coro back, then task it up and set consumption appropriately
if coro is not None:
print("Spawned task")
yield asyncio.create_task(coro)
if hook.consumes:
break
except slack_util.DeadHook:
# If a hook wants to die, let it.
self.hooks.remove(hook)
print("Done spawning tasks")
async def async_message_feed(self) -> AsyncGenerator[dict, None]:
"""
Async wrapper around the message feed.
Yields messages awaitably forever.
"""
# Create the msg feed
feed = slack_util.message_stream(self.slack)
# Create a simple callable that gets one message from the feed
def get_one():
return next(feed)
# Continuously yield async threaded tasks that poll the feed
while True:
yield await asyncio.get_running_loop().run_in_executor(None, get_one)
_singleton = ClientWrapper()
def get_client_wrapper() -> ClientWrapper:
return _singleton

270
house_management.py Normal file
View File

@ -0,0 +1,270 @@
import dataclasses
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Tuple, List, Optional, Any
import google_api
import scroll_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
# Note: These ranges use named range feature of google sheets.
# To edit range of jobs, edit the named range in Data -> Named Ranges
job_range = "AllJobs" # Note that the first row is headers
point_range = "PointRange"
# How tolerant of spelling errors in names to be
SHEET_LOOKUP_THRESHOLD = 0.9
JOB_VAL = 1
LATE_VAL = 0.5
MISS_VAL = -1
SIGNOFF_VAL = 0.1
# What to put for a non-signed-off job
SIGNOFF_PLACEHOLDER = "E-SIGNOFF"
@dataclass
class Job(object):
"""
Represents a job in a more internally meaningful way.
"""
name: str
house: str
day_of_week: str
# Extra stuff, interpreted
day: Optional[date]
@dataclass
class JobAssignment(object):
"""
Tracks a job's assignment and completion
"""
job: Job
assignee: scroll_util.Brother
signer: Optional[scroll_util.Brother]
late: bool
def to_raw(self) -> Tuple[str, str, str, str, str, str]:
# Converts this back into a spreadsheet row
signer_name = self.signer.name if self.signer is not None else SIGNOFF_PLACEHOLDER
late = "y" if self.late else "n"
return self.job.name, self.job.house, self.job.day_of_week, self.assignee.name, signer_name, late
@dataclass
class PointStatus(object):
"""
Tracks a brothers points
"""
brother_raw: str
brother: scroll_util.Brother # Interpereted
job_points: float = 0
signoff_points: float = 0
towel_points: float = 0
work_party_points: float = 0
bonus_points: float = 0
def to_raw(self) -> Tuple[str, str, str, str, str, str]:
# Convert to a row. Also, do some rounding while we're at it
fstring = "{:.2f}"
return (self.brother_raw,
fstring.format(self.job_points),
fstring.format(self.signoff_points),
fstring.format(self.towel_points),
fstring.format(self.work_party_points),
fstring.format(self.bonus_points),
)
def strip_all(l: List[str]) -> List[str]:
return [x.strip() for x in l]
def import_assignments() -> List[Optional[JobAssignment]]:
"""
Imports Jobs and JobAssignments from the sheet. 1:1 row correspondence.
"""
# Get the raw data
job_rows = google_api.get_sheet_range(SHEET_ID, job_range)
# None-out invalid rows (length not at least 4, which includes the 4 most important features)
def fixer(row):
if len(row) == 4:
return strip_all(row + [SIGNOFF_PLACEHOLDER, "n"])
elif len(row) == 5:
return strip_all(row + "n")
elif len(row) == 6:
return strip_all(row)
else:
return None
# Apply the fix
job_rows = [fixer(row) for row in job_rows]
# Now, create jobs
assignments = []
for row in job_rows:
if row is None:
assignments.append(None)
else:
# Breakout list
job_name, location, day, assignee, signer, late = row
# Figure out when the day actually is, in terms of the date class
day_rank = {
"monday": 0,
"tuesday": 1,
"wednesday": 2,
"thursday": 3,
"friday": 4,
"saturday": 5,
"sunday": 6
}.get(day.lower(), None)
if day_rank is not None:
# Figure out current date day of week, and extrapolate the jobs day of week from there
today = date.today()
today_rank = today.weekday()
days_till = day_rank - today_rank
if days_till <= 0:
days_till += 7
# Now we know what day it is!
job_day = today + timedelta(days=days_till)
else:
# Can't win 'em all
job_day = None
# Create the job
job = Job(name=job_name, house=location, day_of_week=day, day=job_day)
# Now make an assignment for the job
# Find the brother it is assigned to
try:
assignee = scroll_util.find_by_name(assignee, SHEET_LOOKUP_THRESHOLD)
except scroll_util.BadName:
# If we can't get one close enough, make a dummy
assignee = scroll_util.Brother(assignee, scroll_util.MISSINGBRO_SCROLL)
# Find the brother who is currently listed as having signed it off
try:
if signer == SIGNOFF_PLACEHOLDER:
signer = None
else:
signer = scroll_util.find_by_name(signer)
except scroll_util.BadName:
# If we can't figure out the name
signer = None
# Make late a bool
late = late == "y"
# Create the assignment
assignment = JobAssignment(job=job, assignee=assignee, signer=signer, late=late)
# Append to job/assignment lists
assignments.append(assignment)
# Git 'em gone
return assignments
def export_assignments(assigns: List[Optional[JobAssignment]]) -> None:
# Smash to rows
rows = []
for v in assigns:
if v is None:
rows.append([""] * 6)
else:
rows.append(list(v.to_raw()))
# Send to google
google_api.set_sheet_range(SHEET_ID, job_range, rows)
def import_points() -> (List[str], List[PointStatus]):
# Figure out how many things there are in a point status
field_count = len(dataclasses.fields(PointStatus))
# Get the raw data
point_rows = google_api.get_sheet_range(SHEET_ID, point_range)
# Get the headers
headers = point_rows[0]
point_rows = point_rows[1:]
# Tidy rows up
def converter(row: List[Any]) -> Optional[PointStatus]:
# If its too long, or empty already, ignore
if len(row) == 0 or len(row) > field_count:
return None
# Ensure its the proper length
row: List[Any] = row + ([0] * (field_count - len(row) - 1))
# Ensure all past the first column are float. If can't convert, make 0
for i in range(1, len(row)):
try:
x = float(row[i])
except ValueError:
x = 0
row[i] = x
# Get the brother for the last item
real_brother = scroll_util.find_by_name(row[0], recent_only=True)
# Ok! Now, we just map it directly to a PointStatus
status = PointStatus(row[0], real_brother, *(row[1:]))
return status
# Perform conversion and return
point_statuses = [converter(row) for row in point_rows]
return headers, point_statuses
def export_points(headers: List[str], points: List[PointStatus]) -> None:
# Smash to rows
rows = [list(point_status.to_raw()) for point_status in points]
rows = [headers] + rows
# Send to google
google_api.set_sheet_range(SHEET_ID, point_range, rows)
def apply_house_points(points: List[PointStatus], assigns: List[Optional[JobAssignment]]):
"""
Modifies the points list to reflect job assignment scores.
Destroys existing values in the column
"""
# First, eliminate all house points
for p in points:
p.job_points = 0
# Then, apply each assign
for a in assigns:
# Ignore null assigns
if a is None:
continue
# What modifier should this have?
if a.signer is None:
score = MISS_VAL
else:
if a.late:
score = LATE_VAL
else:
score = JOB_VAL
# Find the corr bro in points
for p in points:
# If we find, add the score and stop looking
if p.brother == a.assignee:
p.job_points += score
break
if p.brother == a.signer:
p.signoff_points += SIGNOFF_VAL

203
job_commands.py Normal file
View File

@ -0,0 +1,203 @@
from typing import List, Match, Callable, TypeVar, Optional, Iterable
from fuzzywuzzy import fuzz
from slackclient import SlackClient
import channel_util
import house_management
import identifier
import scroll_util
import slack_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
MIN_RATIO = 0.9
def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -> None:
"""
DM a brother saying something
"""
# We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts)
for slack_id in identifier.lookup_brother_userids(brother):
dm_id = slack_util.im_channel_for_id(slack, slack_id)
if dm_id:
# Give a dummy msg dict, since we won't actually be using anything in it
slack_util.send_message(slack, saywhat, dm_id)
else:
print("Warning: unable to find dm for brother {}".format(brother))
T = TypeVar("T")
def tiemax(items: Iterable[T], key: Callable[[T], float], min_score: Optional[float] = None) -> List[T]:
best = []
best_score = min_score
for elt in items:
score = key(elt)
if best_score is None or score > best_score:
best_score = score
best = [elt]
elif score == best_score:
best.append(elt)
return best
def do_signoff(slack: SlackClient, msg: dict, on_assign_index: int, by_brother: scroll_util.Brother) -> None:
# First things first: Get the signoffs
assignments = house_management.import_assignments()
# Get the one we want
on_assign = assignments[on_assign_index]
# Modify it
on_assign.signer = by_brother
# Put them all back
house_management.export_assignments(assignments)
# Then we update points for house jobs
headers, points = house_management.import_points()
house_management.apply_house_points(points, assignments)
house_management.export_points(headers, points)
# Then we respond cool!
slack_util.reply(slack, msg, "{} signed off {} for {} {} {}".format(on_assign.signer.name,
on_assign.assignee.name,
on_assign.job.house,
on_assign.job.day_of_week,
on_assign.job.name))
alert_user(slack, on_assign.assignee, "Your house job was signed off")
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Callback to signoff a user.
"""
# Find out who this is
signee_name = match.group(1)
# Fix with a quick lookup
try:
signee = scroll_util.find_by_name(signee_name, MIN_RATIO, recent_only=True)
except scroll_util.BadName as e:
slack_util.reply(slack, msg, e.as_response())
return
# Also, who just signed us off?
signer = identifier.lookup_msg_brother(msg)
# Get all of the assignments
assigns = house_management.import_assignments()
# Find closest assignment to what we're after
def scorer(a: Optional[house_management.JobAssignment]) -> float:
if a is None:
return 0
else:
return fuzz.ratio(signee.name, a.assignee.name)
closest_assigns = tiemax(assigns, key=scorer)
# Remove those that are already signed off
closest_assigns = [c for c in closest_assigns if c is not None]
closest_assigns = [c for c in closest_assigns if c.signer is None]
# If there aren't any jobs, say so
if len(closest_assigns) == 0:
slack_util.reply(slack, msg, "Unable to find any jobs assigned to brother {} "
"(identified as {}).".format(signee_name, signee.name))
return
# If theres only one job, sign it off
elif len(closest_assigns) == 1:
targ_assign = closest_assigns[0]
# Where is it?
targ_assign_index = assigns.index(targ_assign)
do_signoff(slack, msg, targ_assign_index, signer)
return
# If theres multiple jobs, we need to get a follow up!
else:
slack_util.reply(slack, msg, "Dunno how to handle multiple jobs yet")
return
# noinspection PyUnusedLocal
async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Resets the scores.
"""
# Get curr rows
headers, points = house_management.import_points()
# Set to 0/default
for i in range(len(points)):
new = house_management.PointStatus(brother_raw=points[i].brother_raw, brother=points[i].brother)
points[i] = new
house_management.export_points(headers, points)
# Now unsign everything
assigns = house_management.import_assignments()
for a in assigns:
if a is not None:
a.signer = None
house_management.export_assignments(assigns)
slack_util.reply(slack, msg, "Reset scores and signoffs")
async def nag_callback(slack, msg, match):
# Get the day
day = match.group(1).lower().strip()
# Get the assigns
assigns = house_management.import_assignments()
# Filter to day
assigns = [assign for assign in assigns if assign.job.day_of_week.lower() == day]
# Filter signed off
assigns = [assign for assign in assigns if assign.signer is None]
# If no jobs found, somethings up. Probably mispelled day.
if not assigns:
slack_util.reply(slack, msg, "No jobs found. Check that the day is spelled correctly, with no extra symbols.\n"
"It is possible that all jobs have been signed off, as well.",
in_thread=True)
return
# Nag each
response = "Do yer jerbs! They are as follows:\n"
for assign in assigns:
# Make the row template
response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name)
# Find the people to @
brother_slack_ids = identifier.lookup_brother_userids(assign.assignee)
if brother_slack_ids:
for slack_id in brother_slack_ids:
response += "<@{}> ".format(slack_id)
else:
response += "(scroll missing. Please register for @ pings!)"
response += "\n"
slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.BOTZONE)
signoff_hook = slack_util.Hook(signoff_callback,
pattern=r"testsignoff\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
reset_hook = slack_util.Hook(reset_callback,
pattern=r"testreset signoffs",
channel_whitelist=[channel_util.COMMAND_CENTER_ID]) # COMMAND_CENTER_ID
nag_hook = slack_util.Hook(nag_callback,
pattern=r"nagjobs\s*(.*)",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])

View File

@ -1,86 +0,0 @@
import identifier
import scroll_util
import slack_util
import google_api
import channel_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
# Note: These ranges use named range feature of google sheets.
# To edit range of jobs, edit the named range in Data -> Named Ranges
eight_job_range = "EightJobs" # Format: Job Day Bro
fiftythree_job_range = "FiftyThreeJobs"
class Job(object):
"""
Object representing a house job
"""
def __init__(self, house: str, job_name: str, day: str, brother_name: str):
self.house, self.job_name, self.day, self.brother_name = house, job_name, day, brother_name
self.day = self.day.lower().strip()
def get_jobs(day=None):
"""
Retrieves the house jobs for a given day.
If no day is provided, returns all house jobs for the week.
:param day: Day to compare the day column to. If equal, keep. If Not, discard. If day==None, ignore this filter
:return: list of Job objects
"""
# Get the spreadsheet section
eight_jobs = google_api.get_sheet_range(SHEET_ID, eight_job_range)
ft_jobs = google_api.get_sheet_range(SHEET_ID, fiftythree_job_range)
# Turn to job objects
def valid_row(x):
try:
return len(x) == 3
except (AttributeError, TypeError):
return False
eight_jobs = [Job("8", *r) for r in eight_jobs if valid_row(r)]
ft_jobs = [Job("53", *r) for r in ft_jobs if valid_row(r)]
jobs = eight_jobs + ft_jobs
# Filter to day
if day:
jobs = [j for j in jobs if j.day == day]
return jobs
async def nag_callback(slack, msg, match):
# Get the day
day = match.group(1).lower().strip()
jobs = get_jobs(day)
# If no jobs found, somethings up. Probably mispelled day.
if not jobs:
slack_util.reply(slack, msg, "No jobs found. Check that the day is spelled correctly, with no extra symbols",
in_thread=True)
return
# Nag each
response = "Do yer jerbs! They are as follows:\n"
for job in jobs:
# Make the row template
response += "({}) {} -- ".format(job.house, job.job_name)
# Find the people to @
brother = scroll_util.find_by_name(job.brother_name)
brother_slack_ids = identifier.lookup_brother_userids(brother)
if brother_slack_ids:
for slack_id in brother_slack_ids:
response += "<@{}> ".format(slack_id)
else:
response += "{} (scroll missing. Please register for @ pings!)".format(job.brother_name)
response += "\n"
slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.GENERAL)
nag_hook = slack_util.Hook(nag_callback, pattern=r"nagjobs\s*(.*)", channel_whitelist=[channel_util.COMMAND_CENTER_ID])

View File

@ -1,199 +0,0 @@
from typing import List, Any, Match, Tuple
from fuzzywuzzy import process
from slackclient import SlackClient
import channel_util
import google_api
import identifier
import job_nagger
import slack_util
import scroll_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
# Note: These ranges use named range feature of google sheets.
# To edit range of jobs, edit the named range in Data -> Named Ranges in the Goole Sheets page
output_range = "JobScoreTrackerV2"
MIN_RATIO = 0.9
SIGNOFF_REWARD = 0.1
def get_curr_points() -> List[Tuple[str, float, str]]:
"""
:return: The current contents of the output range
"""
# Get the current stuff
curr_output = google_api.get_sheet_range(SHEET_ID, output_range)
all_jobs = job_nagger.get_jobs()
# Each element: force length of 3. Fmt name, score, job
def row_fixer(r: List[Any]) -> Tuple[str, float, str]:
if len(r) == 0:
return "", 0, "No Job"
else:
# Get the appropriate score
if len(r) > 1:
try:
curr_score = float(r[1])
except ValueError:
curr_score = 0
else:
curr_score = 0
# Find the current job
job = "No Job"
for j in all_jobs:
if j.brother_name == r[0]:
job = "{} ({} - {})".format(j.job_name, j.day, j.house)
break
return r[0], curr_score, job
# Fix each row
curr_output = [row_fixer(x) for x in curr_output]
# Cut off the chaff - doesn't seem to be necessary.
return curr_output
def put_points(vals: List[Tuple[str, float, str]]) -> None:
# Turn the tuples to lists
vals = [list(row) for row in vals]
google_api.set_sheet_range(SHEET_ID, output_range, vals)
def alert_user(slack: SlackClient, name: str, saywhat: str) -> None:
"""
DM a brother saying something
"""
brother_dict = scroll_util.find_by_name(name)
# We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts)
for slack_id in identifier.lookup_brother_userids(brother_dict):
dm_id = slack_util.im_channel_for_id(slack, slack_id)
if dm_id:
# Give a dummy msg dict, since we won't actually be using anything in it
slack_util.reply(slack, {}, saywhat, to_channel=dm_id, in_thread=False)
else:
print("Warning: unable to find dm for brother {}".format(brother_dict))
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Callback to signoff a user.
"""
# Find the index of our person.
name = match.group(1)
# Also, who just signed us off?
signer = identifier.lookup_msg_brother(msg).name
# Try giving the person a point
try:
(bro_name, bro_total, bro_job), (ass_name, ass_total, _) = adjust_scores((name, 1), (signer, SIGNOFF_REWARD))
slack_util.reply(slack, msg, "Gave {} one housejob point for job {}.\n"
"They now have {} for this period.\n"
"You ({}) were credited with the signoff".format(bro_name, bro_job, bro_total, ass_name))
alert_user(slack, bro_name,
"You, who we believe to be {}, just had your house job signed off by {}!".format(bro_name, ass_name))
except scroll_util.BadName as e:
# We didn't find a name - no action was performed.
slack_util.reply(slack, msg, e.as_response())
async def punish_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Undoes a signoff. Maybe should rename
"""
# Find the index of our person.
name = match.group(2)
# Also, who just signed us off?
signer = identifier.lookup_msg_brother(msg).name
# Try giving the person a point
try:
(bro_name, bro_total, _), (ass_name, ass_total, _) = adjust_scores((name, -1), (signer, -SIGNOFF_REWARD))
slack_util.reply(slack, msg, "Took one housejob point from {}.\n"
"They now have {} for this period.\n"
"Under the assumption that this was to undo a mistake, we have deducted the "
"usual signoff reward from you, ({}).\n "
"You can easily earn it back by signing off the right person ;).".format(bro_name,
bro_total,
ass_name))
alert_user(slack, bro_name,
"You, who we believe to be {}, just had your house job UN-signed off by {}.\n"
"Perhaps the asshoman made a mistake when they first signed you off.\n"
"If you believe that they undid the signoff accidentally, go talk to them".format(bro_name, signer))
except scroll_util.BadName as e:
# We didn't find a name - no action was performed.
slack_util.reply(slack, msg, e.as_response())
# noinspection PyUnusedLocal
async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Resets the scores.
"""
# Get curr rows
points = get_curr_points()
new_points = [(a, 0, c) for a, _, c in points]
put_points(new_points)
slack_util.reply(slack, msg, "Reset scores")
def adjust_scores(*name_delta_tuples: Tuple[str, float]) -> List[Tuple[str, float, str]]:
"""
Helper that uses a sequence of tuples in the format (name, delta) to adjust each (name) to have +delta score.
Operation performed as a batch.
:param name_delta_tuples: The name + score deltas
:return: The updated tuples rows.
"""
# Get the current stuff
points = get_curr_points()
names = [p[0] for p in points]
modified_user_indexes = []
for name, delta in name_delta_tuples:
# Find our guy
target_name, ratio = process.extractOne(name, names)
ratio = ratio / 100.0
# If bad ratio, error
if ratio < MIN_RATIO:
raise scroll_util.BadName(target_name, ratio, MIN_RATIO)
# Where is he in the list?
target_index = names.index(target_name)
# Get his current score
curr_score = points[target_index][1]
curr_job = points[target_index][2]
# target should be in the form index, (name, score)
target_new = target_name, curr_score + delta, curr_job
# Put it back
points[target_index] = target_new
# Record where we edited
modified_user_indexes.append(target_index)
# Push all to sheets if exit loop without error
put_points(points)
# Conver indexes to rows, then return the adjusted name/score_tuples
return [points[i] for i in modified_user_indexes]
signoff_hook = slack_util.Hook(signoff_callback,
pattern=r"signoff\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
undosignoff_hook = slack_util.Hook(punish_callback,
pattern=r"(unsignoff|undosignoff|undo)\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
reset_hook = slack_util.Hook(reset_callback,
pattern=r"reset signoffs",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])

130
main.py
View File

@ -1,26 +1,15 @@
import asyncio import asyncio
from typing import List, Any, AsyncGenerator
from slackclient import SlackClient # Obvious
import channel_util import channel_util
import identifier import identifier
import job_nagger import job_commands
import job_signoff
import management_commands import management_commands
import periodicals import periodicals
import scroll_util import scroll_util
import slack_util
import slavestothemachine import slavestothemachine
from dummy import FakeClient
# Read api token from file # Read api token from file
api_file = open("apitoken.txt", 'r') from client_wrapper import ClientWrapper
SLACK_API = next(api_file).strip()
api_file.close()
# Enable to use dummy
DEBUG_MODE = False
def main() -> None: def main() -> None:
@ -39,7 +28,7 @@ def main() -> None:
wrap.add_hook(channel_util.channel_check_hook) wrap.add_hook(channel_util.channel_check_hook)
# Add nagging functionality # Add nagging functionality
wrap.add_hook(job_nagger.nag_hook) wrap.add_hook(job_commands.nag_hook)
# Add kill switch # Add kill switch
wrap.add_hook(management_commands.reboot_hook) wrap.add_hook(management_commands.reboot_hook)
@ -49,127 +38,24 @@ def main() -> None:
wrap.add_hook(slavestothemachine.dump_work_hook) wrap.add_hook(slavestothemachine.dump_work_hook)
# Add signoffs # Add signoffs
wrap.add_hook(job_signoff.signoff_hook) wrap.add_hook(job_commands.signoff_hook)
wrap.add_hook(job_signoff.undosignoff_hook) wrap.add_hook(job_commands.reset_hook)
wrap.add_hook(job_signoff.reset_hook)
# Add help # Add help
help_callback = management_commands.list_hooks_callback_gen(wrap.hooks) # help_callback = management_commands.list_hooks_callback_gen(wrap.hooks)
wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern)) # wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern))
# Add boozebot # Add boozebot
wrap.add_passive(periodicals.ItsTenPM()) wrap.add_passive(periodicals.ItsTenPM())
event_loop = asyncio.get_event_loop() event_loop = asyncio.get_event_loop()
event_loop.set_debug(True)
message_handling = wrap.respond_messages() message_handling = wrap.respond_messages()
passive_handling = wrap.run_passives() passive_handling = wrap.run_passives()
both = asyncio.gather(message_handling, passive_handling) both = asyncio.gather(message_handling, passive_handling)
event_loop.run_until_complete(both) event_loop.run_until_complete(both)
class ClientWrapper(object):
"""
Essentially the main state object.
We only ever expect one of these.
Holds a slack client, and handles messsages.
"""
def __init__(self):
# Init slack
if DEBUG_MODE:
self.slack = FakeClient()
else:
self.slack = SlackClient(SLACK_API)
# For overriding output channel
self.debug_slack = slack_util.SlackDebugCondom(self.slack)
# Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[slack_util.Hook] = []
# Periodicals are just wrappers around an iterable, basically
self.passives: List[slack_util.Passive] = []
# Scheduled events handling
def add_passive(self, per: slack_util.Passive) -> None:
self.passives.append(per)
async def run_passives(self) -> None:
# Make a task to repeatedly spawn each event
awaitables = [p.run(self.slack) for p in self.passives]
await asyncio.gather(*awaitables)
# Message handling
def add_hook(self, hook: slack_util.Hook) -> None:
self.hooks.append(hook)
async def respond_messages(self) -> None:
"""
Asynchronous tasks that eternally reads and responds to messages.
"""
async for _ in self.spool_tasks():
print("Handling a message...!")
async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]:
async for msg in self.async_message_feed():
# Preprocess msg
# We only care about standard messages, not subtypes, as those usually just channel activity
if msg.get("subtype") is not None:
continue
# Never deal with general, EVER!
if msg.get("channel") == channel_util.GENERAL:
continue
# Strip garbage
msg['text'] = msg['text'].strip()
print("Recv: \"{}\"".format(msg['text']))
# Handle debug
if msg['text'][:6] == "DEBUG ":
slack_to_use = self.debug_slack
msg['text'] = msg['text'][6:]
print("Debug handling \"{}\"".format(msg['text']))
else:
slack_to_use = self.slack
# Msg is good
# Find which hook, if any, satisfies
sat_hook = None
sat_match = None
for hook in self.hooks:
match = hook.check(msg)
if match is not None:
sat_match = match
sat_hook = hook
break
# If no hooks, continue
if not sat_hook:
continue
# Throw up as a task, otherwise
coro = sat_hook.invoke(slack_to_use, msg, sat_match)
task = asyncio.create_task(coro)
yield task
async def async_message_feed(self) -> AsyncGenerator[dict, None]:
"""
Async wrapper around the message feed.
Yields messages awaitably forever.
"""
# Create the msg feed
feed = slack_util.message_stream(self.slack)
# Create a simple callable that gets one message from the feed
def get_one():
return next(feed)
# Continuously yield async threaded tasks that poll the feed
while True:
yield await asyncio.get_running_loop().run_in_executor(None, get_one)
# run main # run main
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -24,7 +24,7 @@ class ItsTenPM(slack_util.Passive):
await asyncio.sleep(delay) await asyncio.sleep(delay)
# Crow like a rooster # Crow like a rooster
slack_util.reply(slack, {}, "IT'S 10 PM!", in_thread=False, to_channel=channel_util.RANDOM) slack_util.send_message(slack, "IT'S 10 PM!", channel_util.RANDOM)
# Wait a while before trying it again, to prevent duplicates # Wait a while before trying it again, to prevent duplicates
await asyncio.sleep(60) await asyncio.sleep(60)

View File

@ -4,6 +4,7 @@ Only really kept separate for neatness sake.
""" """
import re import re
from dataclasses import dataclass
from typing import List, Optional, Match from typing import List, Optional, Match
from fuzzywuzzy import process from fuzzywuzzy import process
@ -11,17 +12,17 @@ from slackclient import SlackClient
import slack_util import slack_util
# Use this if we can't figure out who a brother actually is
MISSINGBRO_SCROLL = 0
@dataclass
class Brother(object): class Brother(object):
""" """
Represents a brother. Represents a brother.
""" """
def __init__(self, name: str, scroll: int): name: str
self.name = name scroll: int
self.scroll = scroll
def __repr__(self):
return "<Brother {}-{}>".format(self.name, self.scroll)
# load the family tree # load the family tree
@ -32,7 +33,7 @@ brother_match = re.compile(r"([0-9]*)~(.*)")
brothers_matches = [brother_match.match(line) for line in familyfile] brothers_matches = [brother_match.match(line) for line in familyfile]
brothers_matches = [m for m in brothers_matches if m] brothers_matches = [m for m in brothers_matches if m]
brothers: List[Brother] = [Brother(m.group(2), int(m.group(1))) for m in brothers_matches] brothers: List[Brother] = [Brother(m.group(2), int(m.group(1))) for m in brothers_matches]
recent_brothers: List[Brother] = brothers[700:]
async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None: async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None:
""" """
@ -81,23 +82,32 @@ class BadName(Exception):
"match ratio {}. Please type name better.".format(self.name, self.score, self.threshold) "match ratio {}. Please type name better.".format(self.name, self.score, self.threshold)
def find_by_name(name: str, threshold: Optional[float] = None) -> Brother: def find_by_name(name: str, threshold: Optional[float] = None, recent_only: bool=False) -> Brother:
""" """
Looks up a brother by name. Raises exception if threshold provided and not met. Looks up a brother by name. Raises exception if threshold provided and not met.
:param threshold: Minimum match ratio to accept. Can be none. :param threshold: Minimum match ratio to accept. Can be none.
:param name: The name to look up, with a fuzzy search :param name: The name to look up, with a fuzzy search
:param recent_only: Whether or not to only search more recent undergrad brothers.
:return: The best-match brother :return: The best-match brother
""" """
# Really quikly mog name into Brother, so the processor function works fine # Pick a list
name_bro = Brother(name, -1) if recent_only:
bros_to_use = recent_brothers
else:
bros_to_use = brothers
# Get all of the names
all_names = [b.name for b in bros_to_use]
# Do fuzzy match # Do fuzzy match
found = process.extractOne(name_bro, brothers, processor=lambda b: b.name) found, score = process.extractOne(name, all_names)
if (not threshold) or found[1] > threshold: score = score / 100.0
return found[0] if (not threshold) or score > threshold:
found_index = all_names.index(found)
return bros_to_use[found_index]
else: else:
raise BadName(name, found[1], threshold) raise BadName(found, score, threshold)
scroll_hook = slack_util.Hook(scroll_callback, pattern=r"scroll\s+(.*)") scroll_hook = slack_util.Hook(scroll_callback, pattern=r"scroll\s+(.*)")

View File

@ -5,14 +5,12 @@ from typing import Any, Optional, Generator, Match, Callable, List, Coroutine
from slackclient import SlackClient from slackclient import SlackClient
from slackclient.client import SlackNotConnected from slackclient.client import SlackNotConnected
import channel_util
""" """
Slack helpers. Separated for compartmentalization Slack helpers. Separated for compartmentalization
""" """
def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_channel: str = None) -> None: def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_channel: str = None) -> dict:
""" """
Sends message with "text" as its content to the channel that message came from Sends message with "text" as its content to the channel that message came from
""" """
@ -24,9 +22,22 @@ def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_c
if in_thread: if in_thread:
thread = (msg.get("thread_ts") # In-thread case - get parent ts thread = (msg.get("thread_ts") # In-thread case - get parent ts
or msg.get("ts")) # Not in-thread case - get msg itself ts or msg.get("ts")) # Not in-thread case - get msg itself ts
slack.rtm_send_message(channel=to_channel, message=text, thread=thread) return send_message(slack, text, to_channel, thread=thread)
else: else:
slack.rtm_send_message(channel=to_channel, message=text) return send_message(slack, text, to_channel)
def send_message(slack: SlackClient, text: str, channel: str, thread: str = None, broadcast: bool = False) -> dict:
"""
Copy of the internal send message function of slack
"""
kwargs = {"channel": channel, "text": text}
if thread:
kwargs["thread_ts"] = thread
if broadcast:
kwargs["reply_broadcast"] = True
return slack.api_call("chat.postMessage", **kwargs)
def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]: def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]:
@ -39,29 +50,6 @@ def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]:
return None return None
class SlackDebugCondom(object):
def __init__(self, actual_slack: SlackClient):
self.actual_slack = actual_slack
def __getattribute__(self, name: str) -> Any:
# Specialized behaviour
if name == "rtm_send_message":
# Flub some args
def override_send_message(*args, **kwargs):
print("Overriding: {} {}".format(args, kwargs))
kwargs["channel"] = channel_util.BOTZONE
kwargs["thread"] = None
self.actual_slack.rtm_send_message(*args, **kwargs)
return override_send_message
else:
# Default behaviour. Try to give the self first, elsewise give the child
try:
return super(SlackDebugCondom, self).__getattribute__(name)
except AttributeError:
return self.actual_slack.__getattribute__(name)
def message_stream(slack: SlackClient) -> Generator[dict, None, None]: def message_stream(slack: SlackClient) -> Generator[dict, None, None]:
""" """
Generator that yields messages from slack. Generator that yields messages from slack.
@ -93,12 +81,28 @@ MsgAction = Coroutine[Any, Any, None]
Callback = Callable[[SlackClient, dict, Match], MsgAction] Callback = Callable[[SlackClient, dict, Match], MsgAction]
class Hook(object): class DeadHook(Exception):
pass
class AbsHook(object):
def __init__(self, consumes_applicable: bool):
# Whether or not messages that yield a coroutine should not be checked further
self.consumes = consumes_applicable
def try_apply(self, slack: SlackClient, msg: dict) -> Optional[Coroutine[None, None, None]]:
raise NotImplementedError()
class Hook(AbsHook):
def __init__(self, def __init__(self,
callback: Callback, callback: Callback,
pattern: str, pattern: str,
channel_whitelist: Optional[List[str]] = None, channel_whitelist: Optional[List[str]] = None,
channel_blacklist: Optional[List[str]] = None): channel_blacklist: Optional[List[str]] = None,
consumer: bool = True):
super(Hook, self).__init__(consumer)
# Save all # Save all
self.pattern = pattern self.pattern = pattern
self.channel_whitelist = channel_whitelist self.channel_whitelist = channel_whitelist
@ -114,7 +118,7 @@ class Hook(object):
else: else:
raise Exception("Cannot whitelist and blacklist") raise Exception("Cannot whitelist and blacklist")
def check(self, msg: dict) -> Optional[Match]: def try_apply(self, slack: SlackClient, msg: dict) -> Optional[Coroutine[None, None, None]]:
""" """
Returns whether a message should be handled by this dict, returning a Match if so, or None Returns whether a message should be handled by this dict, returning a Match if so, or None
""" """
@ -134,9 +138,6 @@ class Hook(object):
# print("Hit blacklist") # print("Hit blacklist")
return None return None
return match
def invoke(self, slack: SlackClient, msg: dict, match: Match):
return self.callback(slack, msg, match) return self.callback(slack, msg, match)

View File

@ -1,4 +1,5 @@
scroll~name scroll~name
0~MISSINGBRO
1~Richard Brodeur 1~Richard Brodeur
2~Charles T. Kleman 2~Charles T. Kleman
4~Warren Bentley 4~Warren Bentley