bots/triage-slackbot/triage_slackbot/handlers.py (481 lines of code) (raw):

import typing as t from enum import Enum from logging import getLogger from openai_slackbot.clients.slack import CreateSlackMessageResponse, SlackClient from openai_slackbot.handlers import BaseActionHandler, BaseHandler, BaseMessageHandler from openai_slackbot.utils.slack import ( RenderedSlackBlock, block_id_exists, extract_text_from_event, get_block_by_id, remove_block_id_if_exists, render_slack_id_to_mention, render_slack_url, ) from triage_slackbot.category import RequestCategory from triage_slackbot.config import get_config from triage_slackbot.openai_utils import get_predicted_category logger = getLogger(__name__) class BlockId(str, Enum): # Block that will be rendered if on-call recagorizes inbound request but doesn't select a new category. empty_category_warning = "empty_category_warning_block" # Block that will be rendreed if on-call recategorizes inbound request to "Other" but doesn't select a conversation. empty_conversation_warning = "empty_conversation_warning_block" # Block that will be rendered to show on-call all the remaining categories to route to. recategorize_select_category = "recategorize_select_category_block" # Block that will be rendered to show on-call all the conversations they can reroute the user # to, if they select "Other" as the category. recategorize_select_conversation = "recategorize_select_conversation_block" class MessageTemplatePath(str, Enum): # Template for feed channel message that summarizes triage updates. feed = "messages/feed.j2" # Template for message that notifies oncall about inbound request in the same channel as the feed channel. notify_oncall_in_feed = "messages/notify_oncall_in_feed.j2" # Template for message that notifies oncall about inbound request in a different channel from the feed channel. notify_oncall_channel = "messages/notify_oncall_channel.j2" # Template for message that will autorespond to inbound requests. autorespond = "messages/autorespond.j2" BlockIdToTemplatePath: t.Dict[BlockId, str] = { BlockId.empty_category_warning: "blocks/empty_category_warning.j2", BlockId.empty_conversation_warning: "blocks/empty_conversation_warning.j2", BlockId.recategorize_select_conversation: "blocks/select_conversation.j2", } class InboundRequestHandlerMixin(BaseHandler): def __init__(self, slack_client: SlackClient) -> None: super().__init__(slack_client) self.config = get_config() def render_block_if_not_exists( self, *, block_id: BlockId, blocks: t.List[RenderedSlackBlock] ) -> t.List[RenderedSlackBlock]: if not block_id_exists(blocks, block_id): template_path = BlockIdToTemplatePath[block_id] block = self._slack_client.render_blocks_from_template(template_path) blocks.append(block) return blocks def get_selected_category(self, body: t.Dict[str, t.Any]) -> t.Optional[RequestCategory]: category = ( body["state"] .get("values", {}) .get(BlockId.recategorize_select_category, {}) .get("recategorize_select_category_action", {}) .get("selected_option", {}) or {} ).get("value") if not category: return None return self.config.categories[category] def get_selected_conversation(self, body: t.Dict[str, t.Any]) -> t.Optional[str]: return ( body["state"] .get("values", {}) .get(BlockId.recategorize_select_conversation, {}) .get("recategorize_select_conversation_action", {}) .get("selected_conversation") ) async def notify_oncall( self, *, predicted_category: RequestCategory, selected_conversation: t.Optional[str], remaining_categories: t.List[RequestCategory], inbound_message_channel: str, inbound_message_ts: str, feed_message_channel: str, feed_message_ts: str, inbound_message_url: str, ) -> None: autoresponded = await self._maybe_autorespond( predicted_category, selected_conversation, inbound_message_channel, inbound_message_ts, feed_message_channel, feed_message_ts, ) if autoresponded: logger.info(f"Autoresponded to inbound request: {inbound_message_url}") return # This metadata will continue to be passed along to the subsequent # notify on-call messages. metadata = { "event_type": "notify_oncall", "event_payload": { "inbound_message_channel": inbound_message_channel, "inbound_message_ts": inbound_message_ts, "feed_message_channel": feed_message_channel, "feed_message_ts": feed_message_ts, "inbound_message_url": inbound_message_url, "predicted_category": predicted_category.key, }, } block_args = { "predicted_category": predicted_category, "remaining_categories": remaining_categories, "inbound_message_channel": inbound_message_channel, } if predicted_category.route_to_channel: channel = predicted_category.oncall_slack_id thread_ts = None # This will be a new message, not a thread. blocks = await self._get_notify_oncall_channel_blocks( **block_args, inbound_message_url=inbound_message_url, ) else: channel = feed_message_channel thread_ts = feed_message_ts # Post this as a thread reply to the original feed message. blocks = await self._get_notify_oncall_in_feed_blocks(**block_args) await self._slack_client.post_message( channel=channel, thread_ts=thread_ts, blocks=blocks, metadata=metadata, text="Notify on-call for new inbound request", ) async def _get_notify_oncall_in_feed_blocks( self, *, predicted_category: RequestCategory, remaining_categories: t.List[RequestCategory], inbound_message_channel: str, ): oncall_mention = self._get_oncall_mention(predicted_category) predicted_category_display_name = predicted_category.display_name oncall_greeting = ( f":wave: Hi {oncall_mention}" if oncall_mention else f"No on-call defined for {predicted_category_display_name}" ) return self._slack_client.render_blocks_from_template( MessageTemplatePath.notify_oncall_in_feed.value, { "predicted_category": predicted_category_display_name, "oncall_greeting": oncall_greeting, "options": RequestCategory.to_block_options(remaining_categories), "inbound_message_channel": inbound_message_channel, }, ) async def _get_notify_oncall_channel_blocks( self, *, predicted_category: RequestCategory, remaining_categories: t.List[RequestCategory], inbound_message_channel: str, inbound_message_url: str, ): return self._slack_client.render_blocks_from_template( MessageTemplatePath.notify_oncall_channel.value, { "inbound_message_url": inbound_message_url, "inbound_message_channel": inbound_message_channel, "predicted_category": predicted_category.display_name, "options": RequestCategory.to_block_options(remaining_categories), }, ) def _get_oncall_mention(self, predicted_category: RequestCategory) -> t.Optional[str]: oncall_slack_id = predicted_category.oncall_slack_id return render_slack_id_to_mention(oncall_slack_id) if oncall_slack_id else None async def _maybe_autorespond( self, predicted_category: RequestCategory, selected_conversation: t.Optional[str], inbound_message_channel: str, inbound_message_ts: str, feed_message_channel: str, feed_message_ts: str, ) -> bool: if not predicted_category.autorespond: return False text = "Hi, thanks for reaching out!" if predicted_category.autorespond_message: rendered_selected_conversation = ( render_slack_id_to_mention(selected_conversation) if selected_conversation else None ) text += ( f" {predicted_category.autorespond_message.format(rendered_selected_conversation)}" ) blocks = self._slack_client.render_blocks_from_template( MessageTemplatePath.autorespond.value, {"text": text} ) message = await self._slack_client.post_message( channel=inbound_message_channel, thread_ts=inbound_message_ts, text=text, blocks=blocks, ) message_link = await self._slack_client.get_message_link( channel=message.channel, message_ts=message.ts ) # Post an update to the feed channel. feed_message = ( f"{render_slack_url(url=message_link, text='Autoresponded')} to inbound request." ) await self._slack_client.post_message( channel=feed_message_channel, thread_ts=feed_message_ts, text=feed_message ) return True class InboundRequestHandler(BaseMessageHandler, InboundRequestHandlerMixin): """ Handles inbound requests in inbound request channel. """ async def handle(self, args): event = args.event channel = event.get("channel") ts = event.get("ts") logging_extra = self.logging_extra(args) text = extract_text_from_event(event) if not text: logger.info("No text in event, done processing", extra=logging_extra) return predicted_category = await self._predict_category(text) logger.info(f"Predicted category: {predicted_category}", extra=logging_extra) message_link = await self._slack_client.get_message_link(channel=channel, message_ts=ts) feed_message = await self._update_feed( predicted_category=predicted_category, message_channel=channel, message_link=message_link, ) logger.info( f"Updated feed channel for inbound message link: {message_link}", extra=logging_extra, ) remaining_categories = [ r for r in self.config.categories.values() if r != predicted_category ] await self.notify_oncall( predicted_category=predicted_category, selected_conversation=None, remaining_categories=remaining_categories, inbound_message_channel=channel, inbound_message_ts=ts, feed_message_channel=feed_message.channel, feed_message_ts=feed_message.ts, inbound_message_url=message_link, ) logger.info("Notified on-call", extra=logging_extra) async def should_handle(self, args): event = args.event return ( event["channel"] == self.config.inbound_request_channel_id and # Don't respond to messages in threads (with the exception of thread replies # that are also sent to the channel) ( ( event.get("thread_ts") is None and (not event.get("subtype") or event.get("subtype") == "file_share") ) or event.get("subtype") == "thread_broadcast" ) ) async def _predict_category(self, body) -> RequestCategory: predicted_category = await get_predicted_category(body) return self.config.categories[predicted_category] async def _update_feed( self, *, predicted_category: RequestCategory, message_channel: str, message_link: str, ) -> CreateSlackMessageResponse: oncall_mention = self._get_oncall_mention(predicted_category) or "No on-call assigned" blocks = self._slack_client.render_blocks_from_template( MessageTemplatePath.feed.value, { "predicted_category": predicted_category.display_name, "inbound_message_channel": message_channel, "inbound_message_url": message_link, "oncall_mention": oncall_mention, }, ) message = await self._slack_client.post_message( channel=self.config.feed_channel_id, blocks=blocks, text="New inbound request received", ) return message class InboundRequestAcknowledgeHandler(BaseActionHandler, InboundRequestHandlerMixin): """ Once InboundRequestHandler has predicted the category of an inbound request and notifies the corresponding on-call, this handler will be called if on-call acknowledges the prediction, i.e. they think the prediction is accurate. """ @property def action_id(self): return "acknowledge_submit_action" async def handle(self, args): body = args.body notify_oncall_msg = body["container"] notify_oncall_msg_ts = notify_oncall_msg["message_ts"] notify_oncall_msg_channel = notify_oncall_msg["channel_id"] feed_message_metadata = body["message"].get("metadata", {}).get("event_payload", {}) feed_message_ts = feed_message_metadata["feed_message_ts"] feed_message_channel = feed_message_metadata["feed_message_channel"] inbound_message_url = feed_message_metadata["inbound_message_url"] predicted_category = feed_message_metadata["predicted_category"] # Oncall that was notified. user = body["user"] await self._slack_client.update_message( blocks=[], channel=notify_oncall_msg_channel, ts=notify_oncall_msg_ts, # If oncall is notified in the feed channel, don't need to include # the inbound message URL since oncall will be notified in the feed # message thread, and the URL is already in the original message. text=self._get_message( user=user, category=predicted_category, inbound_message_url=inbound_message_url, with_url=notify_oncall_msg_channel != feed_message_channel, ), ) # If oncall gets notified in a separate channel and not the feed channel, # update the feed thread with the acknowledgment. if notify_oncall_msg_channel != feed_message_channel: await self._slack_client.post_message( blocks=[], channel=feed_message_channel, thread_ts=feed_message_ts, text=self._get_message( user=user, category=predicted_category, inbound_message_url=inbound_message_url, with_url=False, ), ) feed_message = await self._slack_client.get_message( channel=feed_message_channel, ts=feed_message_ts ) if feed_message: # If the original message has been thumbs-downed, this means # that the bot's original prediction is wrong, so don't thumbs # up the feed message. wrong_original_prediction = any( [r["name"] == "-1" for r in feed_message.get("reactions", [])] ) if not wrong_original_prediction: await self._slack_client.add_reaction( channel=feed_message_channel, name="thumbsup", timestamp=feed_message_ts, ) def _get_message( self, user: t.Dict, category: str, inbound_message_url: str, with_url: bool ) -> str: message = f":thumbsup: {render_slack_id_to_mention(user['id'])} acknowledged the " if with_url: message += render_slack_url(url=inbound_message_url, text="inbound message") else: message += "inbound message" return f"{message} triaged to {self.config.categories[category].display_name}." class InboundRequestRecategorizeHandler(BaseActionHandler, InboundRequestHandlerMixin): """ This handler will be called if on-call wants to recategorize the request that they get notified about. """ @property def action_id(self): return "recategorize_submit_action" async def handle(self, args): body = args.body notify_oncall_msg = body["container"] notify_oncall_msg_ts = notify_oncall_msg["message_ts"] notify_oncall_msg_channel = notify_oncall_msg["channel_id"] msg_metadata = body["message"].get("metadata", {}).get("event_payload", {}) feed_message_ts = msg_metadata["feed_message_ts"] feed_message_channel = msg_metadata["feed_message_channel"] inbound_message_url = msg_metadata["inbound_message_url"] # Predicted category that turned out to be incorrect # and wanted to be recategorized. predicted_category = self.config.categories[msg_metadata.pop("predicted_category")] assert predicted_category user: t.Dict = body["user"] notify_oncall_msg_blocks = body["message"]["blocks"] selection_block = get_block_by_id( notify_oncall_msg_blocks, BlockId.recategorize_select_category ) remaining_category_keys: t.List[str] = [ o["value"] for o in selection_block["accessory"]["options"] ] selected_category: t.Optional[RequestCategory] = self.get_selected_category(body) selected_conversation: t.Optional[str] = self.get_selected_conversation(body) valid, notify_oncall_msg_blocks = await self._validate_selection( selected_category, selected_conversation, notify_oncall_msg_blocks ) if valid: assert selected_category, "selected_category should be set if valid" message_kwargs = { "user": user, "predicted_category": predicted_category, "selected_category": selected_category, "selected_conversation": selected_conversation, "inbound_message_url": inbound_message_url, } await self._slack_client.update_message( blocks=[], channel=notify_oncall_msg_channel, ts=notify_oncall_msg_ts, # If the feed message is in the same channel as the notify on-call message, don't need to include # the URL since it's already in the original feed message. text=self._get_message( **message_kwargs, with_url=notify_oncall_msg_channel != feed_message_channel, ), ) # Indicate that the previous predicted category is not accurate. await self._slack_client.add_reaction( channel=feed_message_channel, name="thumbsdown", timestamp=feed_message_ts, ) # If the feed message is in a different channel than the notify on-call message, # post recategorization update to the feed channel. if notify_oncall_msg_channel != feed_message_channel: await self._slack_client.post_message( blocks=[], channel=feed_message_channel, thread_ts=feed_message_ts, text=self._get_message(**message_kwargs, with_url=False), ) remaining_categories = [ self.config.categories[category_key] for category_key in remaining_category_keys if category_key != selected_category.key ] # Route this to the next oncall. await self.notify_oncall( predicted_category=selected_category, selected_conversation=selected_conversation, remaining_categories=remaining_categories, **msg_metadata, ) else: # Display warning. await self._slack_client.update_message( blocks=notify_oncall_msg_blocks, channel=notify_oncall_msg_channel, ts=notify_oncall_msg_ts, text="", ) def _get_message( self, *, user: t.Dict, predicted_category: RequestCategory, selected_category: RequestCategory, selected_conversation: t.Optional[str], inbound_message_url: str, with_url: bool, ) -> str: rendered_selected_conversation = ( render_slack_id_to_mention(selected_conversation) if selected_conversation else None ) selected_category_display_name = selected_category.display_name.format( rendered_selected_conversation ) message_text = f"<{inbound_message_url}|inbound message>" if with_url else "inbound message" return f":thumbsdown: {render_slack_id_to_mention(user['id'])} reassigned the {message_text} from {predicted_category.display_name} to: {selected_category_display_name}." async def _validate_selection( self, selected_category: t.Optional[RequestCategory], selected_conversation: t.Optional[str], blocks: t.List[RenderedSlackBlock], ) -> t.Tuple[bool, t.List[RenderedSlackBlock]]: if not selected_category: return False, self.render_block_if_not_exists( block_id=BlockId.empty_category_warning, blocks=blocks ) elif selected_category.is_other() and not selected_conversation: return False, self.render_block_if_not_exists( block_id=BlockId.empty_conversation_warning, blocks=blocks ) return True, blocks class InboundRequestRecategorizeSelectHandler(BaseActionHandler, InboundRequestHandlerMixin): """ This handler will be called if on-call selects a new category for a request they get notififed about. """ @property def action_id(self): return "recategorize_select_category_action" async def handle(self, args): body = args.body notify_oncall_msg = body["container"] notify_oncall_msg_ts = notify_oncall_msg["message_ts"] notify_oncall_msg_channel = notify_oncall_msg["channel_id"] notify_oncall_msg_blocks = body["message"]["blocks"] notify_oncall_msg_blocks = remove_block_id_if_exists( notify_oncall_msg_blocks, BlockId.empty_category_warning ) selected_category = self.get_selected_category(body) if selected_category.is_other(): # Prompt on-call to select a conversation if Other category is selected. notify_oncall_msg_blocks = self.render_block_if_not_exists( block_id=BlockId.recategorize_select_conversation, blocks=notify_oncall_msg_blocks, ) else: # Remove warning if on-call updates their selection from Other to non-Other. notify_oncall_msg_blocks = remove_block_id_if_exists( notify_oncall_msg_blocks, BlockId.recategorize_select_conversation ) # Update message with warnings, if any. await self._slack_client.update_message( blocks=notify_oncall_msg_blocks, channel=notify_oncall_msg_channel, ts=notify_oncall_msg_ts, ) class InboundRequestRecategorizeSelectConversationHandler(BaseActionHandler): """ This handler will be called if on-call selects a conversation to route the request to. """ @property def action_id(self): return "recategorize_select_conversation_action" async def handle(self, args): pass