From 994e2d246fc09ed942a871696ccae1bdc3002217 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Sun, 17 Sep 2023 11:30:53 +0200 Subject: Support thread buffers --- slack/commands.py | 113 ++++++++++-------- slack/register.py | 29 +++-- slack/shared.py | 2 + slack/slack_buffer.py | 275 ++++++++++++++++++++++++++++++++++++++++++ slack/slack_conversation.py | 283 ++++++++------------------------------------ slack/slack_message.py | 21 +++- slack/slack_thread.py | 87 ++++++++++++++ slack/slack_workspace.py | 19 ++- 8 files changed, 525 insertions(+), 304 deletions(-) create mode 100644 slack/slack_buffer.py create mode 100644 slack/slack_thread.py (limited to 'slack') diff --git a/slack/commands.py b/slack/commands.py index 57a257f..8e807e1 100644 --- a/slack/commands.py +++ b/slack/commands.py @@ -13,10 +13,9 @@ from slack.error import SlackError, SlackRtmError, UncaughtError from slack.log import print_error from slack.python_compatibility import format_exception, removeprefix, removesuffix from slack.shared import shared -from slack.slack_conversation import ( - SlackConversation, - get_conversation_from_buffer_pointer, -) +from slack.slack_buffer import SlackBuffer +from slack.slack_conversation import SlackConversation +from slack.slack_thread import SlackThread from slack.slack_user import name_from_user_info_without_spaces from slack.slack_workspace import SlackWorkspace from slack.task import run_async @@ -143,9 +142,9 @@ def command_slack_connect( else: workspace_connect(workspace) else: - conversation = get_conversation_from_buffer_pointer(buffer) - if conversation: - workspace_connect(conversation.workspace) + slack_buffer = shared.buffers.get(buffer) + if slack_buffer: + workspace_connect(slack_buffer.workspace) def workspace_disconnect(workspace: SlackWorkspace): @@ -170,18 +169,18 @@ def command_slack_disconnect( else: workspace_disconnect(workspace) else: - conversation = get_conversation_from_buffer_pointer(buffer) - if conversation: - workspace_disconnect(conversation.workspace) + slack_buffer = shared.buffers.get(buffer) + if slack_buffer: + workspace_disconnect(slack_buffer.workspace) @weechat_command() def command_slack_rehistory( buffer: str, args: List[str], options: Dict[str, Optional[str]] ): - conversation = get_conversation_from_buffer_pointer(buffer) - if conversation: - run_async(conversation.rerender_history()) + slack_buffer = shared.buffers.get(buffer) + if slack_buffer: + run_async(slack_buffer.rerender_history()) @weechat_command() @@ -272,6 +271,15 @@ def command_slack_workspace_del( ) +@weechat_command(min_args=1) +def command_slack_thread( + buffer: str, args: List[str], options: Dict[str, Optional[str]] +): + slack_buffer = shared.buffers.get(buffer) + if isinstance(slack_buffer, SlackConversation): + run_async(slack_buffer.open_thread(args[0], switch=True)) + + def print_uncaught_error( error: UncaughtError, detailed: bool, options: Dict[str, Optional[str]] ): @@ -300,9 +308,14 @@ def command_slack_debug( weechat.prnt("", "Active futures:") weechat.prnt("", pprint.pformat(shared.active_futures)) elif args[0] == "buffer": - conversation = get_conversation_from_buffer_pointer(buffer) - if conversation: - weechat.prnt("", f"Conversation id: {conversation.id}") + slack_buffer = shared.buffers.get(buffer) + if isinstance(slack_buffer, SlackConversation): + weechat.prnt("", f"Conversation id: {slack_buffer.id}") + elif isinstance(slack_buffer, SlackThread): + weechat.prnt( + "", + f"Conversation id: {slack_buffer.parent.conversation.id}, Thread ts: {slack_buffer.parent.thread_ts}, Thread hash: {slack_buffer.parent.hash}", + ) elif args[0] == "errors": num_arg = int(args[1]) if len(args) > 1 and args[1].isdecimal() else 5 num = min(num_arg, len(shared.uncaught_errors)) @@ -427,22 +440,22 @@ def completion_irc_channels_cb( return weechat.WEECHAT_RC_OK -def complete_input(conversation: SlackConversation, query: str): +def complete_input(slack_buffer: SlackBuffer, query: str): if ( - conversation.completion_context == "ACTIVE_COMPLETION" - and conversation.completion_values + slack_buffer.completion_context == "ACTIVE_COMPLETION" + and slack_buffer.completion_values ): - input_value = weechat.buffer_get_string(conversation.buffer_pointer, "input") - input_pos = weechat.buffer_get_integer(conversation.buffer_pointer, "input_pos") - result = conversation.completion_values[conversation.completion_index] + input_value = weechat.buffer_get_string(slack_buffer.buffer_pointer, "input") + input_pos = weechat.buffer_get_integer(slack_buffer.buffer_pointer, "input_pos") + result = slack_buffer.completion_values[slack_buffer.completion_index] input_before = removesuffix(input_value[:input_pos], query) input_after = input_value[input_pos:] new_input = input_before + result + input_after new_pos = input_pos - len(query) + len(result) - with conversation.completing(): - weechat.buffer_set(conversation.buffer_pointer, "input", new_input) - weechat.buffer_set(conversation.buffer_pointer, "input_pos", str(new_pos)) + with slack_buffer.completing(): + weechat.buffer_set(slack_buffer.buffer_pointer, "input", new_input) + weechat.buffer_set(slack_buffer.buffer_pointer, "input_pos", str(new_pos)) def nick_suffix(): @@ -452,47 +465,47 @@ def nick_suffix(): async def complete_user_next( - conversation: SlackConversation, query: str, is_first_word: bool + slack_buffer: SlackBuffer, query: str, is_first_word: bool ): - if conversation.completion_context == "NO_COMPLETION": - conversation.completion_context = "PENDING_COMPLETION" - search = await conversation.workspace.api.edgeapi.fetch_users_search(query) - if conversation.completion_context != "PENDING_COMPLETION": + if slack_buffer.completion_context == "NO_COMPLETION": + slack_buffer.completion_context = "PENDING_COMPLETION" + search = await slack_buffer.workspace.api.edgeapi.fetch_users_search(query) + if slack_buffer.completion_context != "PENDING_COMPLETION": return - conversation.completion_context = "ACTIVE_COMPLETION" + slack_buffer.completion_context = "ACTIVE_COMPLETION" suffix = nick_suffix() if is_first_word else " " - conversation.completion_values = [ - name_from_user_info_without_spaces(conversation.workspace, user) + suffix + slack_buffer.completion_values = [ + name_from_user_info_without_spaces(slack_buffer.workspace, user) + suffix for user in search["results"] ] - conversation.completion_index = 0 - elif conversation.completion_context == "ACTIVE_COMPLETION": - conversation.completion_index += 1 - if conversation.completion_index >= len(conversation.completion_values): - conversation.completion_index = 0 + slack_buffer.completion_index = 0 + elif slack_buffer.completion_context == "ACTIVE_COMPLETION": + slack_buffer.completion_index += 1 + if slack_buffer.completion_index >= len(slack_buffer.completion_values): + slack_buffer.completion_index = 0 - complete_input(conversation, query) + complete_input(slack_buffer, query) -def complete_previous(conversation: SlackConversation, query: str) -> int: - if conversation.completion_context == "ACTIVE_COMPLETION": - conversation.completion_index -= 1 - if conversation.completion_index < 0: - conversation.completion_index = len(conversation.completion_values) - 1 - complete_input(conversation, query) +def complete_previous(slack_buffer: SlackBuffer, query: str) -> int: + if slack_buffer.completion_context == "ACTIVE_COMPLETION": + slack_buffer.completion_index -= 1 + if slack_buffer.completion_index < 0: + slack_buffer.completion_index = len(slack_buffer.completion_values) - 1 + complete_input(slack_buffer, query) return weechat.WEECHAT_RC_OK_EAT return weechat.WEECHAT_RC_OK def input_complete_cb(data: str, buffer: str, command: str) -> int: - conversation = get_conversation_from_buffer_pointer(buffer) - if conversation: + slack_buffer = shared.buffers.get(buffer) + if slack_buffer: input_value = weechat.buffer_get_string(buffer, "input") input_pos = weechat.buffer_get_integer(buffer, "input_pos") input_before_cursor = input_value[:input_pos] word_index = ( - -2 if conversation.completion_context == "ACTIVE_COMPLETION" else -1 + -2 if slack_buffer.completion_context == "ACTIVE_COMPLETION" else -1 ) word_until_cursor = " ".join(input_before_cursor.split(" ")[word_index:]) @@ -501,10 +514,10 @@ def input_complete_cb(data: str, buffer: str, command: str) -> int: is_first_word = word_until_cursor == input_before_cursor if command == "/input complete_next": - run_async(complete_user_next(conversation, query, is_first_word)) + run_async(complete_user_next(slack_buffer, query, is_first_word)) return weechat.WEECHAT_RC_OK_EAT else: - return complete_previous(conversation, query) + return complete_previous(slack_buffer, query) return weechat.WEECHAT_RC_OK diff --git a/slack/register.py b/slack/register.py index d45fc41..5e81792 100644 --- a/slack/register.py +++ b/slack/register.py @@ -5,7 +5,6 @@ import weechat from slack.commands import register_commands from slack.config import SlackConfig from slack.shared import shared -from slack.slack_conversation import get_conversation_from_buffer_pointer from slack.slack_emoji import load_standard_emojis from slack.task import run_async, sleep from slack.util import get_callback_name, with_color @@ -21,9 +20,9 @@ def shutdown_cb(): def signal_buffer_switch_cb(data: str, signal: str, buffer_pointer: str) -> int: - conversation = get_conversation_from_buffer_pointer(buffer_pointer) - if conversation: - run_async(conversation.buffer_switched_to()) + slack_buffer = shared.buffers.get(buffer_pointer) + if slack_buffer: + run_async(slack_buffer.buffer_switched_to()) return weechat.WEECHAT_RC_OK @@ -38,29 +37,29 @@ def input_text_cursor_moved_cb(data: str, signal: str, buffer_pointer: str) -> i def reset_completion_context_on_input(buffer_pointer: str): - conversation = get_conversation_from_buffer_pointer(buffer_pointer) - if conversation and conversation.completion_context != "IN_PROGRESS_COMPLETION": - conversation.completion_context = "NO_COMPLETION" + slack_buffer = shared.buffers.get(buffer_pointer) + if slack_buffer and slack_buffer.completion_context != "IN_PROGRESS_COMPLETION": + slack_buffer.completion_context = "NO_COMPLETION" def modifier_input_text_display_with_cursor_cb( data: str, modifier: str, buffer_pointer: str, string: str ) -> str: prefix = "" - conversation = get_conversation_from_buffer_pointer(buffer_pointer) - if conversation: + slack_buffer = shared.buffers.get(buffer_pointer) + if slack_buffer: input_delim_color = weechat.config_string( weechat.config_get("weechat.bar.input.color_delim") ) input_delim_start = with_color(input_delim_color, "[") input_delim_end = with_color(input_delim_color, "]") - if not conversation.workspace.is_connected: + if not slack_buffer.workspace.is_connected: prefix += ( f"{input_delim_start}" f"{with_color(shared.config.color.disconnected.value, 'disconnected')}" f"{input_delim_end} " ) - if conversation.is_loading: + if slack_buffer.is_loading: prefix += ( f"{input_delim_start}" f"{with_color(shared.config.color.loading.value, 'loading')}" @@ -70,12 +69,12 @@ def modifier_input_text_display_with_cursor_cb( def typing_self_cb(data: str, signal: str, signal_data: str) -> int: - if not shared.config.look.typing_status_self: + if not shared.config.look.typing_status_self or signal != "typing_self_typing": return weechat.WEECHAT_RC_OK - conversation = get_conversation_from_buffer_pointer(signal_data) - if conversation: - conversation.typing_update_self(signal) + slack_buffer = shared.buffers.get(signal_data) + if slack_buffer: + slack_buffer.set_typing_self() return weechat.WEECHAT_RC_OK diff --git a/slack/shared.py b/slack/shared.py index 600723b..0cf25f4 100644 --- a/slack/shared.py +++ b/slack/shared.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Union if TYPE_CHECKING: from slack.config import SlackConfig from slack.error import UncaughtError + from slack.slack_buffer import SlackBuffer from slack.slack_emoji import Emoji from slack.slack_workspace import SlackWorkspace from slack.task import Future, Task @@ -22,6 +23,7 @@ class Shared: self.weechat_callbacks: Dict[str, Callable[..., WeechatCallbackReturnType]] self.active_tasks: Dict[str, List[Task[object]]] = defaultdict(list) self.active_futures: Dict[str, Future[object]] = {} + self.buffers: Dict[str, SlackBuffer] = {} self.workspaces: Dict[str, SlackWorkspace] = {} self.config: SlackConfig self.uncaught_errors: List[UncaughtError] = [] diff --git a/slack/slack_buffer.py b/slack/slack_buffer.py new file mode 100644 index 0000000..e5adb19 --- /dev/null +++ b/slack/slack_buffer.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from contextlib import contextmanager +from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple + +import weechat + +from slack.shared import shared +from slack.slack_message import SlackMessage, SlackTs +from slack.util import get_callback_name + +if TYPE_CHECKING: + from typing_extensions import Literal + + from slack.slack_api import SlackApi + from slack.slack_workspace import SlackWorkspace + + +def hdata_line_ts(line_pointer: str) -> Optional[SlackTs]: + data = weechat.hdata_pointer(weechat.hdata_get("line"), line_pointer, "data") + for i in range( + weechat.hdata_integer(weechat.hdata_get("line_data"), data, "tags_count") + ): + tag = weechat.hdata_string( + weechat.hdata_get("line_data"), data, f"{i}|tags_array" + ) + if tag.startswith("slack_ts_"): + return SlackTs(tag[9:]) + return None + + +def tags_set_notify_none(tags: List[str]) -> List[str]: + notify_tags = {"notify_highlight", "notify_message", "notify_private"} + tags = [tag for tag in tags if tag not in notify_tags] + tags += ["no_highlight", "notify_none"] + return tags + + +def modify_buffer_line(buffer_pointer: str, ts: SlackTs, new_text: str): + own_lines = weechat.hdata_pointer( + weechat.hdata_get("buffer"), buffer_pointer, "own_lines" + ) + line_pointer = weechat.hdata_pointer( + weechat.hdata_get("lines"), own_lines, "last_line" + ) + + # Find the last line with this ts + is_last_line = True + while line_pointer and hdata_line_ts(line_pointer) != ts: + is_last_line = False + line_pointer = weechat.hdata_move(weechat.hdata_get("line"), line_pointer, -1) + + if not line_pointer: + return + + if shared.weechat_version >= 0x04000000: + data = weechat.hdata_pointer(weechat.hdata_get("line"), line_pointer, "data") + weechat.hdata_update( + weechat.hdata_get("line_data"), data, {"message": new_text} + ) + return + + # Find all lines for the message + pointers: List[str] = [] + while line_pointer and hdata_line_ts(line_pointer) == ts: + pointers.append(line_pointer) + line_pointer = weechat.hdata_move(weechat.hdata_get("line"), line_pointer, -1) + pointers.reverse() + + if not pointers: + return + + if is_last_line: + lines = new_text.split("\n") + extra_lines_count = len(lines) - len(pointers) + if extra_lines_count > 0: + line_data = weechat.hdata_pointer( + weechat.hdata_get("line"), pointers[0], "data" + ) + tags_count = weechat.hdata_integer( + weechat.hdata_get("line_data"), line_data, "tags_count" + ) + tags = [ + weechat.hdata_string( + weechat.hdata_get("line_data"), line_data, f"{i}|tags_array" + ) + for i in range(tags_count) + ] + tags = tags_set_notify_none(tags) + tags_str = ",".join(tags) + last_read_line = weechat.hdata_pointer( + weechat.hdata_get("lines"), own_lines, "last_read_line" + ) + should_set_unread = last_read_line == pointers[-1] + + # Insert new lines to match the number of lines in the message + weechat.buffer_set(buffer_pointer, "print_hooks_enabled", "0") + for _ in range(extra_lines_count): + weechat.prnt_date_tags(buffer_pointer, ts.major, tags_str, " \t ") + pointers.append( + weechat.hdata_pointer( + weechat.hdata_get("lines"), own_lines, "last_line" + ) + ) + if should_set_unread: + weechat.buffer_set(buffer_pointer, "unread", "") + weechat.buffer_set(buffer_pointer, "print_hooks_enabled", "1") + else: + # Split the message into at most the number of existing lines as we can't insert new lines + lines = new_text.split("\n", len(pointers) - 1) + # Replace newlines to prevent garbled lines in bare display mode + lines = [line.replace("\n", " | ") for line in lines] + + # Extend lines in case the new message is shorter than the old as we can't delete lines + lines += [""] * (len(pointers) - len(lines)) + + for pointer, line in zip(pointers, lines): + data = weechat.hdata_pointer(weechat.hdata_get("line"), pointer, "data") + weechat.hdata_update(weechat.hdata_get("line_data"), data, {"message": line}) + + +class SlackBuffer(ABC): + def __init__(self): + self._typing_self_last_sent = 0 + # TODO: buffer_pointer may be accessed by buffer_switch before it's initialized + self.buffer_pointer: str = "" + self.is_loading = False + self.history_filled = False + self.history_pending = False + + self.completion_context: Literal[ + "NO_COMPLETION", + "PENDING_COMPLETION", + "ACTIVE_COMPLETION", + "IN_PROGRESS_COMPLETION", + ] = "NO_COMPLETION" + self.completion_values: List[str] = [] + self.completion_index = 0 + + @property + def _api(self) -> SlackApi: + return self.workspace.api + + @contextmanager + def loading(self): + self.is_loading = True + weechat.bar_item_update("input_text") + try: + yield + finally: + self.is_loading = False + weechat.bar_item_update("input_text") + + @contextmanager + def completing(self): + self.completion_context = "IN_PROGRESS_COMPLETION" + try: + yield + finally: + self.completion_context = "ACTIVE_COMPLETION" + + @property + @abstractmethod + def workspace(self) -> SlackWorkspace: + raise NotImplementedError() + + @property + @abstractmethod + def context(self) -> Literal["conversation", "thread"]: + raise NotImplementedError() + + @property + @abstractmethod + def messages(self) -> Mapping[SlackTs, SlackMessage]: + raise NotImplementedError() + + @abstractmethod + async def get_name_and_buffer_props(self) -> Tuple[str, Dict[str, str]]: + raise NotImplementedError() + + @abstractmethod + async def buffer_switched_to(self) -> None: + raise NotImplementedError() + + def get_full_name(self, name: str) -> str: + return f"{shared.SCRIPT_NAME}.{self.workspace.name}.{name}" + + async def open_buffer(self, switch: bool = False): + if self.buffer_pointer: + if switch: + weechat.buffer_set(self.buffer_pointer, "display", "1") + return + + name, buffer_props = await self.get_name_and_buffer_props() + full_name = self.get_full_name(name) + + if switch: + buffer_props["display"] = "1" + + if shared.weechat_version >= 0x03050000: + self.buffer_pointer = weechat.buffer_new_props( + full_name, + buffer_props, + get_callback_name(self._buffer_input_cb), + "", + get_callback_name(self._buffer_close_cb), + "", + ) + else: + self.buffer_pointer = weechat.buffer_new( + full_name, + get_callback_name(self._buffer_input_cb), + "", + get_callback_name(self._buffer_close_cb), + "", + ) + for prop_name, value in buffer_props.items(): + weechat.buffer_set(self.buffer_pointer, prop_name, value) + + shared.buffers[self.buffer_pointer] = self + if switch: + await self.buffer_switched_to() + + async def update_buffer_props(self) -> None: + name, buffer_props = await self.get_name_and_buffer_props() + buffer_props["name"] = self.get_full_name(name) + for key, value in buffer_props.items(): + weechat.buffer_set(self.buffer_pointer, key, value) + + async def rerender_message(self, message: SlackMessage): + modify_buffer_line( + self.buffer_pointer, + message.ts, + await message.render_message(context=self.context, rerender=True), + ) + + async def rerender_history(self): + for message in self.messages.values(): + await self.rerender_message(message) + + async def typing_add_user(self, user_id: str, thread_ts: Optional[str]): + if not shared.config.look.typing_status_nicks: + return + + if not thread_ts: + user = await self.workspace.users[user_id] + weechat.hook_signal_send( + "typing_set_nick", + weechat.WEECHAT_HOOK_SIGNAL_STRING, + f"{self.buffer_pointer};typing;{user.nick()}", + ) + + def set_typing_self(self): + now = time.time() + if now - 4 > self._typing_self_last_sent: + self._typing_self_last_sent = now + self.workspace.send_typing(self) + + async def print_message(self, message: SlackMessage, backlog: bool = False): + tags = await message.tags(backlog=backlog) + rendered = await message.render(self.context) + weechat.prnt_date_tags(self.buffer_pointer, message.ts.major, tags, rendered) + + def _buffer_input_cb(self, data: str, buffer: str, input_data: str) -> int: + weechat.prnt(buffer, "Text: %s" % input_data) + return weechat.WEECHAT_RC_OK + + def _buffer_close_cb(self, data: str, buffer: str) -> int: + if self.buffer_pointer in shared.buffers: + del shared.buffers[self.buffer_pointer] + self.buffer_pointer = "" + self.history_filled = False + return weechat.WEECHAT_RC_OK diff --git a/slack/slack_conversation.py b/slack/slack_conversation.py index 6a24616..4baa17c 100644 --- a/slack/slack_conversation.py +++ b/slack/slack_conversation.py @@ -1,17 +1,17 @@ from __future__ import annotations import hashlib -import time from collections import OrderedDict -from contextlib import contextmanager -from typing import TYPE_CHECKING, Dict, List, Mapping, NoReturn, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Mapping, NoReturn, Optional, Tuple, Union import weechat +from slack.python_compatibility import removeprefix from slack.shared import shared +from slack.slack_buffer import SlackBuffer from slack.slack_message import SlackMessage, SlackTs +from slack.slack_thread import SlackThread from slack.task import gather, run_async -from slack.util import get_callback_name if TYPE_CHECKING: from slack_api.slack_conversations_info import SlackConversationsInfo @@ -24,129 +24,15 @@ if TYPE_CHECKING: ) from typing_extensions import Literal - from slack.slack_api import SlackApi from slack.slack_workspace import SlackWorkspace -def get_conversation_from_buffer_pointer( - buffer_pointer: str, -) -> Optional[SlackConversation]: - for workspace in shared.workspaces.values(): - for conversation in workspace.open_conversations.values(): - if conversation.buffer_pointer == buffer_pointer: - return conversation - return None - - def invalidate_nicklists(): for workspace in shared.workspaces.values(): for conversation in workspace.open_conversations.values(): conversation.nicklist_needs_refresh = True -def hdata_line_ts(line_pointer: str) -> Optional[SlackTs]: - data = weechat.hdata_pointer(weechat.hdata_get("line"), line_pointer, "data") - for i in range( - weechat.hdata_integer(weechat.hdata_get("line_data"), data, "tags_count") - ): - tag = weechat.hdata_string( - weechat.hdata_get("line_data"), data, f"{i}|tags_array" - ) - if tag.startswith("slack_ts_"): - return SlackTs(tag[9:]) - return None - - -def tags_set_notify_none(tags: List[str]) -> List[str]: - notify_tags = {"notify_highlight", "notify_message", "notify_private"} - tags = [tag for tag in tags if tag not in notify_tags] - tags += ["no_highlight", "notify_none"] - return tags - - -def modify_buffer_line(buffer_pointer: str, ts: SlackTs, new_text: str): - own_lines = weechat.hdata_pointer( - weechat.hdata_get("buffer"), buffer_pointer, "own_lines" - ) - line_pointer = weechat.hdata_pointer( - weechat.hdata_get("lines"), own_lines, "last_line" - ) - - # Find the last line with this ts - is_last_line = True - while line_pointer and hdata_line_ts(line_pointer) != ts: - is_last_line = False - line_pointer = weechat.hdata_move(weechat.hdata_get("line"), line_pointer, -1) - - if not line_pointer: - return - - if shared.weechat_version >= 0x04000000: - data = weechat.hdata_pointer(weechat.hdata_get("line"), line_pointer, "data") - weechat.hdata_update( - weechat.hdata_get("line_data"), data, {"message": new_text} - ) - return - - # Find all lines for the message - pointers: List[str] = [] - while line_pointer and hdata_line_ts(line_pointer) == ts: - pointers.append(line_pointer) - line_pointer = weechat.hdata_move(weechat.hdata_get("line"), line_pointer, -1) - pointers.reverse() - - if not pointers: - return - - if is_last_line: - lines = new_text.split("\n") - extra_lines_count = len(lines) - len(pointers) - if extra_lines_count > 0: - line_data = weechat.hdata_pointer( - weechat.hdata_get("line"), pointers[0], "data" - ) - tags_count = weechat.hdata_integer( - weechat.hdata_get("line_data"), line_data, "tags_count" - ) - tags = [ - weechat.hdata_string( - weechat.hdata_get("line_data"), line_data, f"{i}|tags_array" - ) - for i in range(tags_count) - ] - tags = tags_set_notify_none(tags) - tags_str = ",".join(tags) - last_read_line = weechat.hdata_pointer( - weechat.hdata_get("lines"), own_lines, "last_read_line" - ) - should_set_unread = last_read_line == pointers[-1] - - # Insert new lines to match the number of lines in the message - weechat.buffer_set(buffer_pointer, "print_hooks_enabled", "0") - for _ in range(extra_lines_count): - weechat.prnt_date_tags(buffer_pointer, ts.major, tags_str, " \t ") - pointers.append( - weechat.hdata_pointer( - weechat.hdata_get("lines"), own_lines, "last_line" - ) - ) - if should_set_unread: - weechat.buffer_set(buffer_pointer, "unread", "") - weechat.buffer_set(buffer_pointer, "print_hooks_enabled", "1") - else: - # Split the message into at most the number of existing lines as we can't insert new lines - lines = new_text.split("\n", len(pointers) - 1) - # Replace newlines to prevent garbled lines in bare display mode - lines = [line.replace("\n", " | ") for line in lines] - - # Extend lines in case the new message is shorter than the old as we can't delete lines - lines += [""] * (len(pointers) - len(lines)) - - for pointer, line in zip(pointers, lines): - data = weechat.hdata_pointer(weechat.hdata_get("line"), pointer, "data") - weechat.hdata_update(weechat.hdata_get("line_data"), data, {"message": line}) - - def sha1_hex(string: str) -> str: return str(hashlib.sha1(string.encode()).hexdigest()) @@ -198,6 +84,8 @@ class SlackConversationMessageHashes(Dict[SlackTs, str]): other_message = self._conversation.messages[ts_with_same_hash] run_async(self._conversation.rerender_message(other_message)) + if other_message.thread_buffer is not None: + run_async(other_message.thread_buffer.update_buffer_props()) for reply in other_message.replies.values(): run_async(self._conversation.rerender_message(reply)) @@ -205,48 +93,42 @@ class SlackConversationMessageHashes(Dict[SlackTs, str]): self._inverse_map[short_hash] = key return self[key] + def get_ts(self, ts_hash: str) -> Optional[SlackTs]: + hash_without_prefix = removeprefix(ts_hash, "$") + return self._inverse_map.get(hash_without_prefix) + -class SlackConversation: +class SlackConversation(SlackBuffer): def __init__( self, workspace: SlackWorkspace, info: SlackConversationsInfo, ): - self.workspace = workspace + super().__init__() + self._workspace = workspace self._info = info self._members: Optional[List[str]] = None self._messages: OrderedDict[SlackTs, SlackMessage] = OrderedDict() - self._typing_self_last_sent = time.time() - # TODO: buffer_pointer may be accessed by buffer_switch before it's initialized - self.buffer_pointer: str = "" - self.is_loading = False - self.history_filled = False - self.history_pending = False self.nicklist_needs_refresh = True self.message_hashes = SlackConversationMessageHashes(self) - self.completion_context: Literal[ - "NO_COMPLETION", - "PENDING_COMPLETION", - "ACTIVE_COMPLETION", - "IN_PROGRESS_COMPLETION", - ] = "NO_COMPLETION" - self.completion_values: List[str] = [] - self.completion_index = 0 - @classmethod async def create(cls, workspace: SlackWorkspace, conversation_id: str): info_response = await workspace.api.fetch_conversations_info(conversation_id) return cls(workspace, info_response["channel"]) - @property - def _api(self) -> SlackApi: - return self.workspace.api - @property def id(self) -> str: return self._info["id"] + @property + def workspace(self) -> SlackWorkspace: + return self._workspace + + @property + def context(self) -> Literal["conversation", "thread"]: + return "conversation" + @property def messages(self) -> Mapping[SlackTs, SlackMessage]: return self._messages @@ -262,6 +144,10 @@ class SlackConversation: else: return "channel" + @property + def buffer_type(self) -> Literal["private", "channel"]: + return "private" if self.type in ("im", "mpim") else "channel" + async def name(self) -> str: if self._info["is_im"] is True: im_user = await self.workspace.users[self._info["user"]] @@ -302,24 +188,6 @@ class SlackConversation: ) -> str: return f"{self.name_prefix(name_type)}{await self.name()}" - @contextmanager - def loading(self): - self.is_loading = True - weechat.bar_item_update("input_text") - try: - yield - finally: - self.is_loading = False - weechat.bar_item_update("input_text") - - @contextmanager - def completing(self): - self.completion_context = "IN_PROGRESS_COMPLETION" - try: - yield - finally: - self.completion_context = "ACTIVE_COMPLETION" - async def open_if_open(self): if "is_open" in self._info: if self._info["is_open"]: @@ -327,54 +195,36 @@ class SlackConversation: elif self._info.get("is_member"): await self.open_buffer() - async def open_buffer(self): - if self.buffer_pointer: - return + async def get_name_and_buffer_props(self) -> Tuple[str, Dict[str, str]]: + name_without_prefix = await self.name() + name = f"{self.name_prefix('full_name')}{name_without_prefix}" + short_name = self.name_prefix("short_name") + name_without_prefix - name = await self.name() - name_with_prefix_for_full_name = f"{self.name_prefix('full_name')}{name}" - full_name = f"{shared.SCRIPT_NAME}.{self.workspace.name}.{name_with_prefix_for_full_name}" - short_name = self.name_prefix("short_name") + name - - buffer_props = { + return name, { "short_name": short_name, "title": "topic", "input_multiline": "1", "nicklist": "0" if self.type == "im" else "1", "nicklist_display_groups": "0", - "localvar_set_type": ( - "private" if self.type in ("im", "mpim") else "channel" - ), + "localvar_set_type": self.buffer_type, "localvar_set_slack_type": self.type, "localvar_set_nick": self.workspace.my_user.nick(), - "localvar_set_channel": name_with_prefix_for_full_name, + "localvar_set_channel": name, "localvar_set_server": self.workspace.name, } - if shared.weechat_version >= 0x03050000: - self.buffer_pointer = weechat.buffer_new_props( - full_name, - buffer_props, - get_callback_name(self._buffer_input_cb), - "", - get_callback_name(self._buffer_close_cb), - "", - ) - else: - self.buffer_pointer = weechat.buffer_new( - full_name, - get_callback_name(self._buffer_input_cb), - "", - get_callback_name(self._buffer_close_cb), - "", - ) - for prop_name, value in buffer_props.items(): - weechat.buffer_set(self.buffer_pointer, prop_name, value) + async def buffer_switched_to(self): + await gather(self.nicklist_update(), self.fill_history()) + async def open_buffer(self, switch: bool = False): + await super().open_buffer(switch) self.workspace.open_conversations[self.id] = self - async def buffer_switched_to(self): - await gather(self.nicklist_update(), self.fill_history()) + async def rerender_message(self, message: SlackMessage): + await super().rerender_message(message) + parent_message = message.parent_message + if parent_message and parent_message.thread_buffer: + await parent_message.thread_buffer.rerender_message(message) async def load_members(self, load_all: bool = False): if self._members is None: @@ -439,7 +289,7 @@ class SlackConversation: sender_bot_ids = [m.sender_bot_id for m in messages if m.sender_bot_id] self.workspace.bots.initialize_items(sender_bot_ids) - await gather(*(message.render() for message in messages)) + await gather(*(message.render(self.context) for message in messages)) for message in messages: await self.print_message(message, backlog=True) @@ -492,6 +342,8 @@ class SlackConversation: parent_message = message.parent_message if parent_message: parent_message.replies[message.ts] = message + if parent_message.thread_buffer: + await parent_message.thread_buffer.print_message(message) if message.sender_user_id: # TODO: thread buffers @@ -502,15 +354,6 @@ class SlackConversation: f"{self.buffer_pointer};off;{user.nick()}", ) - async def rerender_message(self, message: SlackMessage): - modify_buffer_line( - self.buffer_pointer, message.ts, await message.render_message(rerender=True) - ) - - async def rerender_history(self): - for message in self._messages.values(): - await self.rerender_message(message) - async def change_message( self, data: Union[SlackMessageChanged, SlackMessageReplied] ): @@ -550,34 +393,10 @@ class SlackConversation: message.reaction_remove(reaction, user_id) await self.rerender_message(message) - async def typing_add_user(self, user_id: str, thread_ts: Optional[str]): - if not shared.config.look.typing_status_nicks: - return - - if not thread_ts: - user = await self.workspace.users[user_id] - weechat.hook_signal_send( - "typing_set_nick", - weechat.WEECHAT_HOOK_SIGNAL_STRING, - f"{self.buffer_pointer};typing;{user.nick()}", - ) - - def typing_update_self(self, typing_state: str): - now = time.time() - if now - 4 > self._typing_self_last_sent: - self._typing_self_last_sent = now - self.workspace.send_typing(self.id) - - async def print_message(self, message: SlackMessage, backlog: bool = False): - tags = await message.tags(backlog=backlog) - rendered = await message.render() - weechat.prnt_date_tags(self.buffer_pointer, message.ts.major, tags, rendered) - - def _buffer_input_cb(self, data: str, buffer: str, input_data: str) -> int: - weechat.prnt(buffer, "Text: %s" % input_data) - return weechat.WEECHAT_RC_OK - - def _buffer_close_cb(self, data: str, buffer: str) -> int: - self.buffer_pointer = "" - self.history_filled = False - return weechat.WEECHAT_RC_OK + async def open_thread(self, thread_hash: str, switch: bool = False): + thread_ts = self.message_hashes.get_ts(thread_hash) + if thread_ts: + thread_message = self.messages[thread_ts] + if thread_message.thread_buffer is None: + thread_message.thread_buffer = SlackThread(thread_message) + await thread_message.thread_buffer.open_buffer(switch) diff --git a/slack/slack_message.py b/slack/slack_message.py index 0466559..6728038 100644 --- a/slack/slack_message.py +++ b/slack/slack_message.py @@ -35,9 +35,10 @@ if TYPE_CHECKING: SlackMessageReaction, SlackMessageSubtypeHuddleThreadRoom, ) - from typing_extensions import assert_never + from typing_extensions import Literal, assert_never from slack.slack_conversation import SlackConversation + from slack.slack_thread import SlackThread from slack.slack_workspace import SlackWorkspace @@ -110,6 +111,7 @@ class SlackMessage: self.ts = SlackTs(message_json["ts"]) self.replies: OrderedDict[SlackTs, SlackMessage] = OrderedDict() self.reply_history_filled = False + self.thread_buffer: Optional[SlackThread] = None self._deleted = False @property @@ -261,9 +263,12 @@ class SlackMessage: return ",".join(tags) - async def render(self) -> str: + async def render( + self, + context: Literal["conversation", "thread"], + ) -> str: prefix_coro = self.render_prefix() - message_coro = self.render_message() + message_coro = self.render_message(context) prefix, message = await gather(prefix_coro, message_coro) self._rendered = f"{prefix}\t{message}" return self._rendered @@ -382,9 +387,15 @@ class SlackMessage: self._rendered_message = text + text_edited + reactions return self._rendered_message - async def render_message(self, rerender: bool = False) -> str: - thread_prefix = self._create_thread_prefix() + async def render_message( + self, + context: Literal["conversation", "thread"], + rerender: bool = False, + ) -> str: 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 diff --git a/slack/slack_thread.py b/slack/slack_thread.py new file mode 100644 index 0000000..f92dfc4 --- /dev/null +++ b/slack/slack_thread.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from itertools import chain +from typing import TYPE_CHECKING, Dict, Mapping, Tuple + +from slack.slack_buffer import SlackBuffer +from slack.slack_message import SlackMessage, SlackTs +from slack.task import gather + +if TYPE_CHECKING: + from typing_extensions import Literal + + from slack.slack_workspace import SlackWorkspace + + +class SlackThread(SlackBuffer): + def __init__(self, parent: SlackMessage) -> None: + super().__init__() + self.parent = parent + + @property + def workspace(self) -> SlackWorkspace: + return self.parent.workspace + + @property + def context(self) -> Literal["conversation", "thread"]: + return "thread" + + @property + def messages(self) -> Mapping[SlackTs, SlackMessage]: + return self.parent.replies + + async def get_name_and_buffer_props(self) -> Tuple[str, Dict[str, str]]: + conversation_name = await self.parent.conversation.name_with_prefix("full_name") + name = f"{conversation_name}.${self.parent.hash}" + short_name = f" ${self.parent.hash}" + + return name, { + "short_name": short_name, + "title": "topic", + "input_multiline": "1", + "localvar_set_type": self.parent.conversation.buffer_type, + "localvar_set_slack_type": "thread", + "localvar_set_nick": self.workspace.my_user.nick(), + "localvar_set_channel": name, + "localvar_set_server": self.workspace.name, + } + + async def buffer_switched_to(self): + await self.fill_history() + + async def print_history(self): + if self.history_filled: + return + + self.history_filled = True + + with self.loading(): + messages = chain([self.parent], self.parent.replies.values()) + for message in messages: + await self.print_message(message, backlog=True) + + async def fill_history(self): + if self.history_pending: + return + + if self.parent.reply_history_filled: + await self.print_history() + return + + with self.loading(): + self.history_pending = True + + messages = await self.parent.conversation.fetch_replies(self.parent) + if messages is None: + return + + sender_user_ids = [m.sender_user_id for m in messages if m.sender_user_id] + self.workspace.users.initialize_items(sender_user_ids) + + sender_bot_ids = [m.sender_bot_id for m in messages if m.sender_bot_id] + self.workspace.bots.initialize_items(sender_bot_ids) + + await gather(*(message.render(self.context) for message in messages)) + await self.print_history() + + self.history_pending = False diff --git a/slack/slack_workspace.py b/slack/slack_workspace.py index a570658..08895d1 100644 --- a/slack/slack_workspace.py +++ b/slack/slack_workspace.py @@ -25,8 +25,10 @@ from slack.log import print_error from slack.proxy import Proxy from slack.shared import shared from slack.slack_api import SlackApi +from slack.slack_buffer import SlackBuffer from slack.slack_conversation import SlackConversation from slack.slack_message import SlackMessage, SlackTs +from slack.slack_thread import SlackThread from slack.slack_user import SlackBot, SlackUser, SlackUsergroup from slack.task import Future, Task, create_task, gather, run_async from slack.util import get_callback_name @@ -344,12 +346,25 @@ class SlackWorkspace: print("lost connection on ping, reconnecting") run_async(self.reconnect()) - def send_typing(self, conversation_id: str): + def send_typing(self, buffer: SlackBuffer): if not self.is_connected: raise SlackError(self, "Can't send typing when not connected") if self._ws is None: raise SlackError(self, "is_connected is True while _ws is None") - msg = {"type": "typing", "channel": conversation_id} + + if isinstance(buffer, SlackConversation): + conversation_id = buffer.id + elif isinstance(buffer, SlackThread): + conversation_id = buffer.parent.conversation.id + else: + raise NotImplementedError(f"Unknown buffer type: {type(buffer)}") + + msg = { + "type": "user_typing", + "channel": conversation_id, + } + if isinstance(buffer, SlackThread): + msg["thread_ts"] = buffer.parent.ts self._ws.send(json.dumps(msg)) async def reconnect(self): -- cgit