diff --git a/client.py b/client.py index ce6ebfe..718cbf9 100644 --- a/client.py +++ b/client.py @@ -121,11 +121,14 @@ class ClientWrapper(object): # Get the user who clicked the button ev.user = slack_util.UserContext(payload["user"]["id"]) + # Get the message that they clicked + ev.message = slack_util.RelatedMessageContext(payload["message"]["ts"], payload["message"]["text"]) + # Get the channel it was clicked in ev.conversation = slack_util.ConversationContext(payload["channel"]["id"]) # Get the message this button/action was attached to - ev.interaction = slack_util.InteractiveContext(payload["response_url"], + ev.interaction = slack_util.InteractionContext(payload["response_url"], payload["trigger_id"], action["block_id"], action["action_id"], @@ -185,7 +188,7 @@ class ClientWrapper(object): def get_conversation_by_name(self, conversation_identifier: str) -> Optional[slack_util.Conversation]: # If looking for a direct message, first lookup user, then fetch if conversation_identifier[0] == "@": - user_name = conversation_identifier + # user_name = conversation_identifier # Find the user by their name raise NotImplementedError("There wasn't a clear use case for this yet, so we've opted to just not use it") @@ -237,42 +240,74 @@ class ClientWrapper(object): return self.send_message(text, event.conversation.conversation_id) def _send_core(self, api_method: str, text: str, channel_id: str, thread: str, broadcast: bool, - blocks: List[dict]) -> dict: + blocks: Optional[List[dict]]) -> dict: """ Copy of the internal send message function of slack, with some helpful options. Returns the JSON response. """ - kwargs = {"channel": channel_id, "text": text} + # Check that text exists if there are no blocks + if blocks is None and text is None: + raise ValueError("Must provide blocks or texts or both.") + elif text is None: + text = "_Block message. Open slack client to view_" + + # Begin constructing kwargs with fields that _must_ exist + kwargs = {"channel": channel_id, "text": text, "as_user": True} + + # Deduce thread stuff if thread: kwargs["thread_ts"] = thread if broadcast: kwargs["reply_broadcast"] = True + elif broadcast: + raise ValueError("Can't broadcast a non-threaded message. Try again.") + + # Set blocks iff provided. if blocks: kwargs["blocks"] = blocks return self.api_call(api_method, **kwargs) def send_message(self, - text: str, + text: Optional[str], channel_id: str, thread: str = None, broadcast: bool = False, - blocks: List[dict] = None) -> dict: + blocks: Optional[List[dict]] = None) -> dict: """ Wraps _send_core for normal messages """ return self._send_core("chat.postMessage", text, channel_id, thread, broadcast, blocks) def send_ephemeral(self, - text: str, + text: Optional[str], channel_id: str, thread: str = None, - blocks: List[dict] = None) -> dict: + blocks: Optional[List[dict]] = None) -> dict: """ Wraps _send_core for ephemeral messages """ return self._send_core("chat.postEphemeral", text, channel_id, thread, False, blocks) + def edit_message(self, text: Optional[str], channel_id: str, message_ts: str, blocks: Optional[List[dict]] = None): + """ + Edits a message. + """ + # Check that text exists if there are no blocks + if blocks is None and text is None: + raise ValueError("Must provide blocks or texts or both.") + elif text is None: + text = "_Block message. Open slack client to view_" + + # Set the default args + kwargs = {"ts": message_ts, "channel": channel_id, "text": text, "as_user": True} + + # Set blocks if provided + if blocks is not None: + kwargs["blocks"] = blocks + + return self.api_call("chat.update", **kwargs) + # Update slack data def update_channels(self): diff --git a/hooks.py b/hooks.py index 8c60b4c..134f445 100644 --- a/hooks.py +++ b/hooks.py @@ -2,15 +2,15 @@ from __future__ import annotations import re from time import time -from typing import Match, Any, Coroutine, Callable, Optional, Union, List +from typing import Match, Any, Coroutine, Callable, Optional, Union, List, TypeVar, Dict 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] +# Type signature of a message event callback function, for convenience +MsgCallback = Callable[[slack_util.Event, Match], MsgAction] """ Hooks @@ -34,11 +34,12 @@ class AbsHook(object): class ChannelHook(AbsHook): """ - Hook that handles messages in a variety of channels + Hook that handles messages in a variety of channels. + Guarantees prescence of Post, Related, Conversation, and User """ def __init__(self, - callback: Callback, + callback: MsgCallback, patterns: Union[str, List[str]], channel_whitelist: Optional[List[str]] = None, channel_blacklist: Optional[List[str]] = None, @@ -69,7 +70,7 @@ 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.conversation and event.message): + if not (event.conversation and event.post and event.message and event.user): return None # Fail if pattern invalid @@ -104,9 +105,11 @@ class ChannelHook(AbsHook): class ReplyWaiter(AbsHook): """ A special hook that only cares about replies to a given message. + Guarantees presence of Post, Message, Thread, User, and Conversation. + As such, it ignores bots. """ - def __init__(self, callback: Callback, pattern: str, thread_ts: str, lifetime: float): + def __init__(self, callback: MsgCallback, pattern: str, thread_ts: str, lifetime: float): super().__init__(True) self.callback = callback self.pattern = pattern @@ -125,7 +128,7 @@ class ReplyWaiter(AbsHook): raise HookDeath() # Next make sure we're actually a message - if not (event.message and event.thread): + if not (event.post and event.post and event.thread and event.conversation and event.user): return None # Otherwise proceed normally @@ -134,7 +137,7 @@ class ReplyWaiter(AbsHook): return None # Does it match the regex? if not, ignore - match = re.match(self.pattern, event.message.text.strip(), flags=re.IGNORECASE) + match = re.match(self.pattern, event.post.text.strip(), flags=re.IGNORECASE) if match: self.dead = True return self.callback(event, match) @@ -142,6 +145,69 @@ class ReplyWaiter(AbsHook): return None +# The associated generic type of a button - value mapping +ActionVal = TypeVar("T") + + +class InteractionListener(AbsHook): + """ + Listens for replies on buttons. + Guarantees Interaction, Message, User, and Conversation + For fields that don't have a value of their own (such as buttons), + one can provide a mapping of action_ids to values. + In either case, the value is fed as a parameter to the callback + """ + + def __init__(self, + callback: Callable[[slack_util.Event, Union[ActionVal, str]], MsgAction], + action_bindings: Optional[Dict[str, ActionVal]], + # The action_id -> value mapping. Value fed to callback + message_ts: str, # Which message contains the block we care about + lifetime: float, # How long to keep listening + on_death: Optional[Callable[[], None]] # Function to call on death. For instance, if you want to delete the message + ): + super().__init__(True) + self.callback = callback + self.bindings = action_bindings + self.message_ts = message_ts + self.lifetime = lifetime + self.start_time = time() + self.on_death = on_death + 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: + if self.on_death: + self.on_death() + raise HookDeath() + + # Next make sure we're actually a message + if not (event.interaction and event.message): + return None + + # Otherwise proceed normally + # Is the msg the one we care about? If not, ignore + if event.message.ts != self.message_ts: + return None + + # Lookup the binding if we can/need to + value = event.interaction.action_value + if value is None and self.bindings is not None: + value = self.bindings.get(event.interaction.action_id) + + # If the value is still none, we have an issue! + if value is None: + raise ValueError("Couldn't find an appropriate value for interaction {}".format(event.interaction)) + + # Call the callback + return self.callback(event, value) + + class Passive(object): """ Base class for Periodical tasks, such as reminders and stuff @@ -150,4 +216,3 @@ class Passive(object): async def run(self) -> None: # Run this passive routed through the specified slack client. raise NotImplementedError() - diff --git a/slack_util.py b/slack_util.py index fa2d556..b12852c 100644 --- a/slack_util.py +++ b/slack_util.py @@ -19,6 +19,7 @@ Objects to represent things within a slack workspace """ +# noinspection PyUnresolvedReferences @dataclass class User: id: str @@ -60,11 +61,19 @@ Objects to represent attributes an event may contain @dataclass class Event: + # Info on if this event ocurred in a particular conversation/channel, and if so which one conversation: Optional[ConversationContext] = None + # Info on if a particular user caused this event, and if so which one user: Optional[UserContext] = None - message: Optional[MessageContext] = None + # Info on if this event was a someone posting a message. For contents see related + post: Optional[PostMessageContext] = None + # The content of the most relevant message to this event + message: Optional[RelatedMessageContext] = None + # Info if this event was threaded on a parent message, and if so what that message was thread: Optional[ThreadContext] = None - interaction: Optional[InteractiveContext] = None + # Info if this event was an interaction, and if so with what + interaction: Optional[InteractionContext] = None + # Info about regarding if bot caused this event, and if so which one bot: Optional[BotContext] = None @@ -92,20 +101,28 @@ class BotContext: bot_id: str -# Whether or not this is a threadable text message +# Whether this was a newly posted message @dataclass -class MessageContext: +class PostMessageContext: + pass + + +# Whether this event was related to a particular message, but not specifically posting it. +# To see if they posted it, check for PostMessageContext +@dataclass +class RelatedMessageContext: ts: str text: str +# Whether or not this is a threadable text message @dataclass class ThreadContext: thread_ts: str @dataclass -class InteractiveContext: +class InteractionContext: response_url: str # Used to confirm/respond to requests trigger_id: str # Used to open popups block_id: str # Identifies the block of the interacted component @@ -166,7 +183,8 @@ def message_dict_to_event(update: dict) -> Event: # For now we only handle these basic types of messages involving text # TODO: Handle "unwrappeable" messages if "text" in update and "ts" in update: - event.message = MessageContext(update["ts"], update["text"]) + event.message = RelatedMessageContext(update["ts"], update["text"]) + event.post = PostMessageContext() if "channel" in update: event.conversation = ConversationContext(update["channel"]) if "user" in update: @@ -181,8 +199,6 @@ def message_dict_to_event(update: dict) -> Event: return event - - """ Methods for easily responding to messages, etc. """