Almost done

This commit is contained in:
Jacob Henry 2019-02-22 04:34:25 -05:00
parent fe33a5a0e4
commit ddb6ffac10
10 changed files with 460 additions and 435 deletions

View File

@ -1,31 +0,0 @@
from typing import Match
from slackclient import SlackClient
import slack_util
# Useful channels
GENERAL = "C0CFHPNEM"
RANDOM = "C0CFDQWUW"
COMMAND_CENTER_ID = "GCR631LQ1"
SLAVES_TO_THE_MACHINE_ID = "C9WUQBYNP"
BOTZONE = "C3BF2MFKM"
HOUSEJOBS = "CDWDDTAT0"
# Callback for telling what channel we in
async def channel_check_callback(slack: SlackClient, msg: dict, match: Match) -> None:
# Sets the users scroll
rest_of_msg = match.group(1).strip()
rest_of_msg = rest_of_msg.replace("<", "lcaret")
rest_of_msg = rest_of_msg.replace(">", "rcaret")
# Respond
response = ""
response += "Channel id: {}\n".format(msg["channel"])
response += "Escaped message: {}\n".format(rest_of_msg)
slack_util.reply(slack, msg, response)
channel_check_hook = slack_util.ChannelHook(channel_check_callback,
patterns=r"channel id\s*(.*)")

View File

@ -1,127 +0,0 @@
import asyncio
import traceback
from typing import List, Any, AsyncGenerator, Coroutine, TypeVar
from slackclient import SlackClient # Obvious
import channel_util
import sys
import slack_util
# Read the API token
api_file = open("apitoken.txt", 'r')
SLACK_API = next(api_file).strip()
api_file.close()
# Enable to do single-threaded and have better exceptions
DEBUG_MODE = False
A, B, C = TypeVar("A"), TypeVar("B"), TypeVar("C")
async def _loud_mouth(c: Coroutine[A, B, C]) -> Coroutine[A, B, C]:
# Print exceptions as they pass through
try:
return await c
except Exception:
traceback.print_exc()
raise
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
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 t in self.spool_tasks():
sys.stdout.flush()
if DEBUG_MODE:
await t
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']))
print(msg)
# 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(_loud_mouth(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. Now {} running total.".format(len(asyncio.all_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 grab() -> ClientWrapper:
return _singleton

View File

@ -1,17 +1,16 @@
"""
Allows users to register their user account as a specific scroll
"""
import asyncio
import shelve
from typing import List, Match
from slackclient import SlackClient
import scroll_util
import slack_util
# The following db maps SLACK_USER_ID -> SCROLL_INTEGER
DB_NAME = "user_scrolls"
DB_LOCK = asyncio.Lock()
# Initialize the hooks
NON_REG_MSG = ("You currently have no scroll registered. To register, type\n"
@ -19,16 +18,17 @@ NON_REG_MSG = ("You currently have no scroll registered. To register, type\n"
"except with your scroll instead of 666")
async def identify_callback(slack, msg, match):
async def identify_callback(event: slack_util.Event, match: Match):
"""
Sets the users scroll
"""
async with DB_LOCK:
with shelve.open(DB_NAME) as db:
# Get the query
query = match.group(1).strip()
try:
user = msg.get("user")
user = event.user.user_id
scroll = int(query)
db[user] = scroll
result = "Updated user {} to have scroll {}".format(user, scroll)
@ -36,13 +36,14 @@ async def identify_callback(slack, msg, match):
result = "Bad scroll: {}".format(query)
# Respond
slack_util.reply(slack, msg, result)
slack_util.get_slack().reply(event, result)
async def identify_other_callback(slack: SlackClient, msg: dict, match: Match):
async def identify_other_callback(event: slack_util.Event, match: Match):
"""
Sets another users scroll
"""
async with DB_LOCK:
with shelve.open(DB_NAME) as db:
# Get the query
user = match.group(1).strip()
@ -59,32 +60,34 @@ async def identify_other_callback(slack: SlackClient, msg: dict, match: Match):
result = "Bad scroll: {}".format(scroll_txt)
# Respond
slack_util.reply(slack, msg, result)
slack_util.get_slack().reply(event, result)
# noinspection PyUnusedLocal
async def check_callback(slack: SlackClient, msg: dict, match: Match):
async def check_callback(event: slack_util.Event, match: Match):
"""
Replies with the users current scroll assignment
"""
async with DB_LOCK:
# Tells the user their current scroll
with shelve.open(DB_NAME) as db:
try:
scroll = db[msg.get("user")]
scroll = db[event.user.user_id]
result = "You are currently registered with scroll {}".format(scroll)
except KeyError:
result = NON_REG_MSG
slack_util.reply(slack, msg, result)
slack_util.get_slack().reply(event, result)
# noinspection PyUnusedLocal
async def name_callback(slack, msg, match):
async def name_callback(event: slack_util.Event, match: Match):
"""
Tells the user what it thinks the calling users name is.
"""
async with DB_LOCK:
with shelve.open(DB_NAME) as db:
try:
scroll = db[msg.get("user")]
scroll = db[event.user.user_id]
brother = scroll_util.find_by_scroll(scroll)
if brother:
result = "The bot thinks your name is {}".format(brother.name)
@ -94,17 +97,7 @@ async def name_callback(slack, msg, match):
result = NON_REG_MSG
# Respond
slack_util.reply(slack, msg, result)
async def lookup_msg_brother(msg: dict) -> scroll_util.Brother:
"""
Finds the real-world name of whoever posted msg.
Utilizes their bound-scroll.
:raises BrotherNotFound:
:return: brother dict or None
"""
return await lookup_slackid_brother(msg.get("user"))
slack_util.get_slack().reply(event, result)
async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother:
@ -113,6 +106,7 @@ async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother:
:raises BrotherNotFound:
:return: Brother object or None
"""
async with DB_LOCK:
with shelve.open(DB_NAME) as db:
try:
scroll = db[slack_id]
@ -121,13 +115,14 @@ async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother:
raise scroll_util.BrotherNotFound("Slack id {} not tied to brother".format(slack_id))
def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]:
async def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]:
"""
Returns a list of all userids associated with the given brother.
:param brother: Brother to lookup scrolls for
:return: List of user id strings (may be empty)
"""
async with DB_LOCK:
with shelve.open(DB_NAME) as db:
keys = db.keys()
result = []

View File

@ -4,8 +4,6 @@ from typing import List, Match, Callable, TypeVar, Optional, Iterable
from fuzzywuzzy import fuzz
from slackclient import SlackClient
import channel_util
import client_wrapper
import house_management
import identifier
import scroll_util
@ -16,14 +14,14 @@ SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
MIN_RATIO = 80.0
def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -> None:
async def alert_user(brother: scroll_util.Brother, saywhat: str) -> None:
"""
DM a brother saying something. Wrapper around several simpler methods
"""
# We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts)
succ = False
for slack_id in identifier.lookup_brother_userids(brother):
slack_util.send_message(slack, saywhat, slack_id)
for slack_id in await identifier.lookup_brother_userids(brother):
slack_util.get_slack().send_message(saywhat, slack_id)
succ = True
# Warn if we never find
@ -31,6 +29,7 @@ def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -
print("Warning: unable to find dm for brother {}".format(brother))
# Generic type
T = TypeVar("T")
@ -60,8 +59,7 @@ class _ModJobContext:
assign: house_management.JobAssignment # The job assignment to modify
async def _mod_jobs(slack: SlackClient,
msg: dict,
async def _mod_jobs(event: slack_util.Event,
relevance_scorer: Callable[[house_management.JobAssignment], Optional[float]],
modifier: Callable[[_ModJobContext], None],
no_job_msg: str = None
@ -72,10 +70,10 @@ async def _mod_jobs(slack: SlackClient,
:param modifier: Callback function to modify a job. Only called on a successful operation, and only on one job
"""
# Make an error wrapper
verb = slack_util.VerboseWrapper(slack, msg)
verb = slack_util.VerboseWrapper(event)
# Who invoked this command?
signer = await verb(identifier.lookup_msg_brother(msg))
signer = await verb(event.user.as_user().get_brother())
# Get all of the assignments
assigns = await verb(house_management.import_assignments())
@ -115,7 +113,7 @@ async def _mod_jobs(slack: SlackClient,
if len(closest_assigns) == 0:
if no_job_msg is None:
no_job_msg = "Unable to find any jobs to apply this command to. Try again with better spelling or whatever."
slack_util.reply(slack, msg, no_job_msg)
slack_util.get_slack().reply(event, no_job_msg)
# If theres only one job, sign it off
elif len(closest_assigns) == 1:
@ -125,7 +123,7 @@ async def _mod_jobs(slack: SlackClient,
else:
# Say we need more info
job_list = "\n".join("{}: {}".format(i, a.job.pretty_fmt()) for i, a in enumerate(closest_assigns))
slack_util.reply(slack, msg, "Multiple relevant job listings found.\n"
slack_util.get_slack().reply(event, "Multiple relevant job listings found.\n"
"Please enter the number corresponding to the job "
"you wish to modify:\n{}".format(job_list))
@ -133,7 +131,7 @@ async def _mod_jobs(slack: SlackClient,
pattern = r"\d+"
# Make the follow up callback
async def foc(_slack: SlackClient, _msg: dict, _match: Match) -> None:
async def foc(_event: slack_util.Event, _match: Match) -> None:
# Get the number out
index = int(_match.group(0))
@ -143,17 +141,17 @@ async def _mod_jobs(slack: SlackClient,
await success_callback(closest_assigns[index])
else:
# They gave a bad index, or we were unable to find the assignment again.
slack_util.reply(_slack, _msg, "Invalid job index / job unable to be found.")
slack_util.get_slack().reply(_event, "Invalid job index / job unable to be found.")
# Make a listener hook
new_hook = slack_util.ReplyWaiter(foc, pattern, msg["ts"], 120)
new_hook = slack_util.ReplyWaiter(foc, pattern, event.message.ts, 120)
# Register it
client_wrapper.grab().add_hook(new_hook)
slack_util.get_slack().add_hook(new_hook)
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
verb = slack_util.VerboseWrapper(slack, msg)
async def signoff_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event)
# Find out who we are trying to sign off is
signee_name = match.group(1)
@ -171,17 +169,17 @@ async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
context.assign.signer = context.signer
# Say we did it wooo!
slack_util.reply(slack, msg, "Signed off {} for {}".format(context.assign.assignee.name,
slack_util.get_slack().reply(event, "Signed off {} for {}".format(context.assign.assignee.name,
context.assign.job.name))
alert_user(slack, context.assign.assignee, "{} signed you off for {}.".format(context.assign.signer.name,
alert_user(context.assign.assignee, "{} signed you off for {}.".format(context.assign.signer.name,
context.assign.job.pretty_fmt()))
# Fire it off
await _mod_jobs(slack, msg, scorer, modifier)
await _mod_jobs(event, scorer, modifier)
async def undo_callback(slack: SlackClient, msg: dict, match: Match) -> None:
verb = slack_util.VerboseWrapper(slack, msg)
async def undo_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event)
# Find out who we are trying to sign off is
signee_name = match.group(1)
@ -199,18 +197,18 @@ async def undo_callback(slack: SlackClient, msg: dict, match: Match) -> None:
context.assign.signer = None
# Say we did it wooo!
slack_util.reply(slack, msg, "Undid signoff of {} for {}".format(context.assign.assignee.name,
slack_util.get_slack().reply(event, "Undid signoff of {} for {}".format(context.assign.assignee.name,
context.assign.job.name))
alert_user(slack, context.assign.assignee, "{} undid your signoff off for {}.\n"
alert_user(context.assign.assignee, "{} undid your signoff off for {}.\n"
"Must have been a mistake".format(context.assign.signer.name,
context.assign.job.pretty_fmt()))
# Fire it off
await _mod_jobs(slack, msg, scorer, modifier)
await _mod_jobs(event, scorer, modifier)
async def late_callback(slack: SlackClient, msg: dict, match: Match) -> None:
verb = slack_util.VerboseWrapper(slack, msg)
async def late_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event)
# Find out who we are trying to sign off is
signee_name = match.group(1)
@ -228,16 +226,16 @@ async def late_callback(slack: SlackClient, msg: dict, match: Match) -> None:
context.assign.late = not context.assign.late
# Say we did it
slack_util.reply(slack, msg, "Toggled lateness of {}.\n"
slack_util.get_slack().reply(event, "Toggled lateness of {}.\n"
"Now marked as late: {}".format(context.assign.job.pretty_fmt(),
context.assign.late))
# Fire it off
await _mod_jobs(slack, msg, scorer, modifier)
await _mod_jobs(event, scorer, modifier)
async def reassign_callback(slack: SlackClient, msg: dict, match: Match) -> None:
verb = slack_util.VerboseWrapper(slack, msg)
async def reassign_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event)
# Find out our two targets
from_name = match.group(1).strip()
@ -263,21 +261,21 @@ async def reassign_callback(slack: SlackClient, msg: dict, match: Match) -> None
reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(),
from_bro,
to_bro)
slack_util.reply(slack, msg, reassign_msg)
slack_util.get_slack().reply(event, reassign_msg)
# Tell the people
reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(),
from_bro,
to_bro)
alert_user(slack, from_bro, reassign_msg)
alert_user(slack, to_bro, reassign_msg)
alert_user(from_bro, reassign_msg)
alert_user(to_bro, reassign_msg)
# Fire it off
await _mod_jobs(slack, msg, scorer, modifier)
await _mod_jobs(event, scorer, modifier)
# noinspection PyUnusedLocal
async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
async def reset_callback(event: slack_util.Event, match: Match) -> None:
"""
Resets the scores.
"""
@ -299,18 +297,18 @@ async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
house_management.apply_house_points(points, await house_management.import_assignments())
house_management.export_points(headers, points)
slack_util.reply(slack, msg, "Reset scores and signoffs")
slack_util.get_slack().reply(event, "Reset scores and signoffs")
# noinspection PyUnusedLocal
async def refresh_callback(slack: SlackClient, msg: dict, match: Match) -> None:
async def refresh_callback(event: slack_util.Event, match: Match) -> None:
headers, points = await house_management.import_points()
house_management.apply_house_points(points, await house_management.import_assignments())
house_management.export_points(headers, points)
slack_util.reply(slack, msg, "Force updated point values")
slack_util.get_slack().reply(event, "Force updated point values")
async def nag_callback(slack, msg, match):
async def nag_callback(event: slack_util.Event, match: Match) -> None:
# Get the day
day = match.group(1).lower().strip()
@ -325,7 +323,7 @@ async def nag_callback(slack, msg, match):
# 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"
slack_util.get_slack().reply(event, "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
@ -339,7 +337,7 @@ async def nag_callback(slack, msg, match):
response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name)
# Find the people to @
brother_slack_ids = identifier.lookup_brother_userids(assign.assignee)
brother_slack_ids = await identifier.lookup_brother_userids(assign.assignee)
if brother_slack_ids:
for slack_id in brother_slack_ids:
@ -348,7 +346,8 @@ async def nag_callback(slack, msg, match):
response += "(scroll missing. Please register for @ pings!)"
response += "\n"
slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.GENERAL)
general_id = slack_util.get_slack().get_channel_by_name("#general").id
slack_util.get_slack().reply(event, response, in_thread=False, to_channel=general_id)
signoff_hook = slack_util.ChannelHook(signoff_callback,
@ -356,7 +355,7 @@ signoff_hook = slack_util.ChannelHook(signoff_callback,
r"signoff\s+(.*)",
r"sign off\s+(.*)",
],
channel_whitelist=[channel_util.HOUSEJOBS])
channel_whitelist=["#housejobs"])
undo_hook = slack_util.ChannelHook(undo_callback,
patterns=[
@ -364,37 +363,36 @@ undo_hook = slack_util.ChannelHook(undo_callback,
r"undosignoff\s+(.*)",
r"undo signoff\s+(.*)",
],
channel_whitelist=[channel_util.HOUSEJOBS])
channel_whitelist=["#housejobs"])
late_hook = slack_util.ChannelHook(late_callback,
patterns=[
r"marklate\s+(.*)",
r"mark late\s+(.*)",
],
channel_whitelist=[channel_util.HOUSEJOBS])
channel_whitelist=["#housejobs"])
reset_hook = slack_util.ChannelHook(reset_callback,
patterns=[
r"reset signoffs",
r"reset sign offs",
],
channel_whitelist=[channel_util.COMMAND_CENTER_ID])
channel_whitelist=["#command-center"])
nag_hook = slack_util.ChannelHook(nag_callback,
patterns=[
r"nagjobs\s+(.*)",
r"nag jobs\s+(.*)"
],
channel_whitelist=[channel_util.COMMAND_CENTER_ID])
channel_whitelist=["#command-center"])
reassign_hook = slack_util.ChannelHook(reassign_callback,
patterns=r"reassign\s+(.*?)-&gt;\s+(.+)",
channel_whitelist=[channel_util.HOUSEJOBS])
channel_whitelist=["#housejobs"])
refresh_hook = slack_util.ChannelHook(refresh_callback,
patterns=[
"refresh points",
"update points"
],
channel_whitelist=[channel_util.COMMAND_CENTER_ID]
)
channel_whitelist=["#command-center"])

15
main.py
View File

@ -2,10 +2,6 @@ import asyncio
import textwrap
from typing import Match
from slackclient import SlackClient
import channel_util
import client_wrapper
import identifier
import job_commands
import management_commands
@ -16,7 +12,7 @@ import slavestothemachine
def main() -> None:
wrap = client_wrapper.grab()
wrap = slack_util.get_slack()
# Add scroll handling
wrap.add_hook(scroll_util.scroll_hook)
@ -27,9 +23,6 @@ def main() -> None:
wrap.add_hook(identifier.identify_other_hook)
wrap.add_hook(identifier.name_hook)
# Added channel utility
wrap.add_hook(channel_util.channel_check_hook)
# Add kill switch
wrap.add_hook(management_commands.reboot_hook)
@ -55,7 +48,7 @@ def main() -> None:
wrap.add_passive(periodicals.RemindJobs())
event_loop = asyncio.get_event_loop()
event_loop.set_debug(client_wrapper.DEBUG_MODE)
event_loop.set_debug(slack_util.DEBUG_MODE)
message_handling = wrap.respond_messages()
passive_handling = wrap.run_passives()
both = asyncio.gather(message_handling, passive_handling)
@ -63,8 +56,8 @@ def main() -> None:
# noinspection PyUnusedLocal
async def help_callback(slack: SlackClient, msg: dict, match: Match) -> None:
slack_util.reply(slack, msg, textwrap.dedent("""
async def help_callback(event: slack_util.Event, match: Match) -> None:
slack_util.get_slack().reply(event, textwrap.dedent("""
Commands are as follows. Note that some only work in certain channels.
"my scroll is number" : Registers your slack account to have a certain scroll, for the purpose of automatic dm's.
"@person has scroll number" : same as above, but for other users. Helpful if they are being obstinate.

View File

@ -1,28 +1,25 @@
from typing import Match, List
from slackclient import SlackClient
import channel_util
import slack_util
def list_hooks_callback_gen(hooks: List[slack_util.ChannelHook]) -> slack_util.Callback:
# noinspection PyUnusedLocal
async def callback(slack, msg, match):
slack_util.reply(slack, msg, "\n".join(hook.patterns for hook in hooks))
async def callback(event: slack_util.Event, match: Match) -> None:
slack_util.get_slack().reply(event, "\n".join(hook.patterns for hook in hooks))
return callback
# Gracefully reboot to reload code changes
# noinspection PyUnusedLocal
async def reboot_callback(slack: SlackClient, msg: dict, match: Match) -> None:
async def reboot_callback(event: slack_util.Event, match: Match) -> None:
response = "Ok. Rebooting..."
slack_util.reply(slack, msg, response)
slack_util.get_slack().reply(event, response)
exit(0)
# Make hooks
reboot_hook = slack_util.ChannelHook(reboot_callback,
patterns=r"reboot",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])
channel_whitelist=["#command-center"])

View File

@ -2,9 +2,6 @@ import asyncio
from datetime import datetime
from typing import Optional, List
from slackclient import SlackClient
import channel_util
import house_management
import identifier
import slack_util
@ -17,7 +14,7 @@ def seconds_until(target: datetime) -> float:
class ItsTenPM(slack_util.Passive):
async def run(self, slack: SlackClient) -> None:
async def run(self) -> None:
while True:
# Get 10PM
ten_pm = datetime.now().replace(hour=22, minute=0, second=0)
@ -27,14 +24,14 @@ class ItsTenPM(slack_util.Passive):
await asyncio.sleep(delay)
# Crow like a rooster
slack_util.send_message(slack, "IT'S 10 PM!", channel_util.RANDOM)
slack_util.get_slack().send_message("IT'S 10 PM!", slack_util.get_slack().get_channel_by_name("#random").id)
# Wait a while before trying it again, to prevent duplicates
await asyncio.sleep(60)
class RemindJobs(slack_util.Passive):
async def run(self, slack: SlackClient) -> None:
async def run(self) -> None:
while True:
# Get the end of the current day (Say, 10PM)
today_remind_time = datetime.now().replace(hour=22, minute=00, second=0)
@ -80,14 +77,14 @@ class RemindJobs(slack_util.Passive):
print("Nagging!")
for a in assigns:
# Get the relevant slack ids
assignee_ids = identifier.lookup_brother_userids(a.assignee)
assignee_ids = await identifier.lookup_brother_userids(a.assignee)
# For each, send them a DM
success = False
for slack_id in assignee_ids:
msg = "{}, you still need to do {}".format(a.assignee.name, a.job.pretty_fmt())
success = True
slack_util.send_message(slack, msg, slack_id)
slack_util.get_slack().send_message(msg, slack_id)
# Warn on failure
if not success:

View File

@ -8,7 +8,6 @@ from dataclasses import dataclass
from typing import List, Optional, Match
from fuzzywuzzy import process
from slackclient import SlackClient
import slack_util
@ -38,7 +37,7 @@ 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]
async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None:
async def scroll_callback(event: slack_util.Event, match: Match) -> None:
"""
Finds the scroll of a brother, or the brother of a scroll, based on msg text.
"""
@ -57,7 +56,7 @@ async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None:
result = "Couldn't find brother {}".format(query)
# Respond
slack_util.reply(slack, msg, result)
slack_util.get_slack().reply(event, result)
def find_by_scroll(scroll: int) -> Optional[Brother]:

View File

@ -1,71 +1,61 @@
from __future__ import annotations
import asyncio
import re
import sys
import traceback
from dataclasses import dataclass
from time import sleep, time
from typing import Any, Optional, Generator, Match, Callable, List, Coroutine, Union, TypeVar, Awaitable
from typing import List, Any, AsyncGenerator, Coroutine, TypeVar
from typing import Optional, Generator, Match, Callable, Union, Awaitable
from slackclient import SlackClient
from slackclient.client import SlackNotConnected
# Enable to do single-threaded and have better exceptions
import identifier
import scroll_util
DEBUG_MODE = False
"""
Slack helpers. Separated for compartmentalization
Objects to represent things within a slack workspace
"""
def reply(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.
Returns the JSON response.
"""
# If no channel specified, just do same as msg
if to_channel is None:
to_channel = msg['channel']
# Send in a thread by default
if in_thread:
thread = (msg.get("thread_ts") # In-thread case - get parent ts
or msg.get("ts")) # Not in-thread case - get msg itself ts
return send_message(slack, text, to_channel, thread=thread)
else:
return send_message(slack, text, to_channel)
def send_message(text: str, channel: str, thread: str = None, broadcast: bool = False) -> dict:
"""
Copy of the internal send message function of slack, with some helpful options.
Returns the JSON response.
"""
kwargs = {"channel": channel, "text": text}
if thread:
kwargs["thread_ts"] = thread
if broadcast:
kwargs["reply_broadcast"] = True
return slack.api_call("chat.postMessage", **kwargs)
"""
Objects to represent things
"""
@dataclass
class User:
pass
id: str
name: str
real_name: str
email: Optional[str]
async def get_brother(self) -> Optional[scroll_util.Brother]:
"""
Try to find the brother corresponding to this user.
"""
return await identifier.lookup_slackid_brother(self.id)
@dataclass
class Channel:
@property
def channel_name(self) -> str:
raise NotImplementedError()
id: str
name: str
purpose: str
members: List[User]
"""
Below we have a modular system that represents possible event contents.
Objects to represent attributes an event may contain
"""
@dataclass
class Event:
channel: Optional[ChannelContext]
user: Optional[UserContext]
message: Optional[Message]
channel: Optional[ChannelContext] = None
user: Optional[UserContext] = None
message: Optional[MessageContext] = None
thread: Optional[ThreadContext] = None
# If this was posted in a specific channel or conversation
@ -73,6 +63,9 @@ class Event:
class ChannelContext:
channel_id: str
def get_channel(self) -> Channel:
raise NotImplementedError()
# If there is a specific user associated with this event
@dataclass
@ -85,16 +78,28 @@ class UserContext:
# Whether or not this is a threadable text message
@dataclass
class Message:
class MessageContext:
ts: str
text: str
@dataclass
class ThreadContext:
thread_ts: str
parent_ts: str
# If a file was additionally shared
@dataclass
class File:
pass
"""
Objects for interfacing easily with rtm steams
"""
def message_stream(slack: SlackClient) -> Generator[Event, None, None]:
"""
Generator that yields messages from slack.
@ -104,50 +109,199 @@ def message_stream(slack: SlackClient) -> Generator[Event, None, None]:
# Do forever
while True:
try:
if slack.rtm_connect(with_team_state=False, auto_reconnect=True):
if slack.rtm_connect(with_team_state=True, auto_reconnect=True):
print("Waiting for messages")
while True:
sleep(0.1)
update = slack.rtm_read()
for item in update:
if item.get('type') == 'message':
yield item
update_list = slack.rtm_read()
# Handle each
for update in update_list:
print("Message received: {}".format(update))
event = Event()
# Big logic folks
if update["type"] == "message":
event.message = MessageContext(update["ts"], update["text"])
event.channel = ChannelContext(update["channel"])
event.user = UserContext(update["user"])
# TODO: Handle more types
# We need to
yield event
except (SlackNotConnected, OSError) as e:
print("Error while reading messages:")
print(e)
except (ValueError, TypeError):
except (ValueError, TypeError) as e:
print("Malformed message... Restarting connection")
print(e)
sleep(5)
print("Connection failed - retrying")
T = TypeVar("T")
"""
Objects to wrap slack connections
"""
# Read the API token
api_file = open("apitoken.txt", 'r')
SLACK_API = next(api_file).strip()
api_file.close()
class VerboseWrapper(Callable):
class ClientWrapper(object):
"""
Generates exception-ready delegates.
Warns of exceptions as they are passed through it, via responding to the given message.
Essentially the main state object.
We only ever expect one of these per api token.
Holds a slack client, and handles messsages.
"""
def __init__(self, slack: SlackClient, command_msg: dict):
self.slack = slack
self.command_msg = command_msg
async def __call__(self, awt: Awaitable[T]) -> T:
def __init__(self, api_token):
# Init slack
self.slack = SlackClient(api_token)
# Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[AbsHook] = []
# Periodicals are just wrappers around an iterable, basically
self.passives: List[Passive] = []
# Cache users and channels
self.users: dict = {}
self.channels: dict = {}
# Scheduled events handling
def add_passive(self, per: Passive) -> None:
self.passives.append(per)
async def run_passives(self) -> None:
"""
Run all currently added passives
"""
awaitables = [p.run() for p in self.passives]
await asyncio.gather(*awaitables)
# Message handling
def add_hook(self, hook: AbsHook) -> None:
self.hooks.append(hook)
async def respond_messages(self) -> None:
"""
Asynchronous tasks that eternally reads and responds to messages.
"""
async for t in self.spool_tasks():
sys.stdout.flush()
if DEBUG_MODE:
await t
async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]:
async for event in self.async_event_feed():
# Find which hook, if any, satisfies
for hook in list(self.hooks): # Note that we do list(self.hooks) to avoid edit-while-iterating issues
# Try invoking each
try:
return await awt
except Exception as e:
reply(self.command_msg, "Error: {}".format(str(e)), True)
raise e
# Try to make a coroutine handling the message
coro = hook.try_apply(event)
# 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(_exception_printing_task(coro))
if hook.consumes:
break
except DeadHook:
# If a hook wants to die, let it.
self.hooks.remove(hook)
print("Done spawning tasks. Now {} running total.".format(len(asyncio.all_tasks())))
async def async_event_feed(self) -> AsyncGenerator[Event, None]:
"""
Async wrapper around the message feed.
Yields messages awaitably forever.
"""
# Create the msg feed
feed = 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)
def get_channel(self, channel_id: str) -> Optional[Channel]:
return self.channels.get(channel_id)
def get_channel_by_name(self, channel_name: str) -> Optional[Channel]:
# Find the channel in the dict
for v in self.channels.values():
if v.name == channel_name:
return v
return None
def get_user(self, user_id: str) -> Optional[Channel]:
return self.users.get(user_id)
def api_call(self, api_method, **kwargs):
return self.slack.api_call(api_method, **kwargs)
def reply(self, event: Event, text: str, in_thread: bool = True) -> dict:
"""
Replies to a message.
Message must have a channel and message context.
Returns the JSON response.
"""
# Ensure we're actually replying to a valid message
assert (event.channel and event.message) is not None
# Send in a thread by default
if in_thread:
# Figure otu what thread to send it to
thread = event.message.ts
if event.thread:
thread = event.thread.thread_ts
return self.send_message(text, event.channel.channel_id, thread=thread)
else:
return self.send_message(text, event.channel.channel_id)
def send_message(self, text: str, channel_id: str, thread: str = None, broadcast: bool = False) -> dict:
"""
Copy of the internal send message function of slack, with some helpful options.
Returns the JSON response.
"""
kwargs = {"channel": channel_id, "text": text}
if thread:
kwargs["thread_ts"] = thread
if broadcast:
kwargs["reply_broadcast"] = True
return self.api_call("chat.postMessage", **kwargs)
# The result of a message
# Create a single instance of the client wrapper
_singleton = ClientWrapper(SLACK_API)
def get_slack() -> ClientWrapper:
return _singleton
# Return type of an event callback
MsgAction = Coroutine[Any, Any, None]
# The function called on a message
Callback = Callable[[SlackClient, Message, Match], MsgAction]
# Type signature of an event callback function
Callback = Callable[[Event, Match], MsgAction]
"""
Hooks
"""
# Signal exception to be raised when a hook has died
class DeadHook(Exception):
pass
@ -166,6 +320,7 @@ class ChannelHook(AbsHook):
"""
Hook that handles messages in a variety of channels
"""
def __init__(self,
callback: Callback,
patterns: Union[str, List[str]],
@ -185,8 +340,7 @@ class ChannelHook(AbsHook):
# Remedy some sensible defaults
if self.channel_blacklist is None:
import channel_util
self.channel_blacklist = [channel_util.GENERAL]
self.channel_blacklist = ["#general"]
elif self.channel_whitelist is None:
pass # We leave as none to show no whitelisting in effect
else:
@ -196,25 +350,32 @@ class ChannelHook(AbsHook):
"""
Returns whether a message should be handled by this dict, returning a Match if so, or None
"""
# Ensure that this is an event in a specific channel, with a text component
if not (event.channel and event.message):
return None
# Fail if pattern invalid
match = None
for p in self.patterns:
match = re.match(p, msg['text'], flags=re.IGNORECASE)
match = re.match(p, event.message.text.strip(), flags=re.IGNORECASE)
if match is not None:
break
if match is None:
return None
# Get the channel name
channel_name = event.channel.get_channel().name
# Fail if whitelist defined, and we aren't there
if self.channel_whitelist is not None and msg["channel"] not in self.channel_whitelist:
if self.channel_whitelist is not None and channel_name not in self.channel_whitelist:
return None
# Fail if blacklist defined, and we are there
if self.channel_blacklist is not None and msg["channel"] in self.channel_blacklist:
if self.channel_blacklist is not None and channel_name in self.channel_blacklist:
return None
return self.callback(slack, msg, match)
return self.callback(event, match)
class ReplyWaiter(AbsHook):
@ -238,19 +399,22 @@ class ReplyWaiter(AbsHook):
# If so, give up the ghost
if self.dead or should_expire:
print("Reply waiter has expired after {} seconds".format(time_alive))
raise DeadHook()
# Next make sure we're actually a message
if not (event.message and event.thread):
return None
# Otherwise proceed normally
# Is the msg the one we care about? If not, ignore
if msg.get("thread_ts", None) != self.thread_ts:
if event.thread.thread_ts != self.thread_ts:
return None
# Does it match the regex? if not, ignore
match = re.match(self.pattern, msg['text'], flags=re.IGNORECASE)
match = re.match(self.pattern, event.message.text.strip(), flags=re.IGNORECASE)
if match:
self.dead = True
return self.callback(slack, msg, match)
return self.callback(event, match)
else:
return None
@ -263,3 +427,44 @@ class Passive(object):
async def run(self) -> None:
# Run this passive routed through the specified slack client.
raise NotImplementedError()
"""
Methods for easily responding to messages, etc.
"""
T = TypeVar("T")
class VerboseWrapper(Callable):
"""
Generates exception-ready delegates.
Warns of exceptions as they are passed through it, via responding to the given message.
"""
def __init__(self, event: Event):
self.event = event
async def __call__(self, awt: Awaitable[T]) -> T:
try:
return await awt
except Exception as e:
get_slack().reply(self.event, "Error: {}".format(str(e)), True)
raise e
"""
Miscellania
"""
A, B, C = TypeVar("A"), TypeVar("B"), TypeVar("C")
# Prints exceptions instead of silently dropping them in async tasks
async def _exception_printing_task(c: Coroutine[A, B, C]) -> Coroutine[A, B, C]:
# Print exceptions as they pass through
try:
return await c
except Exception:
traceback.print_exc()
raise

View File

@ -4,7 +4,6 @@ from typing import Match
from slackclient import SlackClient
import channel_util
import house_management
import identifier
import slack_util
@ -19,16 +18,16 @@ def fmt_work_dict(work_dict: dict) -> str:
# noinspection PyUnusedLocal
async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> None:
async def count_work_callback(event: slack_util.Event, match: Match) -> None:
# Make an error wrapper
verb = slack_util.VerboseWrapper(slack, msg)
verb = slack_util.VerboseWrapper(event)
# Tidy the text
text = msg["text"].lower().strip()
text = event.message.text.strip()
# Couple things to work through.
# One: Who sent the message?
who_wrote = await verb(identifier.lookup_msg_brother(msg))
who_wrote = await verb(event.user.as_user().get_brother())
who_wrote_label = "{} [{}]".format(who_wrote.name, who_wrote.scroll)
# Two: What work did they do?
@ -42,7 +41,7 @@ async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> No
# Three: check if we found anything
if len(new_work) == 0:
if re.search(r'\s\d\s', text) is not None:
slack_util.reply(slack, msg,
slack_util.get_slack().reply(event,
"If you were trying to record work, it was not recognized.\n"
"Use words {} or work will not be recorded".format(counted_data))
return
@ -59,7 +58,7 @@ async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> No
fmt_work_dict(new_work),
contribution_count,
new_total))
slack_util.reply(slack, msg, congrats)
slack_util.get_slack().reply(event, congrats)
async def record_towel_contribution(for_brother: Brother, contribution_count: int) -> int:
@ -91,5 +90,5 @@ async def record_towel_contribution(for_brother: Brother, contribution_count: in
# Make dem HOOKs
count_work_hook = slack_util.ChannelHook(count_work_callback,
patterns=".*",
channel_whitelist=[channel_util.SLAVES_TO_THE_MACHINE_ID],
channel_whitelist=["#slavestothemachine"],
consumer=False)