Message editing and improved block handling
This commit is contained in:
parent
7808a9abb7
commit
2d4c6141a7
51
client.py
51
client.py
|
|
@ -121,11 +121,14 @@ class ClientWrapper(object):
|
||||||
# Get the user who clicked the button
|
# Get the user who clicked the button
|
||||||
ev.user = slack_util.UserContext(payload["user"]["id"])
|
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
|
# Get the channel it was clicked in
|
||||||
ev.conversation = slack_util.ConversationContext(payload["channel"]["id"])
|
ev.conversation = slack_util.ConversationContext(payload["channel"]["id"])
|
||||||
|
|
||||||
# Get the message this button/action was attached to
|
# 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"],
|
payload["trigger_id"],
|
||||||
action["block_id"],
|
action["block_id"],
|
||||||
action["action_id"],
|
action["action_id"],
|
||||||
|
|
@ -185,7 +188,7 @@ class ClientWrapper(object):
|
||||||
def get_conversation_by_name(self, conversation_identifier: str) -> Optional[slack_util.Conversation]:
|
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 looking for a direct message, first lookup user, then fetch
|
||||||
if conversation_identifier[0] == "@":
|
if conversation_identifier[0] == "@":
|
||||||
user_name = conversation_identifier
|
# user_name = conversation_identifier
|
||||||
|
|
||||||
# Find the user by their name
|
# 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")
|
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)
|
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,
|
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.
|
Copy of the internal send message function of slack, with some helpful options.
|
||||||
Returns the JSON response.
|
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:
|
if thread:
|
||||||
kwargs["thread_ts"] = thread
|
kwargs["thread_ts"] = thread
|
||||||
if broadcast:
|
if broadcast:
|
||||||
kwargs["reply_broadcast"] = True
|
kwargs["reply_broadcast"] = True
|
||||||
|
elif broadcast:
|
||||||
|
raise ValueError("Can't broadcast a non-threaded message. Try again.")
|
||||||
|
|
||||||
|
# Set blocks iff provided.
|
||||||
if blocks:
|
if blocks:
|
||||||
kwargs["blocks"] = blocks
|
kwargs["blocks"] = blocks
|
||||||
|
|
||||||
return self.api_call(api_method, **kwargs)
|
return self.api_call(api_method, **kwargs)
|
||||||
|
|
||||||
def send_message(self,
|
def send_message(self,
|
||||||
text: str,
|
text: Optional[str],
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
thread: str = None,
|
thread: str = None,
|
||||||
broadcast: bool = False,
|
broadcast: bool = False,
|
||||||
blocks: List[dict] = None) -> dict:
|
blocks: Optional[List[dict]] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Wraps _send_core for normal messages
|
Wraps _send_core for normal messages
|
||||||
"""
|
"""
|
||||||
return self._send_core("chat.postMessage", text, channel_id, thread, broadcast, blocks)
|
return self._send_core("chat.postMessage", text, channel_id, thread, broadcast, blocks)
|
||||||
|
|
||||||
def send_ephemeral(self,
|
def send_ephemeral(self,
|
||||||
text: str,
|
text: Optional[str],
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
thread: str = None,
|
thread: str = None,
|
||||||
blocks: List[dict] = None) -> dict:
|
blocks: Optional[List[dict]] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Wraps _send_core for ephemeral messages
|
Wraps _send_core for ephemeral messages
|
||||||
"""
|
"""
|
||||||
return self._send_core("chat.postEphemeral", text, channel_id, thread, False, blocks)
|
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
|
# Update slack data
|
||||||
|
|
||||||
def update_channels(self):
|
def update_channels(self):
|
||||||
|
|
|
||||||
85
hooks.py
85
hooks.py
|
|
@ -2,15 +2,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from time import time
|
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
|
import slack_util
|
||||||
|
|
||||||
# Return type of an event callback
|
# Return type of an event callback
|
||||||
MsgAction = Coroutine[Any, Any, None]
|
MsgAction = Coroutine[Any, Any, None]
|
||||||
|
|
||||||
# Type signature of an event callback function
|
# Type signature of a message event callback function, for convenience
|
||||||
Callback = Callable[[slack_util.Event, Match], MsgAction]
|
MsgCallback = Callable[[slack_util.Event, Match], MsgAction]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Hooks
|
Hooks
|
||||||
|
|
@ -34,11 +34,12 @@ class AbsHook(object):
|
||||||
|
|
||||||
class ChannelHook(AbsHook):
|
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,
|
def __init__(self,
|
||||||
callback: Callback,
|
callback: MsgCallback,
|
||||||
patterns: Union[str, List[str]],
|
patterns: Union[str, List[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,
|
||||||
|
|
@ -69,7 +70,7 @@ class ChannelHook(AbsHook):
|
||||||
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
|
||||||
"""
|
"""
|
||||||
# Ensure that this is an event in a specific channel, with a text component
|
# 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
|
return None
|
||||||
|
|
||||||
# Fail if pattern invalid
|
# Fail if pattern invalid
|
||||||
|
|
@ -104,9 +105,11 @@ class ChannelHook(AbsHook):
|
||||||
class ReplyWaiter(AbsHook):
|
class ReplyWaiter(AbsHook):
|
||||||
"""
|
"""
|
||||||
A special hook that only cares about replies to a given message.
|
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)
|
super().__init__(True)
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
|
|
@ -125,7 +128,7 @@ class ReplyWaiter(AbsHook):
|
||||||
raise HookDeath()
|
raise HookDeath()
|
||||||
|
|
||||||
# Next make sure we're actually a message
|
# 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
|
return None
|
||||||
|
|
||||||
# Otherwise proceed normally
|
# Otherwise proceed normally
|
||||||
|
|
@ -134,7 +137,7 @@ class ReplyWaiter(AbsHook):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Does it match the regex? if not, ignore
|
# 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:
|
if match:
|
||||||
self.dead = True
|
self.dead = True
|
||||||
return self.callback(event, match)
|
return self.callback(event, match)
|
||||||
|
|
@ -142,6 +145,69 @@ class ReplyWaiter(AbsHook):
|
||||||
return None
|
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):
|
class Passive(object):
|
||||||
"""
|
"""
|
||||||
Base class for Periodical tasks, such as reminders and stuff
|
Base class for Periodical tasks, such as reminders and stuff
|
||||||
|
|
@ -150,4 +216,3 @@ class Passive(object):
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
# Run this passive routed through the specified slack client.
|
# Run this passive routed through the specified slack client.
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ Objects to represent things within a slack workspace
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
@dataclass
|
@dataclass
|
||||||
class User:
|
class User:
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -60,11 +61,19 @@ Objects to represent attributes an event may contain
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Event:
|
class Event:
|
||||||
|
# Info on if this event ocurred in a particular conversation/channel, and if so which one
|
||||||
conversation: Optional[ConversationContext] = None
|
conversation: Optional[ConversationContext] = None
|
||||||
|
# Info on if a particular user caused this event, and if so which one
|
||||||
user: Optional[UserContext] = None
|
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
|
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
|
bot: Optional[BotContext] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,20 +101,28 @@ class BotContext:
|
||||||
bot_id: str
|
bot_id: str
|
||||||
|
|
||||||
|
|
||||||
# Whether or not this is a threadable text message
|
# Whether this was a newly posted message
|
||||||
@dataclass
|
@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
|
ts: str
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
# Whether or not this is a threadable text message
|
||||||
@dataclass
|
@dataclass
|
||||||
class ThreadContext:
|
class ThreadContext:
|
||||||
thread_ts: str
|
thread_ts: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InteractiveContext:
|
class InteractionContext:
|
||||||
response_url: str # Used to confirm/respond to requests
|
response_url: str # Used to confirm/respond to requests
|
||||||
trigger_id: str # Used to open popups
|
trigger_id: str # Used to open popups
|
||||||
block_id: str # Identifies the block of the interacted component
|
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
|
# For now we only handle these basic types of messages involving text
|
||||||
# TODO: Handle "unwrappeable" messages
|
# TODO: Handle "unwrappeable" messages
|
||||||
if "text" in update and "ts" in update:
|
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:
|
if "channel" in update:
|
||||||
event.conversation = ConversationContext(update["channel"])
|
event.conversation = ConversationContext(update["channel"])
|
||||||
if "user" in update:
|
if "user" in update:
|
||||||
|
|
@ -181,8 +199,6 @@ def message_dict_to_event(update: dict) -> Event:
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Methods for easily responding to messages, etc.
|
Methods for easily responding to messages, etc.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue