aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTrygve Aaberge <trygveaa@gmail.com>2023-10-14 13:54:15 +0200
committerTrygve Aaberge <trygveaa@gmail.com>2024-02-18 11:32:54 +0100
commit205363244afab703127cbb8d836505684c73a284 (patch)
tree2e270240cb389542fdcc190e218acaa2f422e3d2
parent62dd42910c42097f857181f7a65061f47c5ec2e4 (diff)
downloadwee-slack-205363244afab703127cbb8d836505684c73a284.tar.gz
Support highlight notifications without rendering history
-rw-r--r--slack/slack_message.py691
-rw-r--r--slack/util.py20
-rw-r--r--typings/slack_api/slack_conversations_history.pyi1
3 files changed, 383 insertions, 329 deletions
diff --git a/slack/slack_message.py b/slack/slack_message.py
index 134adfa..dd19356 100644
--- a/slack/slack_message.py
+++ b/slack/slack_message.py
@@ -4,7 +4,7 @@ import re
from collections import OrderedDict
from datetime import date, datetime, timedelta
from enum import Enum
-from typing import TYPE_CHECKING, List, Match, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Generator, List, Match, Optional, Union
import weechat
@@ -14,13 +14,12 @@ from slack.error import (
store_and_format_uncaught_error,
store_uncaught_error,
)
-from slack.log import print_error, print_exception_once
+from slack.log import print_error
from slack.python_compatibility import removeprefix, removesuffix
from slack.shared import shared
from slack.slack_user import SlackBot, SlackUser, format_bot_nick, nick_color
from slack.task import gather
-from slack.util import with_color
-from slack.weechat_config import WeeChatColor
+from slack.util import intersperse, with_color
if TYPE_CHECKING:
from slack_api.slack_conversations_history import SlackMessage as SlackMessageDict
@@ -29,9 +28,6 @@ if TYPE_CHECKING:
SlackMessageBlockCompositionText,
SlackMessageBlockElementImage,
SlackMessageBlockRichTextElement,
- SlackMessageBlockRichTextElementBroadcast,
- SlackMessageBlockRichTextElementUser,
- SlackMessageBlockRichTextElementUsergroup,
SlackMessageBlockRichTextList,
SlackMessageBlockRichTextSection,
SlackMessageFile,
@@ -45,19 +41,42 @@ if TYPE_CHECKING:
from slack.slack_thread import SlackThread
from slack.slack_workspace import SlackWorkspace
- Mentions = List[
- Union[
- SlackMessageBlockRichTextElementUser,
- SlackMessageBlockRichTextElementUsergroup,
- SlackMessageBlockRichTextElementBroadcast,
- ]
- ]
-
def unhtmlescape(text: str) -> str:
return text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
+def format_date(timestamp: int, token_string: str, link: Optional[str] = None) -> str:
+ ref_datetime = datetime.fromtimestamp(timestamp)
+ link_suffix = f" ({link})" if link else ""
+ token_to_format = {
+ "date_num": "%Y-%m-%d",
+ "date": "%B %d, %Y",
+ "date_short": "%b %d, %Y",
+ "date_long": "%A, %B %d, %Y",
+ "time": "%H:%M",
+ "time_secs": "%H:%M:%S",
+ }
+
+ def replace_token(match: Match[str]):
+ token = match.group(1)
+ if token.startswith("date_") and token.endswith("_pretty"):
+ if ref_datetime.date() == date.today():
+ return "today"
+ elif ref_datetime.date() == date.today() - timedelta(days=1):
+ return "yesterday"
+ elif ref_datetime.date() == date.today() + timedelta(days=1):
+ return "tomorrow"
+ else:
+ token = token.replace("_pretty", "")
+ if token in token_to_format:
+ return ref_datetime.strftime(token_to_format[token])
+ else:
+ return match.group(0)
+
+ return re.sub(r"{([^}]+)}", replace_token, token_string) + link_suffix
+
+
def convert_int_to_letter(num: int) -> str:
letter = ""
while num > 0:
@@ -143,12 +162,85 @@ class SlackTs(str):
return self._cmp(other) <= 0
+# TODO: Add fallback_name for when it can't be looked up
+class PendingMessageItem:
+ def __init__(
+ self,
+ message: SlackMessage,
+ item_type: Literal[
+ "conversation", "user", "usergroup", "broadcast", "message_nick"
+ ],
+ item_id: str,
+ display_type: Literal["mention", "chat"] = "mention",
+ ):
+ self.message = message
+ self.item_type: Literal[
+ "conversation", "user", "usergroup", "broadcast", "message_nick"
+ ] = item_type
+ self.item_id = item_id
+ self.display_type: Literal["mention", "chat"] = display_type
+
+ async def resolve(self) -> str:
+ if self.item_type == "conversation":
+ conversation = await self.message.workspace.conversations[self.item_id]
+ name = await conversation.name_with_prefix("short_name_without_padding")
+ if self.display_type == "mention":
+ color = shared.config.color.channel_mention_color.value
+ elif self.display_type == "chat":
+ color = "chat_channel"
+ else:
+ assert_never(self.display_type)
+ return with_color(color, name)
+
+ elif self.item_type == "user":
+ user = await self.message.workspace.users[self.item_id]
+ if self.display_type == "mention":
+ name = f"@{user.nick()}"
+ return with_color(shared.config.color.user_mention_color.value, name)
+ elif self.display_type == "chat":
+ return user.nick(colorize=True)
+ else:
+ assert_never(self.display_type)
+
+ elif self.item_type == "usergroup":
+ # TODO: Handle error
+ usergroup = await self.message.workspace.usergroups[self.item_id]
+ name = f"@{usergroup.handle()}"
+ return with_color(shared.config.color.usergroup_mention_color.value, name)
+
+ elif self.item_type == "broadcast":
+ name = f"@{self.item_id}"
+ return with_color(shared.config.color.usergroup_mention_color.value, name)
+
+ elif self.item_type == "message_nick":
+ return await self.message.nick()
+
+ else:
+ assert_never(self.item_type)
+
+ def should_highlight(self) -> bool:
+ if self.item_type == "conversation":
+ return False
+ elif self.item_type == "user":
+ return self.item_id == self.message.workspace.my_user.id
+ elif self.item_type == "usergroup":
+ # TODO
+ return False
+ elif self.item_type == "broadcast":
+ # TODO: figure out how to handle here broadcast
+ return True
+ elif self.item_type == "message_nick":
+ return False
+ else:
+ assert_never(self.item_type)
+
+
class SlackMessage:
def __init__(self, conversation: SlackConversation, message_json: SlackMessageDict):
self._message_json = message_json
self._rendered_prefix = None
self._rendered_message = None
- self._mentions: Optional[Mentions] = None
+ self._parsed_message: Optional[List[Union[str, PendingMessageItem]]] = None
self.conversation = conversation
self.ts = SlackTs(message_json["ts"])
self.replies: OrderedDict[SlackTs, SlackMessage] = OrderedDict()
@@ -242,10 +334,11 @@ class SlackMessage:
def reactions(self) -> List[SlackMessageReaction]:
return self._message_json.get("reactions", [])
- # This does not account for highlights
@property
def priority(self) -> MessagePriority:
- if self.subtype in [
+ if self.should_highlight():
+ return MessagePriority.HIGHLIGHT
+ elif self.subtype in [
"channel_join",
"group_join",
"channel_leave",
@@ -257,7 +350,6 @@ class SlackMessage:
else:
return MessagePriority.MESSAGE
- # This does not account for highlights
@property
def priority_notify_tag(self) -> Optional[str]:
priority = self.priority
@@ -280,6 +372,7 @@ class SlackMessage:
def deleted(self, value: bool):
self._deleted = value
self._rendered_message = None
+ self._parsed_message = None
def update_message_json(self, message_json: SlackMessageDict):
self._message_json.update(
@@ -287,11 +380,13 @@ class SlackMessage:
)
self._rendered_prefix = None
self._rendered_message = None
+ self._parsed_message = None
def update_message_json_room(self, room: SlackMessageSubtypeHuddleThreadRoom):
if "room" in self._message_json:
self._message_json["room"] = room
self._rendered_message = None
+ self._parsed_message = None
async def update_subscribed(
self, subscribed: bool, subscription: SlackThreadSubscription
@@ -326,24 +421,13 @@ class SlackMessage:
reaction["count"] -= 1
self._rendered_message = None
- async def should_highlight(self) -> bool:
+ def should_highlight(self) -> bool:
# TODO: Highlight words from user preferences
- mentions = self._mentions
- if mentions is None:
- _, mentions = await self._render_message()
-
- for mention in mentions:
- if mention["type"] == "user":
- if mention["user_id"] == self.workspace.my_user.id:
- return True
- elif mention["type"] == "usergroup":
- # TODO
- pass
- elif mention["type"] == "broadcast":
- # TODO: figure out how to handle here broadcast
+ parsed_message = self._parse_message_text()
+
+ for item in parsed_message:
+ if isinstance(item, PendingMessageItem) and item.should_highlight():
return True
- else:
- assert_never(mention)
return False
@@ -385,8 +469,6 @@ class SlackMessage:
if user and user.is_self:
tags.append("self_msg")
log_tags = ["notify_none", "no_highlight", "log1"]
- elif await self.should_highlight():
- log_tags = ["notify_highlight", "log1"]
else:
log_tags = ["log1"]
notify_tag = self.priority_notify_tag
@@ -440,8 +522,18 @@ class SlackMessage:
self._rendered_prefix = await self._render_prefix()
return self._rendered_prefix
- async def _render_message_text(self) -> Tuple[str, Mentions]:
- if self._message_json.get("subtype") in [
+ def _parse_message_text(
+ self, update: bool = False
+ ) -> List[Union[str, PendingMessageItem]]:
+ if self._parsed_message is not None and not update:
+ return self._parsed_message
+
+ if self.deleted:
+ self._parsed_message = [
+ with_color(shared.config.color.deleted_message.value, "(deleted)")
+ ]
+
+ elif self._message_json.get("subtype") in [
"channel_join",
"group_join",
"channel_leave",
@@ -456,22 +548,26 @@ class SlackMessage:
if is_join
else f"{with_color(shared.config.color.message_quit.value, 'has left')}"
)
- conversation_name = await self.conversation.name_with_prefix(
- "short_name_without_padding"
+ conversation_item = PendingMessageItem(
+ self, "conversation", self.conversation.id, "chat"
)
- text_conversation_name = f"{with_color('chat_channel', conversation_name)}"
inviter_id = self._message_json.get("inviter")
if is_join and inviter_id:
- inviter_user = await self.workspace.users[inviter_id]
- inviter_text = f" by invitation from {inviter_user.nick(colorize=True)}"
+ inviter_items = [
+ " by invitation from ",
+ PendingMessageItem(self, "user", inviter_id, "chat"),
+ ]
else:
- inviter_text = ""
+ inviter_items = []
- return (
- f"{await self.nick()} {text_action} {text_conversation_name}{inviter_text}",
- [],
- )
+ self._parsed_message = [
+ PendingMessageItem(self, "message_nick", ""),
+ " ",
+ text_action,
+ " ",
+ conversation_item,
+ ] + inviter_items
elif (
"subtype" in self._message_json
@@ -482,163 +578,108 @@ class SlackMessage:
huddle_text = "Huddle started" if not room["has_ended"] else "Huddle ended"
name_text = f", name: {room['name']}" if room["name"] else ""
- texts: List[str] = [huddle_text + name_text]
+ texts: List[Union[str, PendingMessageItem]] = [huddle_text + name_text]
for channel_id in room["channels"]:
texts.append(
- f"https://app.slack.com/client/{team}/{channel_id}?open=start_huddle"
+ f"\nhttps://app.slack.com/client/{team}/{channel_id}?open=start_huddle"
)
- return "\n".join(texts), []
+ self._parsed_message = texts
else:
if "blocks" in self._message_json:
- texts, mentions = await self._render_blocks(
- self._message_json["blocks"]
- )
+ texts = self._render_blocks(self._message_json["blocks"])
else:
- # TODO: highlights from text
- text = unhtmlescape(await self._unfurl_refs(self._message_json["text"]))
- texts = [text] if text else []
- mentions = []
+ items = self._unfurl_refs(self._message_json["text"])
+ texts = [
+ unhtmlescape(item) if isinstance(item, str) else item
+ for item in items
+ ]
- files_texts = self._render_files(self._message_json.get("files", []))
- text_with_files = "\n".join(texts + files_texts)
+ files_text = self._render_files(self._message_json.get("files", []))
+ if files_text:
+ texts.extend(["\n", files_text])
- attachment_texts = await self._render_attachments(text_with_files)
- full_text = "\n".join([text_with_files] + attachment_texts)
+ attachment_items = self._render_attachments(texts)
+ self._parsed_message = texts + attachment_items
- if self._message_json.get("subtype") == "me_message":
- return f"{await self.nick()} {full_text}", mentions
- else:
- return full_text, mentions
+ return self._parsed_message
- async def _render_message(self, rerender: bool = False) -> Tuple[str, Mentions]:
- if self.deleted:
- self._mentions = []
- return (
- with_color(shared.config.color.deleted_message.value, "(deleted)"),
- self._mentions,
- )
- elif (
- self._rendered_message is not None and self._mentions is not None
- ) and not rerender:
- return self._rendered_message, self._mentions
- else:
- text, self._mentions = await self._render_message_text()
- text_edited = (
- f" {with_color(shared.config.color.edited_message_suffix.value, '(edited)')}"
- if self._message_json.get("edited")
- else ""
- )
- reactions = await self._create_reactions_string()
- self._rendered_message = text + text_edited + reactions
- return self._rendered_message, self._mentions
+ async def _render_message(self, rerender: bool = False) -> str:
+ if self._rendered_message is not None and not rerender:
+ return self._rendered_message
+
+ me_prefix = (
+ f"{await self.nick()} "
+ if self._message_json.get("subtype") == "me_message"
+ else ""
+ )
+
+ parsed_message = self._parse_message_text(rerender)
+ text = "".join(
+ [
+ text if isinstance(text, str) else await text.resolve()
+ for text in parsed_message
+ ]
+ )
+ text_edited = (
+ f" {with_color(shared.config.color.edited_message_suffix.value, '(edited)')}"
+ if self._message_json.get("edited")
+ else ""
+ )
+ reactions = await self._create_reactions_string()
+ self._rendered_message = me_prefix + text + text_edited + reactions
+ return self._rendered_message
async def render_message(
self,
context: Literal["conversation", "thread"],
rerender: bool = False,
) -> str:
- text, _ = await self._render_message(rerender=rerender)
+ text = await self._render_message(rerender=rerender)
if context == "thread":
return text
thread_prefix = self._create_thread_prefix()
thread = self._create_thread_string()
return thread_prefix + text + thread
- def _item_prefix(self, item_id: str):
- if item_id.startswith("#") or item_id.startswith("@"):
- return item_id[0]
- elif item_id.startswith("!subteam^") or item_id in [
- "!here",
- "!channel",
- "!everyone",
- ]:
- return "@"
- else:
- return ""
-
- async def _resolve_ref(
- self, item_id: str
- ) -> Optional[Tuple[Optional[WeeChatColor], str]]:
+ def _resolve_ref(self, item_id: str) -> Optional[Union[str, PendingMessageItem]]:
if item_id.startswith("#"):
- conversation = await self.workspace.conversations[
- removeprefix(item_id, "#")
- ]
- color = shared.config.color.channel_mention_color.value
- name = await conversation.name_with_prefix("short_name_without_padding")
- return (color, name)
+ return PendingMessageItem(self, "conversation", removeprefix(item_id, "#"))
elif item_id.startswith("@"):
- user = await self.workspace.users[removeprefix(item_id, "@")]
- color = shared.config.color.user_mention_color.value
- return (color, self._item_prefix(item_id) + user.nick())
+ return PendingMessageItem(self, "user", removeprefix(item_id, "@"))
elif item_id.startswith("!subteam^"):
- usergroup = await self.workspace.usergroups[
- removeprefix(item_id, "!subteam^")
- ]
- color = shared.config.color.usergroup_mention_color.value
- return (color, self._item_prefix(item_id) + usergroup.handle())
+ return PendingMessageItem(
+ self, "usergroup", removeprefix(item_id, "!subteam^")
+ )
elif item_id in ["!here", "!channel", "!everyone"]:
- color = shared.config.color.usergroup_mention_color.value
- return (color, self._item_prefix(item_id) + removeprefix(item_id, "!"))
-
+ return PendingMessageItem(self, "broadcast", removeprefix(item_id, "!"))
elif item_id.startswith("!date"):
parts = item_id.split("^")
- ref_datetime = datetime.fromtimestamp(int(parts[1]))
- link_suffix = f" ({parts[3]})" if len(parts) > 3 else ""
- token_to_format = {
- "date_num": "%Y-%m-%d",
- "date": "%B %d, %Y",
- "date_short": "%b %d, %Y",
- "date_long": "%A, %B %d, %Y",
- "time": "%H:%M",
- "time_secs": "%H:%M:%S",
- }
-
- def replace_token(match: Match[str]):
- token = match.group(1)
- if token.startswith("date_") and token.endswith("_pretty"):
- if ref_datetime.date() == date.today():
- return "today"
- elif ref_datetime.date() == date.today() - timedelta(days=1):
- return "yesterday"
- elif ref_datetime.date() == date.today() + timedelta(days=1):
- return "tomorrow"
- else:
- token = token.replace("_pretty", "")
- if token in token_to_format:
- return ref_datetime.strftime(token_to_format[token])
- else:
- return match.group(0)
-
- text = re.sub(r"{([^}]+)}", replace_token, parts[2]) + link_suffix
- return (None, text)
-
- async def _unfurl_refs(self, message: str) -> str:
- re_mention = re.compile(r"<(?P<id>[^|>]+)(?:\|(?P<fallback_name>[^>]*))?>")
- mention_matches = list(re_mention.finditer(message))
- mention_ids: List[str] = [match["id"] for match in mention_matches]
- items_list = await gather(
- *(self._resolve_ref(mention_id) for mention_id in mention_ids),
- return_exceptions=True,
- )
- items = dict(zip(mention_ids, items_list))
-
- def unfurl_ref(match: Match[str]):
- item = items[match["id"]]
- if item and not isinstance(item, BaseException):
- return with_color(item[0], item[1])
- elif match["fallback_name"]:
- prefix = self._item_prefix(match["id"])
- if match["fallback_name"].startswith(prefix):
- return match["fallback_name"]
- else:
- return prefix + match["fallback_name"]
- elif item:
- print_exception_once(item)
- return match[0]
+ timestamp = int(parts[1])
+ link = parts[3] if len(parts) > 3 else None
+ return format_date(timestamp, parts[2], link)
+
+ def _unfurl_refs(
+ self, message: str
+ ) -> Generator[Union[str, PendingMessageItem], None, None]:
+ re_ref = re.compile(r"<(?P<id>[^|>]+)(?:\|(?P<fallback_name>[^>]*))?>")
+
+ i = 0
+ for match in re_ref.finditer(message):
+ if i < match.start(0):
+ yield message[i : match.start(0)]
+ item = self._resolve_ref(match["id"])
+ if item:
+ yield item
+ elif match["fallback_name"] is not None:
+ yield match["fallback_name"]
+ else:
+ yield match[0]
+ i = match.end(0)
- return re_mention.sub(unfurl_ref, message)
+ if i < len(message):
+ yield message[i:]
def _get_emoji(self, emoji_name: str, skin_tone: Optional[int] = None) -> str:
emoji_name_with_colons = f":{emoji_name}:"
@@ -732,18 +773,10 @@ class SlackMessage:
text = f"[ Thread: {self.hash} Replies: {reply_count}{subscribed_text} ]"
return " " + with_color(nick_color(str(self.hash)), text)
- def _block_element_mentions(self, elements: List[SlackMessageBlockRichTextElement]):
- for element in elements:
- if (
- element["type"] == "user"
- or element["type"] == "usergroup"
- or element["type"] == "broadcast"
- ):
- yield element
-
- async def _render_blocks(self, blocks: List[SlackMessageBlock]):
- block_texts: List[str] = []
- mentions: Mentions = []
+ def _render_blocks(
+ self, blocks: List[SlackMessageBlock]
+ ) -> List[Union[str, PendingMessageItem]]:
+ block_lines: List[List[Union[str, PendingMessageItem]]] = []
for block in blocks:
try:
@@ -751,118 +784,105 @@ class SlackMessage:
fields = block.get("fields", [])
if "text" in block:
fields.insert(0, block["text"])
- block_texts.extend(
- [await self._render_block_element(field) for field in fields]
+ block_lines.extend(
+ self._render_block_element(field) for field in fields
)
elif block["type"] == "actions":
- texts: List[str] = []
+ items: List[Union[str, PendingMessageItem]] = []
for element in block["elements"]:
if element["type"] == "button":
- texts.append(
- await self._render_block_element(element["text"])
- )
+ items.extend(self._render_block_element(element["text"]))
if "url" in element:
- texts.append(element["url"])
+ items.append(element["url"])
else:
text = (
f'<Unsupported block action type "{element["type"]}">'
)
- texts.append(
+ items.append(
with_color(shared.config.color.render_error.value, text)
)
- block_texts.append(" | ".join(texts))
+ block_lines.append(intersperse(items, " | "))
elif block["type"] == "call":
url = block["call"]["v1"]["join_url"]
- block_texts.append("Join via " + url)
+ block_lines.append(["Join via " + url])
elif block["type"] == "divider":
- block_texts.append("---")
+ block_lines.append(["---"])
elif block["type"] == "context":
- block_texts.append(
- " | ".join(
- [
- await self._render_block_element(element)
- for element in block["elements"]
- ]
- )
- )
+ items = [
+ item
+ for element in block["elements"]
+ for item in self._render_block_element(element)
+ ]
+ block_lines.append(intersperse(items, " | "))
elif block["type"] == "image":
if "title" in block:
- block_texts.append(
- await self._render_block_element(block["title"])
- )
- block_texts.append(await self._render_block_element(block))
+ block_lines.append(self._render_block_element(block["title"]))
+ block_lines.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
- )
+ rendered = self._render_block_rich_text_section(element)
if rendered:
- block_texts.append(rendered)
- mentions.extend(
- self._block_element_mentions(element["elements"])
- )
+ block_lines.append(rendered)
elif element["type"] == "rich_text_list":
- rendered = [
- "{}{} {}".format(
+ lines = [
+ [
" " * element.get("indent", 0),
self._render_block_rich_text_list_prefix(
element, item_index
),
- await self._render_block_rich_text_section(
- item_element
- ),
- )
+ " ",
+ ]
+ + self._render_block_rich_text_section(item_element)
for item_index, item_element in enumerate(
element["elements"]
)
]
- block_texts.extend(rendered)
+ block_lines.extend(lines)
elif element["type"] == "rich_text_quote":
- lines = [
- f"> {line}"
+ quote_str = "> "
+ items = [quote_str] + [
+ self._render_block_rich_text_element(
+ sub_element, quote_str
+ )
for sub_element in element["elements"]
- for line in (
- await self._render_block_rich_text_element(
- sub_element
- )
- ).split("\n")
]
- block_texts.extend(lines)
- mentions.extend(
- self._block_element_mentions(element["elements"])
- )
+ block_lines.append(items)
elif element["type"] == "rich_text_preformatted":
- texts = [
+ texts: List[str] = [
sub_element.get("text", sub_element.get("url", ""))
for sub_element in element["elements"]
]
if texts:
- block_texts.append(f"```\n{''.join(texts)}\n```")
+ block_lines.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)
+ block_lines.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)
+ block_lines.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)
+ block_lines.append(
+ [with_color(shared.config.color.render_error.value, text)]
)
- return block_texts, mentions
+ return [item for items in intersperse(block_lines, ["\n"]) for item in items]
- async def _render_block_rich_text_section(
- self, section: SlackMessageBlockRichTextSection
- ) -> str:
- texts: List[str] = []
+ def _render_block_rich_text_section(
+ self, section: SlackMessageBlockRichTextSection, lines_prepend: str = ""
+ ) -> List[Union[str, PendingMessageItem]]:
+ texts: List[Union[str, PendingMessageItem]] = []
prev_element: SlackMessageBlockRichTextElement = {"type": "text", "text": ""}
for element in section["elements"] + [prev_element.copy()]:
colors_apply: List[str] = []
@@ -896,25 +916,29 @@ class SlackMessage:
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))
+ prepend = "".join(
+ characters_remove[::-1]
+ + colors_remove[::-1]
+ + colors_apply
+ + characters_apply
+ )
+ if prepend:
+ texts.append(prepend)
+ text = self._render_block_rich_text_element(element, lines_prepend)
+ if text:
+ texts.append(text)
prev_element = element
- text = "".join(texts)
+ if texts and isinstance(texts[-1], str) and texts[-1].endswith("\n"):
+ texts[-1] = texts[-1][:-1]
- if text.endswith("\n"):
- return text[:-1]
- else:
- return text
+ return texts
- async def _render_block_rich_text_element(
- self, element: SlackMessageBlockRichTextElement
- ) -> str:
+ def _render_block_rich_text_element(
+ self, element: SlackMessageBlockRichTextElement, lines_prepend: str = ""
+ ) -> Union[str, PendingMessageItem]:
if element["type"] == "text":
- return element["text"]
+ return element["text"].replace("\n", "\n" + lines_prepend)
elif element["type"] == "link":
if "text" in element:
if element.get("style", {}).get("code"):
@@ -926,44 +950,39 @@ class SlackMessage:
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)
+ return PendingMessageItem(self, "conversation", element["channel_id"])
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)
+ return PendingMessageItem(self, "user", element["user_id"])
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)
+ return PendingMessageItem(self, "usergroup", element["usergroup_id"])
elif element["type"] == "broadcast":
- name = f"@{element['range']}"
- return with_color(shared.config.color.usergroup_mention_color.value, name)
+ return PendingMessageItem(self, "broadcast", element["range"])
else:
text = f'<Unsupported rich text element type "{element["type"]}">'
return with_color(shared.config.color.render_error.value, text)
- async def _render_block_element(
+ def _render_block_element(
self,
element: Union[SlackMessageBlockCompositionText, SlackMessageBlockElementImage],
- ) -> str:
+ ) -> List[Union[str, PendingMessageItem]]:
if element["type"] == "plain_text" or element["type"] == "mrkdwn":
# TODO: Support markdown, and verbatim and emoji properties
# Looks like emoji and verbatim are only used when posting, so we don't need to care about them.
# We do have to resolve refs (users, dates etc.) and emojis for both plain_text and mrkdwn though.
# See a message for a poll from polly
- # return element["text"]
- return unhtmlescape(await self._unfurl_refs(element["text"]))
+ # Should I run unhtmlescape here?
+ items = self._unfurl_refs(element["text"])
+ return [
+ unhtmlescape(item) if isinstance(item, str) else item for item in items
+ ]
elif element["type"] == "image":
if element.get("alt_text"):
- return f"{element['image_url']} ({element['alt_text']})"
+ return [f"{element['image_url']} ({element['alt_text']})"]
else:
- return element["image_url"]
+ return [element["image_url"]]
else:
text = f'<Unsupported block element type "{element["type"]}">'
- return with_color(shared.config.color.render_error.value, text)
+ return [with_color(shared.config.color.render_error.value, text)]
def _render_block_rich_text_list_prefix(
self, list_element: SlackMessageBlockRichTextList, item_index: int
@@ -984,8 +1003,8 @@ class SlackMessage:
else:
return "▪︎"
- def _render_files(self, files: List[SlackMessageFile]) -> List[str]:
- texts: List[str] = []
+ def _render_files(self, files: List[SlackMessageFile]) -> str:
+ lines: List[str] = []
for file in files:
if file.get("mode") == "tombstone":
text = with_color(
@@ -1012,16 +1031,18 @@ class SlackMessage:
shared.config.color.render_error.value,
f"<Unsupported file, error id: {uncaught_error.id}>",
)
- texts.append(text)
+ lines.append(text)
- return texts
+ return "\n".join(lines)
- async def _render_attachments(self, text_before: str) -> List[str]:
+ # TODO: Check if mentions in attachments should highlight
+ def _render_attachments(
+ self, items_before: List[Union[str, PendingMessageItem]]
+ ) -> List[Union[str, PendingMessageItem]]:
if "attachments" not in self._message_json:
return []
- text_before_unescaped = unhtmlescape(text_before)
- attachments_texts: List[str] = []
+ attachments_texts: List[Union[str, PendingMessageItem]] = []
for attachment in self._message_json["attachments"]:
# Attachments should be rendered roughly like:
#
@@ -1036,76 +1057,81 @@ class SlackMessage:
):
continue
- texts: List[str] = []
+ items: List[Union[str, PendingMessageItem]] = []
prepend_title_text = ""
if "author_name" in attachment:
prepend_title_text = attachment["author_name"] + ": "
if "pretext" in attachment:
- texts.append(attachment["pretext"])
+ items.append(attachment["pretext"])
link_shown = False
title = attachment.get("title")
title_link = attachment.get("title_link", "")
- if title_link and (
- title_link in text_before or title_link in text_before_unescaped
+ if title_link and any(
+ isinstance(text, str) and title_link in text for text in items_before
):
title_link = ""
link_shown = True
if title and title_link:
- texts.append(f"{prepend_title_text}{title} ({title_link})")
+ items.append(f"{prepend_title_text}{title} ({title_link})")
prepend_title_text = ""
elif title and not title_link:
- texts.append(f"{prepend_title_text}{title}")
+ items.append(f"{prepend_title_text}{title}")
prepend_title_text = ""
from_url = attachment.get("from_url", "")
if (
- from_url not in text_before
- and from_url not in text_before_unescaped
+ not any(
+ isinstance(text, str) and from_url in text for text in items_before
+ )
and from_url != title_link
):
- texts.append(from_url)
+ items.append(from_url)
elif from_url:
link_shown = True
atext = attachment.get("text")
if atext:
tx = re.sub(r" *\n[\n ]+", "\n", atext)
- texts.append(prepend_title_text + tx)
+ items.append(prepend_title_text + tx)
prepend_title_text = ""
# TODO: Don't render both text and blocks
- blocks, _ = await self._render_blocks(attachment.get("blocks", []))
- texts.extend(blocks)
+ blocks_items = self._render_blocks(attachment.get("blocks", []))
+ items.extend(blocks_items)
image_url = attachment.get("image_url", "")
if (
- image_url not in text_before
- and image_url not in text_before_unescaped
+ not any(
+ isinstance(text, str) and image_url in text for text in items_before
+ )
and image_url != from_url
and image_url != title_link
):
- texts.append(image_url)
+ items.append(image_url)
elif image_url:
link_shown = True
for field in attachment.get("fields", []):
if field.get("title"):
- texts.append(f"{field['title']}: {field['value']}")
+ items.append(f"{field['title']}: {field['value']}")
else:
- texts.append(field["value"])
+ items.append(field["value"])
files = self._render_files(attachment.get("files", []))
- texts.extend(files)
+ if files:
+ items.append(files)
if attachment.get("is_msg_unfurl"):
- channel_name = await self.conversation.name_with_prefix(
- "short_name_without_padding"
+ channel_name = PendingMessageItem(
+ self, "conversation", self.conversation.id
)
if attachment.get("is_reply_unfurl"):
- footer = f"From a thread in {channel_name}"
+ footer = ["From a thread in ", channel_name]
else:
- footer = f"Posted in {channel_name}"
+ footer = ["Posted in ", channel_name]
+ elif attachment.get("footer"):
+ footer = [attachment.get("footer")]
else:
- footer = attachment.get("footer")
+ footer = []
if footer:
ts = attachment.get("ts")
@@ -1119,25 +1145,27 @@ class SlackMessage:
time_string = ""
if date.today() - date.fromtimestamp(ts_int) <= timedelta(days=1):
time_string = " at {time}"
- timestamp_item = await self._resolve_ref(
- f"!date^{ts_int}^{{date_short_pretty}}{time_string}"
+ timestamp_formatted = format_date(
+ ts_int, "date_short_pretty" + time_string
)
- if timestamp_item:
- timestamp_formatted = with_color(
- timestamp_item[0], timestamp_item[1].capitalize()
- )
- footer += f" | {timestamp_formatted}"
- texts.append(footer)
+ footer.append(f" | {timestamp_formatted.capitalize()}")
+ items.extend(footer)
fallback = attachment.get("fallback")
- if texts == [] and fallback and not link_shown:
- texts.append(fallback)
-
- lines = [
- line for part in texts for line in part.strip().split("\n") if part
+ if items == [] and fallback and not link_shown:
+ items.append(fallback)
+
+ texts_separate_newlines = [
+ item_separate_newline
+ for item in items
+ for item_separate_newline in (
+ intersperse(item.strip().split("\n"), "\n")
+ if isinstance(item, str)
+ else [item]
+ )
]
- if lines:
+ if texts_separate_newlines:
prefix = "|"
line_color = None
color = attachment.get("color")
@@ -1153,8 +1181,15 @@ class SlackMessage:
elif shared.config.look.color_message_attachments.value == "all":
line_color = weechat_color
- attachments_texts.extend(
- with_color(line_color, f"{prefix} {line}") for line in lines
- )
+ texts_with_prefix = [f"{prefix} "] + [
+ f"\n{prefix} " if item == "\n" else item
+ for item in texts_separate_newlines
+ ]
+
+ if line_color:
+ attachments_texts.append(weechat.color(line_color))
+ attachments_texts.extend(texts_with_prefix)
+ if line_color:
+ attachments_texts.append(weechat.color("reset"))
return attachments_texts
diff --git a/slack/util.py b/slack/util.py
index 5c45d9d..41993c5 100644
--- a/slack/util.py
+++ b/slack/util.py
@@ -2,13 +2,23 @@ from __future__ import annotations
from functools import partial
from itertools import islice
-from typing import Callable, Iterable, Iterator, List, Optional, TypeVar
+from typing import (
+ Callable,
+ Iterable,
+ Iterator,
+ List,
+ Optional,
+ Sequence,
+ TypeVar,
+ Union,
+)
import weechat
from slack.shared import WeechatCallbackReturnType, shared
T = TypeVar("T")
+T2 = TypeVar("T2")
def get_callback_name(callback: Callable[..., WeechatCallbackReturnType]) -> str:
@@ -58,3 +68,11 @@ def chunked(iterable: Iterable[T], n: int, strict: bool = False) -> Iterator[Lis
"""
return iter(partial(take, n, iter(iterable)), [])
+
+
+# From https://stackoverflow.com/a/5921708
+def intersperse(lst: Sequence[Union[T, T2]], item: T2) -> List[Union[T, T2]]:
+ """Inserts item between each item in lst"""
+ result: List[Union[T, T2]] = [item] * (len(lst) * 2 - 1)
+ result[0::2] = lst
+ return result
diff --git a/typings/slack_api/slack_conversations_history.pyi b/typings/slack_api/slack_conversations_history.pyi
index df10725..4f0cfa8 100644
--- a/typings/slack_api/slack_conversations_history.pyi
+++ b/typings/slack_api/slack_conversations_history.pyi
@@ -237,6 +237,7 @@ class SlackMessageAttachment(TypedDict):
title: str
title_link: str
service_name: str
+ footer: str
@final
class SlackMessageReaction(TypedDict):