Message editing and improved block handling

This commit is contained in:
Jacob Henry 2019-03-03 19:53:25 -05:00
parent 7808a9abb7
commit 2d4c6141a7
3 changed files with 142 additions and 26 deletions

View File

@ -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):

View File

@ -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()

View File

@ -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.
"""