waitonbot/hooks.py

154 lines
4.7 KiB
Python

from __future__ import annotations
import re
from time import time
from typing import Match, Any, Coroutine, Callable, Optional, Union, List
import slack_util
# Return type of an event callback
MsgAction = Coroutine[Any, Any, None]
# Type signature of an event callback function
Callback = Callable[[slack_util.Event, Match], MsgAction]
"""
Hooks
"""
# Signal exception to be raised when a hook has died
class HookDeath(Exception):
pass
# Abstract hook parent class
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, event: slack_util.Event) -> Optional[MsgAction]:
raise NotImplementedError()
class ChannelHook(AbsHook):
"""
Hook that handles messages in a variety of channels
"""
def __init__(self,
callback: Callback,
patterns: Union[str, List[str]],
channel_whitelist: Optional[List[str]] = None,
channel_blacklist: Optional[List[str]] = None,
consumer: bool = True,
allow_dms: bool = True):
super(ChannelHook, self).__init__(consumer)
# Save all
if not isinstance(patterns, list):
patterns = [patterns]
self.patterns = patterns
self.channel_whitelist = channel_whitelist
self.channel_blacklist = channel_blacklist
self.callback = callback
self.allows_dms = allow_dms
# Remedy some sensible defaults
if self.channel_blacklist is None:
self.channel_blacklist = ["#general"]
elif self.channel_whitelist is None:
pass # We leave as none to show no whitelisting in effect
else:
raise ValueError("Cannot whitelist and blacklist")
def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]:
"""
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.conversation and event.message):
return None
# Fail if pattern invalid
match = None
for p in self.patterns:
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
if isinstance(event.conversation.get_conversation(), slack_util.Channel):
channel_name = event.conversation.get_conversation().name
elif self.allows_dms:
channel_name = "DIRECT_MSG"
else:
return None
# Fail if whitelist defined, and we aren't there
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 channel_name in self.channel_blacklist:
return None
return self.callback(event, match)
class ReplyWaiter(AbsHook):
"""
A special hook that only cares about replies to a given message.
"""
def __init__(self, callback: Callback, pattern: str, thread_ts: str, lifetime: float):
super().__init__(True)
self.callback = callback
self.pattern = pattern
self.thread_ts = thread_ts
self.lifetime = lifetime
self.start_time = time()
self.dead = False
def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]:
# First check: are we dead of age yet?
time_alive = time() - self.start_time
should_expire = time_alive > self.lifetime
# If so, give up the ghost
if self.dead or should_expire:
raise HookDeath()
# 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 event.thread.thread_ts != self.thread_ts:
return None
# Does it match the regex? if not, ignore
match = re.match(self.pattern, event.message.text.strip(), flags=re.IGNORECASE)
if match:
self.dead = True
return self.callback(event, match)
else:
return None
class Passive(object):
"""
Base class for Periodical tasks, such as reminders and stuff
"""
async def run(self) -> None:
# Run this passive routed through the specified slack client.
raise NotImplementedError()