aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--slack/config.py7
-rw-r--r--slack/error.py9
-rw-r--r--slack/slack_message.py301
-rw-r--r--typings/slack_api/slack_conversations_history.pyi218
4 files changed, 513 insertions, 22 deletions
diff --git a/slack/config.py b/slack/config.py
index 864ac18..5ea0cbc 100644
--- a/slack/config.py
+++ b/slack/config.py
@@ -90,6 +90,13 @@ class SlackConfigSectionColor:
WeeChatColor("blue"),
)
+ self.render_error = WeeChatOption(
+ self._section,
+ "render_error",
+ "color for displaying rendering errors in a message",
+ WeeChatColor("red"),
+ )
+
self.user_mention_color = WeeChatOption(
self._section,
"user_mention_color",
diff --git a/slack/error.py b/slack/error.py
index c978584..1a13504 100644
--- a/slack/error.py
+++ b/slack/error.py
@@ -92,8 +92,8 @@ def format_exception_only_str(exc: BaseException) -> str:
return format_exception_only(exc)[-1].strip()
-def store_and_format_exception(e: BaseException):
- uncaught_error = UncaughtError(e)
+def store_and_format_uncaught_error(uncaught_error: UncaughtError) -> str:
+ e = uncaught_error.exception
shared.uncaught_errors.append(uncaught_error)
stack_msg_command = f"/slack debug error {uncaught_error.id}"
stack_msg = f"run `{stack_msg_command}` for the stack trace"
@@ -120,3 +120,8 @@ def store_and_format_exception(e: BaseException):
)
else:
return f"Unknown error occurred: {format_exception_only_str(e)} ({stack_msg})"
+
+
+def store_and_format_exception(e: BaseException) -> str:
+ uncaught_error = UncaughtError(e)
+ return store_and_format_uncaught_error(uncaught_error)
diff --git a/slack/slack_message.py b/slack/slack_message.py
index ab72582..356f05a 100644
--- a/slack/slack_message.py
+++ b/slack/slack_message.py
@@ -2,11 +2,12 @@ from __future__ import annotations
import re
from enum import Enum
-from typing import TYPE_CHECKING, List, Match, Optional
+from typing import TYPE_CHECKING, List, Match, Optional, Union
import weechat
-from slack.log import print_exception_once
+from slack.error import UncaughtError, store_and_format_uncaught_error
+from slack.log import print_error, print_exception_once
from slack.python_compatibility import removeprefix, removesuffix
from slack.shared import shared
from slack.slack_user import format_bot_nick, nick_color
@@ -15,13 +16,54 @@ from slack.util import with_color
if TYPE_CHECKING:
from slack_api.slack_conversations_history import SlackMessage as SlackMessageDict
- from slack_api.slack_conversations_history import SlackMessageReaction
+ from slack_api.slack_conversations_history import (
+ SlackMessageBlock,
+ SlackMessageBlockCompositionText,
+ SlackMessageBlockElementImage,
+ SlackMessageBlockRichTextElement,
+ SlackMessageBlockRichTextList,
+ SlackMessageBlockRichTextSection,
+ SlackMessageReaction,
+ )
from typing_extensions import assert_never
from slack.slack_conversation import SlackConversation
from slack.slack_workspace import SlackWorkspace
+def convert_int_to_letter(num: int) -> str:
+ letter = ""
+ while num > 0:
+ num -= 1
+ letter = chr((num % 26) + 97) + letter
+ num //= 26
+ return letter
+
+
+def convert_int_to_roman(num: int) -> str:
+ roman_numerals = {
+ 1000: "m",
+ 900: "cm",
+ 500: "d",
+ 400: "cd",
+ 100: "c",
+ 90: "xc",
+ 50: "l",
+ 40: "xl",
+ 10: "x",
+ 9: "ix",
+ 5: "v",
+ 4: "iv",
+ 1: "i",
+ }
+ roman_numeral = ""
+ for value, symbol in roman_numerals.items():
+ while num >= value:
+ roman_numeral += symbol
+ num -= value
+ return roman_numeral
+
+
class MessagePriority(Enum):
LOW = 0
MESSAGE = 1
@@ -237,11 +279,16 @@ class SlackMessage:
return f"{await self._nick()} {text_action} {text_conversation_name}{inviter_text}"
- elif self._message_json.get("subtype") == "me_message":
- text = await self._unfurl_refs(self._message_json["text"])
- return f"{await self._nick()} {text}"
else:
- return await self._unfurl_refs(self._message_json["text"])
+ if "blocks" in self._message_json:
+ text = await self._render_blocks(self._message_json["blocks"])
+ else:
+ text = await self._unfurl_refs(self._message_json["text"])
+
+ if self._message_json.get("subtype") == "me_message":
+ return f"{await self._nick()} {text}"
+ else:
+ return text
async def _render_message(self) -> str:
if self._deleted:
@@ -322,7 +369,7 @@ class SlackMessage:
return re_mention.sub(unfurl_ref, message)
- def _get_emoji(self, emoji_name: str) -> str:
+ def _get_emoji(self, emoji_name: str, skin_tone: Optional[int] = None) -> str:
emoji_name_with_colons = f":{emoji_name}:"
if shared.config.look.render_emoji_as.value == "name":
return emoji_name_with_colons
@@ -331,10 +378,19 @@ class SlackMessage:
if emoji_item is None:
return emoji_name_with_colons
+ skin_tone_item = (
+ emoji_item.get("skinVariations", {}).get(str(skin_tone))
+ if skin_tone
+ else None
+ )
+ emoji_unicode = (
+ skin_tone_item["unicode"] if skin_tone_item else emoji_item["unicode"]
+ )
+
if shared.config.look.render_emoji_as.value == "emoji":
- return emoji_item["unicode"]
+ return emoji_unicode
elif shared.config.look.render_emoji_as.value == "both":
- return f"{emoji_item['unicode']}({emoji_name_with_colons})"
+ return f"{emoji_unicode}({emoji_name_with_colons})"
else:
assert_never(shared.config.look.render_emoji_as.value)
@@ -378,3 +434,228 @@ class SlackMessage:
)
else:
return ""
+
+ async def _render_blocks(self, blocks: List[SlackMessageBlock]) -> str:
+ block_texts: List[str] = []
+ for block in blocks:
+ try:
+ if block["type"] == "section":
+ fields = block.get("fields", [])
+ if "text" in block:
+ fields.insert(0, block["text"])
+ block_texts.extend(
+ self._render_block_element(field) for field in fields
+ )
+ elif block["type"] == "actions":
+ texts: List[str] = []
+ for element in block["elements"]:
+ if element["type"] == "button":
+ texts.append(self._render_block_element(element["text"]))
+ if "url" in element:
+ texts.append(element["url"])
+ else:
+ text = (
+ f'<Unsupported block action type "{element["type"]}">'
+ )
+ texts.append(
+ with_color(shared.config.color.render_error.value, text)
+ )
+ block_texts.append(" | ".join(texts))
+ elif block["type"] == "call":
+ url = block["call"]["v1"]["join_url"]
+ block_texts.append("Join via " + url)
+ elif block["type"] == "divider":
+ block_texts.append("---")
+ elif block["type"] == "context":
+ block_texts.append(
+ " | ".join(
+ self._render_block_element(element)
+ for element in block["elements"]
+ )
+ )
+ elif block["type"] == "image":
+ if "title" in block:
+ block_texts.append(self._render_block_element(block["title"]))
+ block_texts.append(self._render_block_element(block))
+ elif block["type"] == "rich_text":
+ for element in block.get("elements", []):
+ if element["type"] == "rich_text_section":
+ rendered = await self._render_block_rich_text_section(
+ element
+ )
+ if rendered:
+ block_texts.append(rendered)
+ elif element["type"] == "rich_text_list":
+ rendered = [
+ "{}{} {}".format(
+ " " * element.get("indent", 0),
+ self._render_block_rich_text_list_prefix(
+ element, item_index
+ ),
+ await self._render_block_rich_text_section(
+ item_element
+ ),
+ )
+ for item_index, item_element in enumerate(
+ element["elements"]
+ )
+ ]
+ block_texts.extend(rendered)
+ elif element["type"] == "rich_text_quote":
+ lines = [
+ f"> {line}"
+ for sub_element in element["elements"]
+ for line in (
+ await self._render_block_rich_text_element(
+ sub_element
+ )
+ ).split("\n")
+ ]
+ block_texts.extend(lines)
+ elif element["type"] == "rich_text_preformatted":
+ texts = [
+ sub_element.get("text", sub_element.get("url", ""))
+ for sub_element in element["elements"]
+ ]
+ if texts:
+ block_texts.append(f"```\n{''.join(texts)}\n```")
+ else:
+ text = f'<Unsupported rich text type "{element["type"]}">'
+ block_texts.append(
+ with_color(shared.config.color.render_error.value, text)
+ )
+ else:
+ text = f'<Unsupported block type "{block["type"]}">'
+ block_texts.append(
+ with_color(shared.config.color.render_error.value, text)
+ )
+ except Exception as e:
+ uncaught_error = UncaughtError(e)
+ print_error(store_and_format_uncaught_error(uncaught_error))
+ text = f"<Error rendering message, error id: {uncaught_error.id}>"
+ block_texts.append(
+ with_color(shared.config.color.render_error.value, text)
+ )
+
+ return "\n".join(block_texts)
+
+ async def _render_block_rich_text_section(
+ self, section: SlackMessageBlockRichTextSection
+ ) -> str:
+ texts: List[str] = []
+ prev_element: SlackMessageBlockRichTextElement = {"type": "text", "text": ""}
+ for element in section["elements"] + [prev_element.copy()]:
+ colors_apply: List[str] = []
+ colors_remove: List[str] = []
+ characters_apply: List[str] = []
+ characters_remove: List[str] = []
+ prev_style = prev_element.get("style", {})
+ cur_style = element.get("style", {})
+ if cur_style.get("bold", False) != prev_style.get("bold", False):
+ if cur_style.get("bold"):
+ colors_apply.append(weechat.color("bold"))
+ characters_apply.append("*")
+ else:
+ colors_remove.append(weechat.color("-bold"))
+ characters_remove.append("*")
+ if cur_style.get("italic", False) != prev_style.get("italic", False):
+ if cur_style.get("italic"):
+ colors_apply.append(weechat.color("italic"))
+ characters_apply.append("_")
+ else:
+ colors_remove.append(weechat.color("-italic"))
+ characters_remove.append("_")
+ if cur_style.get("strike", False) != prev_style.get("strike", False):
+ if cur_style.get("strike"):
+ characters_apply.append("~")
+ else:
+ characters_remove.append("~")
+ if cur_style.get("code", False) != prev_style.get("code", False):
+ if cur_style.get("code"):
+ characters_apply.append("`")
+ else:
+ characters_remove.append("`")
+
+ texts.extend(reversed(characters_remove))
+ texts.extend(reversed(colors_remove))
+ texts.extend(colors_apply)
+ texts.extend(characters_apply)
+ texts.append(await self._render_block_rich_text_element(element))
+ prev_element = element
+
+ text = "".join(texts)
+
+ if text.endswith("\n"):
+ return text[:-1]
+ else:
+ return text
+
+ async def _render_block_rich_text_element(
+ self, element: SlackMessageBlockRichTextElement
+ ) -> str:
+ if element["type"] == "text":
+ return element["text"]
+ elif element["type"] == "link":
+ if "text" in element:
+ if element.get("style", {}).get("code"):
+ return element["text"]
+ else:
+ return f"{element['url']} ({element['text']})"
+ else:
+ return element["url"]
+ elif element["type"] == "emoji":
+ return self._get_emoji(element["name"], element.get("skin_tone"))
+ elif element["type"] == "channel":
+ conversation = await self.workspace.conversations[element["channel_id"]]
+ name = await conversation.name_with_prefix("short_name_without_padding")
+ return with_color(shared.config.color.channel_mention_color.value, name)
+ elif element["type"] == "user":
+ user = await self.workspace.users[element["user_id"]]
+ name = f"@{user.nick()}"
+ return with_color(shared.config.color.user_mention_color.value, name)
+ elif element["type"] == "usergroup":
+ # TODO: Handle error
+ usergroup = await self.workspace.usergroups[element["usergroup_id"]]
+ name = f"@{usergroup.handle()}"
+ return with_color(shared.config.color.usergroup_mention_color.value, name)
+ elif element["type"] == "broadcast":
+ name = f"@{element['range']}"
+ return with_color(shared.config.color.usergroup_mention_color.value, name)
+ else:
+ text = f'<Unsupported rich text element type "{element["type"]}">'
+ return with_color(shared.config.color.render_error.value, text)
+
+ def _render_block_element(
+ self,
+ element: Union[SlackMessageBlockCompositionText, SlackMessageBlockElementImage],
+ ) -> str:
+ if element["type"] == "plain_text" or element["type"] == "mrkdwn":
+ # TODO: Support markdown, and verbatim and emoji properties
+ return element["text"]
+ elif element["type"] == "image":
+ if element.get("alt_text"):
+ return f"{element['image_url']} ({element['alt_text']})"
+ else:
+ return element["image_url"]
+ else:
+ text = f'<Unsupported block element type "{element["type"]}">'
+ return with_color(shared.config.color.render_error.value, text)
+
+ def _render_block_rich_text_list_prefix(
+ self, list_element: SlackMessageBlockRichTextList, item_index: int
+ ) -> str:
+ index = list_element.get("offset", 0) + item_index + 1
+ if list_element["style"] == "ordered":
+ if list_element["indent"] == 0 or list_element["indent"] == 3:
+ return f"{index}."
+ elif list_element["indent"] == 1 or list_element["indent"] == 4:
+ return f"{convert_int_to_letter(index)}."
+ else:
+ return f"{convert_int_to_roman(index)}."
+ else:
+ if list_element["indent"] == 0 or list_element["indent"] == 3:
+ return "•"
+ elif list_element["indent"] == 1 or list_element["indent"] == 4:
+ return "◦"
+ else:
+ return "▪︎"
diff --git a/typings/slack_api/slack_conversations_history.pyi b/typings/slack_api/slack_conversations_history.pyi
index ebedc78..cfb4a8a 100644
--- a/typings/slack_api/slack_conversations_history.pyi
+++ b/typings/slack_api/slack_conversations_history.pyi
@@ -7,21 +7,219 @@ from slack_rtm.slack_rtm_message import SlackMessageRtm
from typing_extensions import Literal, NotRequired, TypedDict, final
@final
-class SlackMessageBlockElement(TypedDict):
- type: str
- url: NotRequired[str]
+class SlackMessageBlockRichTextElementTextStyle(TypedDict):
+ bold: bool
+ italic: bool
+ strike: bool
+ code: bool
+
+@final
+class SlackMessageBlockRichTextElementText(TypedDict):
+ type: Literal["text"]
+ text: str
+ style: NotRequired[SlackMessageBlockRichTextElementTextStyle]
+
+@final
+class SlackMessageBlockRichTextElementLink(TypedDict):
+ type: Literal["link"]
+ url: str
+ text: NotRequired[str]
+ style: NotRequired[SlackMessageBlockRichTextElementTextStyle]
+
+@final
+class SlackMessageBlockRichTextElementEmoji(TypedDict):
+ type: Literal["emoji"]
+ name: str
+ unicode: str
+ skin_tone: int
+
+@final
+class SlackMessageBlockRichTextElementChannel(TypedDict):
+ type: Literal["channel"]
+ channel_id: str
+
+@final
+class SlackMessageBlockRichTextElementUser(TypedDict):
+ type: Literal["user"]
+ user_id: str
+
+@final
+class SlackMessageBlockRichTextElementUsergroup(TypedDict):
+ type: Literal["usergroup"]
+ usergroup_id: str
+
+@final
+class SlackMessageBlockRichTextElementBroadcast(TypedDict):
+ type: Literal["broadcast"]
+ range: Literal["channel", "here"]
+
+SlackMessageBlockRichTextElement = (
+ SlackMessageBlockRichTextElementText
+ | SlackMessageBlockRichTextElementLink
+ | SlackMessageBlockRichTextElementEmoji
+ | SlackMessageBlockRichTextElementChannel
+ | SlackMessageBlockRichTextElementUser
+ | SlackMessageBlockRichTextElementUsergroup
+ | SlackMessageBlockRichTextElementBroadcast
+)
+
+@final
+class SlackMessageBlockRichTextSection(TypedDict):
+ type: Literal["rich_text_section"]
+ elements: List[SlackMessageBlockRichTextElement]
+
+@final
+class SlackMessageBlockRichTextPreformatted(TypedDict):
+ type: Literal["rich_text_preformatted"]
+ elements: List[
+ SlackMessageBlockRichTextElementText | SlackMessageBlockRichTextElementLink
+ ]
+
+@final
+class SlackMessageBlockRichTextQuote(TypedDict):
+ type: Literal["rich_text_quote"]
+ elements: List[SlackMessageBlockRichTextElement]
+
+@final
+class SlackMessageBlockRichTextList(TypedDict):
+ type: Literal["rich_text_list"]
+ elements: List[SlackMessageBlockRichTextSection]
+ style: Literal["ordered", "bullet"]
+ indent: int
+ offset: int
+ border: int
+
+@final
+class SlackMessageBlockRichText(TypedDict):
+ type: Literal["rich_text"]
+ block_id: NotRequired[str]
+ elements: List[
+ SlackMessageBlockRichTextSection
+ | SlackMessageBlockRichTextPreformatted
+ | SlackMessageBlockRichTextQuote
+ | SlackMessageBlockRichTextList
+ ]
+
+@final
+class SlackMessageBlockCallV1(TypedDict):
+ id: str
+ app_id: str
+ app_icon_urls: object
+ date_start: int
+ active_participants: List[str]
+ all_participants: List[str]
+ display_id: str
+ join_url: str
+ desktop_app_join_url: str
+ name: str
+ created_by: str
+ date_end: int
+ channels: List[str]
+ is_dm_call: bool
+ was_rejected: bool
+ was_missed: bool
+ was_accepted: bool
+ has_ended: bool
+
+@final
+class SlackMessageBlockCallCall(TypedDict):
+ v1: SlackMessageBlockCallV1
+ media_backend_type: str
+
+@final
+class SlackMessageBlockCall(TypedDict):
+ type: Literal["call"]
+ block_id: NotRequired[str]
+ call_id: str
+ api_decoration_available: bool
+ call: SlackMessageBlockCallCall
+
+@final
+class SlackMessageBlockCompositionPlainText(TypedDict):
+ type: Literal["plain_text"]
text: str
+ emoji: NotRequired[bool]
+
+@final
+class SlackMessageBlockCompositionMrkdwn(TypedDict):
+ type: Literal["mrkdwn"]
+ text: str
+ verbatim: NotRequired[bool]
+
+SlackMessageBlockCompositionText = (
+ SlackMessageBlockCompositionPlainText | SlackMessageBlockCompositionMrkdwn
+)
+
+@final
+class SlackMessageBlockElementButton(TypedDict):
+ type: Literal["button"]
+ text: SlackMessageBlockCompositionPlainText
+ action_id: str
+ url: NotRequired[str]
+ value: NotRequired[str]
+ style: NotRequired[str]
+ confirm: NotRequired[object]
+ accessibility_label: NotRequired[str]
+
+@final
+class SlackMessageBlockElementImage(TypedDict):
+ type: Literal["image"]
+ image_url: str
+ alt_text: str
+
+SlackMessageBlockElementInteractive = SlackMessageBlockElementButton
+
+SlackMessageBlockElement = (
+ SlackMessageBlockElementInteractive | SlackMessageBlockElementImage
+)
@final
-class SlackMessageBlockElementParent(TypedDict):
- type: str
- elements: List[SlackMessageBlockElement]
+class SlackMessageBlockActions(TypedDict):
+ type: Literal["actions"]
+ block_id: NotRequired[str]
+ elements: List[SlackMessageBlockElementInteractive]
@final
-class SlackMessageBlock(TypedDict):
- type: str
- block_id: str
- elements: List[SlackMessageBlockElementParent]
+class SlackMessageBlockContext(TypedDict):
+ type: Literal["context"]
+ block_id: NotRequired[str]
+ elements: List[SlackMessageBlockCompositionText | SlackMessageBlockElementImage]
+
+@final
+class SlackMessageBlockDivider(TypedDict):
+ type: Literal["divider"]
+ block_id: NotRequired[str]
+
+@final
+class SlackMessageBlockImage(TypedDict):
+ type: Literal["image"]
+ block_id: NotRequired[str]
+ image_url: str
+ alt_text: str
+ title: NotRequired[SlackMessageBlockCompositionPlainText]
+ image_width: NotRequired[int]
+ image_height: NotRequired[int]
+ image_bytes: NotRequired[int]
+ is_animated: NotRequired[bool]
+ fallback: NotRequired[str]
+
+@final
+class SlackMessageBlockSection(TypedDict):
+ type: Literal["section"]
+ block_id: NotRequired[str]
+ text: NotRequired[SlackMessageBlockCompositionText]
+ fields: NotRequired[List[SlackMessageBlockCompositionText]]
+ accessory: NotRequired[SlackMessageBlockElement]
+
+SlackMessageBlock = (
+ SlackMessageBlockRichText
+ | SlackMessageBlockCall
+ | SlackMessageBlockActions
+ | SlackMessageBlockContext
+ | SlackMessageBlockDivider
+ | SlackMessageBlockImage
+ | SlackMessageBlockSection
+)
@final
class SlackMessageAttachment(TypedDict):