Semi-stable rework of signoff procedure
This commit is contained in:
parent
5ae139fde0
commit
716ec52a98
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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])
|
|
||||||
199
job_signoff.py
199
job_signoff.py
|
|
@ -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
130
main.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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+(.*)")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue