diff --git a/astrbot/core/platform/sources/slack/session_codec.py b/astrbot/core/platform/sources/slack/session_codec.py new file mode 100644 index 0000000000..dd6b885a0c --- /dev/null +++ b/astrbot/core/platform/sources/slack/session_codec.py @@ -0,0 +1,107 @@ +THREAD_SESSION_MARKER = "__thread__" +LEGACY_GROUP_SESSION_PREFIX = "group_" +SLACK_DEFAULT_TEXT_FALLBACKS = { + "safe_text": "message", + "image": "[image]", + "file_template": "[file:{name}]", + "generic": "[message]", + "image_upload_failed": "Image upload failed", + "file_upload_failed": "File upload failed", +} +SLACK_SAFE_TEXT_FALLBACK = SLACK_DEFAULT_TEXT_FALLBACKS["safe_text"] + + +def build_slack_text_fallbacks(overrides: dict | None = None) -> dict[str, str]: + """Build Slack text fallback rules. + + Only keys defined in `SLACK_DEFAULT_TEXT_FALLBACKS` are honored; unknown + override keys are intentionally ignored. + """ + text_fallbacks = dict(SLACK_DEFAULT_TEXT_FALLBACKS) + if not isinstance(overrides, dict): + return text_fallbacks + + for key in text_fallbacks: + candidate = overrides.get(key) + if isinstance(candidate, str) and candidate.strip(): + text_fallbacks[key] = candidate + return text_fallbacks + + +def encode_thread_session_id(channel_id: str, thread_ts: str) -> str: + if not channel_id or not thread_ts: + return channel_id + return f"{channel_id}{THREAD_SESSION_MARKER}{thread_ts}" + + +def decode_slack_session_id(session_id: str) -> tuple[str, str | None]: + """Decode a Slack session id into (channel_id, thread_ts|None).""" + if not session_id: + return "", None + + if THREAD_SESSION_MARKER in session_id: + channel_id, thread_ts = session_id.split(THREAD_SESSION_MARKER, 1) + return channel_id, thread_ts or None + + if session_id.startswith(LEGACY_GROUP_SESSION_PREFIX): + return session_id[len(LEGACY_GROUP_SESSION_PREFIX) :], None + + return session_id, None + + +def resolve_target_from_event( + *, + session_id: str, + raw_message: dict, + group_id: str = "", +) -> tuple[str, str | None]: + """Resolve target for received Slack events (uses event raw payload).""" + return resolve_slack_message_target( + session_id=session_id, + raw_message=raw_message, + group_id=group_id, + ) + + +def resolve_target_from_session( + *, + session_id: str, + group_id: str = "", + fallback_channel_id: str = "", +) -> tuple[str, str | None]: + """Resolve target when only session metadata is available (no raw event).""" + return resolve_slack_message_target( + session_id=session_id, + group_id=group_id, + sender_id=fallback_channel_id, + ) + + +def resolve_slack_message_target( + session_id: str, + *, + raw_message: dict | None = None, + group_id: str = "", + sender_id: str = "", +) -> tuple[str, str | None]: + """Backward-compatible resolver shared by legacy and new Slack call sites. + + Precedence for channel: group_id > raw_message.channel > parsed(session_id) > sender_id + Precedence for thread: raw_message.thread_ts > parsed(session_id) + """ + parsed_channel_id, parsed_thread_ts = decode_slack_session_id(session_id) + + raw_channel_id = "" + raw_thread_ts = None + if isinstance(raw_message, dict): + raw_channel = raw_message.get("channel") + if raw_channel not in (None, ""): + raw_channel_id = str(raw_channel) + + raw_thread = raw_message.get("thread_ts") + if raw_thread not in (None, ""): + raw_thread_ts = str(raw_thread) + + channel_id = group_id or raw_channel_id or parsed_channel_id or sender_id + thread_ts = raw_thread_ts or parsed_thread_ts + return channel_id, thread_ts diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index 13e317e49c..11239795b2 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -19,12 +19,18 @@ Platform, PlatformMetadata, ) -from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.astr_message_event import MessageSession from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter from .client import SlackSocketClient, SlackWebhookClient +from .session_codec import ( + build_slack_text_fallbacks, + encode_thread_session_id, + resolve_target_from_session, +) from .slack_event import SlackMessageEvent +from .slack_send_utils import send_with_blocks_and_fallback @register_platform_adapter( @@ -76,43 +82,48 @@ def __init__( self.webhook_client = None self.bot_self_id = None + # Canonical fallback configuration for Slack sends. Both adapter and event + # paths consume these via explicit arguments. + self.text_fallbacks = build_slack_text_fallbacks( + platform_config.get("text_fallbacks"), + ) async def send_by_session( self, - session: MessageSesion, + session: MessageSession, message_chain: MessageChain, ) -> None: - blocks, text = await SlackMessageEvent._parse_slack_blocks( - message_chain=message_chain, + channel_id, thread_ts = resolve_target_from_session( + session_id=session.session_id + ) + await send_with_blocks_and_fallback( web_client=self.web_client, + channel=channel_id, + thread_ts=thread_ts, + message_chain=message_chain, + fallbacks=self.text_fallbacks, + parse_blocks=SlackMessageEvent._parse_slack_blocks, + build_text_fallback=SlackMessageEvent._build_text_fallback_from_chain, + session_id=session.session_id, ) - try: - if session.message_type == MessageType.GROUP_MESSAGE: - # 发送到频道 - channel_id = ( - session.session_id.split("_")[-1] - if "_" in session.session_id - else session.session_id - ) - await self.web_client.chat_postMessage( - channel=channel_id, - text=text, - blocks=blocks if blocks else None, - ) - else: - # 发送私信 - await self.web_client.chat_postMessage( - channel=session.session_id, - text=text, - blocks=blocks if blocks else None, - ) - except Exception as e: - logger.error(f"Slack 发送消息失败: {e}") - await super().send_by_session(session, message_chain) + @staticmethod + def _unwrap_message_replied_event(event: dict) -> dict: + """Flatten Slack message_replied envelopes for normal message processing.""" + if event.get("subtype") == "message_replied": + nested_message = event.get("message") + if isinstance(nested_message, dict): + merged = dict(event) + merged.update(nested_message) + if not merged.get("channel"): + merged["channel"] = event.get("channel", "") + return merged + return event + async def convert_message(self, event: dict) -> AstrBotMessage: + event = self._unwrap_message_replied_event(event) logger.debug(f"[slack] RawMessage {event}") abm = AstrBotMessage() @@ -146,7 +157,13 @@ async def convert_message(self, event: dict) -> AstrBotMessage: abm.group_id = channel_id # 设置会话ID - if abm.type == MessageType.GROUP_MESSAGE: + channel_id_for_session = str(channel_id or "") + # thread_ts may come from unwrapped `message_replied` payloads. + thread_ts = str(event.get("thread_ts", "") or "") + if thread_ts and channel_id_for_session: + # Slack threads can appear in channels and DMs. + abm.session_id = encode_thread_session_id(channel_id_for_session, thread_ts) + elif abm.type == MessageType.GROUP_MESSAGE: abm.session_id = abm.group_id else: abm.session_id = user_id @@ -418,6 +435,7 @@ async def handle_msg(self, message: AstrBotMessage) -> None: platform_meta=self.meta(), session_id=message.session_id, web_client=self.web_client, + text_fallbacks=self.text_fallbacks, ) self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py index 3f62690b53..b14fdeea2d 100644 --- a/astrbot/core/platform/sources/slack/slack_event.py +++ b/astrbot/core/platform/sources/slack/slack_event.py @@ -5,16 +5,24 @@ from slack_sdk.web.async_client import AsyncWebClient -from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( BaseMessageComponent, - File, - Image, Plain, ) from astrbot.api.platform import Group, MessageMember +from .session_codec import ( + build_slack_text_fallbacks, + resolve_target_from_event, +) +from .slack_send_utils import ( + build_text_fallback_from_chain, + from_segment_to_slack_block, + parse_slack_blocks, + send_with_blocks_and_fallback, +) + class SlackMessageEvent(AstrMessageEvent): def __init__( @@ -24,151 +32,69 @@ def __init__( platform_meta, session_id, web_client: AsyncWebClient, + text_fallbacks: dict[str, str] | None = None, ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.web_client = web_client + self.text_fallbacks = build_slack_text_fallbacks(text_fallbacks) @staticmethod async def _from_segment_to_slack_block( segment: BaseMessageComponent, web_client: AsyncWebClient, + fallbacks: dict[str, str] | None = None, ) -> dict | None: """将消息段转换为 Slack 块格式""" - if isinstance(segment, Plain): - return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}} - if isinstance(segment, Image): - # upload file - url = segment.url or segment.file - if url and url.startswith("http"): - return { - "type": "image", - "image_url": url, - "alt_text": "图片", - } - path = await segment.convert_to_file_path() - response = await web_client.files_upload_v2( - file=path, - filename="image.jpg", - ) - if not response["ok"]: - logger.error(f"Slack file upload failed: {response['error']}") - return { - "type": "section", - "text": {"type": "mrkdwn", "text": "图片上传失败"}, - } - image_url = cast(list, response["files"])[0]["url_private"] - logger.debug(f"Slack file upload response: {response}") - return { - "type": "image", - "slack_file": { - "url": image_url, - }, - "alt_text": "图片", - } - if isinstance(segment, File): - # upload file - url = segment.url or segment.file - response = await web_client.files_upload_v2( - file=url, - filename=segment.name or "file", - ) - if not response["ok"]: - logger.error(f"Slack file upload failed: {response['error']}") - return { - "type": "section", - "text": {"type": "mrkdwn", "text": "文件上传失败"}, - } - file_url = cast(list, response["files"])[0]["permalink"] - return { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"文件: <{file_url}|{segment.name or '文件'}>", - }, - } + return await from_segment_to_slack_block( + segment=segment, + web_client=web_client, + fallbacks=fallbacks, + ) @staticmethod async def _parse_slack_blocks( message_chain: MessageChain, web_client: AsyncWebClient, + fallbacks: dict[str, str] | None = None, ): """解析成 Slack 块格式""" - blocks = [] - text_content = "" - - for segment in message_chain.chain: - if isinstance(segment, Plain): - text_content += segment.text - else: - # 如果有文本内容,先添加文本块 - if text_content.strip(): - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": text_content}, - }, - ) - text_content = "" - - # 添加其他类型的块 - block = await SlackMessageEvent._from_segment_to_slack_block( - segment, - web_client, - ) - if block: - blocks.append(block) - - # 如果最后还有文本内容 - if text_content.strip(): - blocks.append( - {"type": "section", "text": {"type": "mrkdwn", "text": text_content}}, - ) + return await parse_slack_blocks( + message_chain=message_chain, + web_client=web_client, + fallbacks=fallbacks, + ) - return blocks, "" if blocks else text_content + @staticmethod + def _build_text_fallback_from_chain( + message_chain: MessageChain, + fallbacks: dict[str, str] | None = None, + ) -> str: + """Build a safe text fallback for retries when block payload is rejected.""" + return build_text_fallback_from_chain( + message_chain=message_chain, + fallbacks=fallbacks, + ) - async def send(self, message: MessageChain) -> None: - blocks, text = await SlackMessageEvent._parse_slack_blocks( - message, - self.web_client, + def _resolve_target(self) -> tuple[str, str | None]: + raw_message = getattr(self.message_obj, "raw_message", None) + return resolve_target_from_event( + session_id=self.session_id, + raw_message=raw_message if isinstance(raw_message, dict) else {}, + group_id=self.get_group_id(), ) - try: - if self.get_group_id(): - # 发送到频道 - await self.web_client.chat_postMessage( - channel=self.get_group_id(), - text=text, - blocks=blocks or None, - ) - else: - # 发送私信 - await self.web_client.chat_postMessage( - channel=self.get_sender_id(), - text=text, - blocks=blocks or None, - ) - except Exception: - # 如果块发送失败,尝试只发送文本 - parts = [] - for segment in message.chain: - if isinstance(segment, Plain): - parts.append(segment.text) - elif isinstance(segment, File): - parts.append(f" [文件: {segment.name}] ") - elif isinstance(segment, Image): - parts.append(" [图片] ") - fallback_text = "".join(parts) - - if self.get_group_id(): - await self.web_client.chat_postMessage( - channel=self.get_group_id(), - text=fallback_text, - ) - else: - await self.web_client.chat_postMessage( - channel=self.get_sender_id(), - text=fallback_text, - ) + async def send(self, message: MessageChain) -> None: + channel_id, thread_ts = self._resolve_target() + await send_with_blocks_and_fallback( + web_client=self.web_client, + channel=channel_id, + thread_ts=thread_ts, + message_chain=message, + fallbacks=self.text_fallbacks, + parse_blocks=SlackMessageEvent._parse_slack_blocks, + build_text_fallback=SlackMessageEvent._build_text_fallback_from_chain, + session_id=self.session_id, + ) await super().send(message) diff --git a/astrbot/core/platform/sources/slack/slack_send_utils.py b/astrbot/core/platform/sources/slack/slack_send_utils.py new file mode 100644 index 0000000000..19522df850 --- /dev/null +++ b/astrbot/core/platform/sources/slack/slack_send_utils.py @@ -0,0 +1,241 @@ +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from slack_sdk.web.async_client import AsyncWebClient + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import BaseMessageComponent, File, Image, Plain + +from .session_codec import SLACK_SAFE_TEXT_FALLBACK, build_slack_text_fallbacks + +ParseSlackBlocksFn = Callable[ + [MessageChain, AsyncWebClient, dict[str, str] | None], + Awaitable[tuple[list[dict[str, Any]], str]], +] +BuildTextFallbackFn = Callable[[MessageChain, dict[str, str] | None], str] + + +async def from_segment_to_slack_block( + segment: BaseMessageComponent, + web_client: AsyncWebClient, + fallbacks: dict[str, str] | None = None, +) -> dict[str, Any] | None: + """Convert a message segment into a Slack block.""" + # Use caller-provided, pre-normalized fallbacks when available to avoid + # repeated normalization per segment. + resolved_fallbacks = ( + build_slack_text_fallbacks(None) if fallbacks is None else fallbacks + ) + if isinstance(segment, Plain): + return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}} + if isinstance(segment, Image): + url = segment.url or segment.file + if url and url.startswith("http"): + return { + "type": "image", + "image_url": url, + "alt_text": "图片", + } + path = await segment.convert_to_file_path() + response = await web_client.files_upload_v2( + file=path, + filename="image.jpg", + ) + if not response["ok"]: + logger.error(f"Slack file upload failed: {response['error']}") + return { + "type": "section", + "text": { + "type": "mrkdwn", + "text": resolved_fallbacks["image_upload_failed"], + }, + } + image_url = cast(list, response["files"])[0]["url_private"] + logger.debug(f"Slack file upload response: {response}") + return { + "type": "image", + "slack_file": { + "url": image_url, + }, + "alt_text": "图片", + } + if isinstance(segment, File): + url = segment.url or segment.file + response = await web_client.files_upload_v2( + file=url, + filename=segment.name or "file", + ) + if not response["ok"]: + logger.error(f"Slack file upload failed: {response['error']}") + return { + "type": "section", + "text": { + "type": "mrkdwn", + "text": resolved_fallbacks["file_upload_failed"], + }, + } + file_url = cast(list, response["files"])[0]["permalink"] + return { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"file: <{file_url}|{segment.name or 'file'}>", + }, + } + + return None + + +async def parse_slack_blocks( + message_chain: MessageChain, + web_client: AsyncWebClient, + fallbacks: dict[str, str] | None = None, +) -> tuple[list[dict[str, Any]], str]: + """Parse a message chain into Slack blocks and fallback text.""" + resolved_fallbacks = ( + build_slack_text_fallbacks(None) if fallbacks is None else fallbacks + ) + blocks: list[dict[str, Any]] = [] + text_content = "" + fallback_parts = [] + + for segment in message_chain.chain: + if isinstance(segment, Plain): + text_content += segment.text + fallback_parts.append(segment.text) + continue + + if text_content.strip(): + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": text_content}, + }, + ) + text_content = "" + + block = await from_segment_to_slack_block( + segment, + web_client, + fallbacks=resolved_fallbacks, + ) + if not block: + continue + + blocks.append(block) + if isinstance(segment, Image): + fallback_parts.append(resolved_fallbacks["image"]) + elif isinstance(segment, File): + fallback_parts.append( + resolved_fallbacks["file_template"].format( + name=segment.name or "file", + ), + ) + else: + fallback_parts.append(resolved_fallbacks["generic"]) + + if text_content.strip(): + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": text_content}}, + ) + + fallback_text = "".join(fallback_parts).strip() or resolved_fallbacks["safe_text"] + return blocks, fallback_text + + +def build_text_fallback_from_chain( + message_chain: MessageChain, + fallbacks: dict[str, str] | None = None, +) -> str: + """Build safe text fallback when block payload is rejected.""" + resolved_fallbacks = ( + build_slack_text_fallbacks(None) if fallbacks is None else fallbacks + ) + parts = [] + for segment in message_chain.chain: + if isinstance(segment, Plain): + parts.append(segment.text) + elif isinstance(segment, File): + parts.append( + resolved_fallbacks["file_template"].format( + name=segment.name or "file", + ), + ) + elif isinstance(segment, Image): + parts.append(resolved_fallbacks["image"]) + else: + parts.append(resolved_fallbacks["generic"]) + return "".join(parts).strip() or resolved_fallbacks["safe_text"] + + +async def send_with_blocks_and_fallback( + *, + web_client: AsyncWebClient, + channel: str, + thread_ts: str | None, + message_chain: MessageChain, + fallbacks: dict[str, str] | None = None, + parse_blocks: ParseSlackBlocksFn = parse_slack_blocks, + build_text_fallback: BuildTextFallbackFn = build_text_fallback_from_chain, + session_id: str = "", +) -> None: + """Send Slack message with blocks first, then fallback to text-only on failure.""" + resolved_fallbacks = ( + build_slack_text_fallbacks(None) if fallbacks is None else fallbacks + ) + if not channel: + logger.warning( + "Skip Slack send because channel_id is empty. session_id=%s thread_ts=%s", + session_id, + thread_ts or "", + ) + return + blocks, text = await parse_blocks( + message_chain, + web_client, + resolved_fallbacks, + ) + safe_text = text or resolved_fallbacks.get("safe_text", SLACK_SAFE_TEXT_FALLBACK) + + message_payload: dict[str, Any] = { + "channel": channel, + "text": safe_text, + "blocks": blocks or None, + } + if thread_ts: + message_payload["thread_ts"] = thread_ts + + try: + await web_client.chat_postMessage(**message_payload) + return + except Exception: + logger.exception( + "Slack send failed, retrying with text-only payload. " + "session_id=%s channel_id=%s thread_ts=%s", + session_id, + channel, + thread_ts or "", + ) + + fallback_text = build_text_fallback(message_chain, resolved_fallbacks) + fallback_text = (fallback_text or "").strip() or resolved_fallbacks.get( + "safe_text", SLACK_SAFE_TEXT_FALLBACK + ) + fallback_payload: dict[str, Any] = { + "channel": channel, + "text": fallback_text, + } + if thread_ts: + fallback_payload["thread_ts"] = thread_ts + + try: + await web_client.chat_postMessage(**fallback_payload) + except Exception: + logger.exception( + "Slack send text-only fallback failed. " + "session_id=%s channel_id=%s thread_ts=%s", + session_id, + channel, + thread_ts or "", + ) diff --git a/tests/unit/test_slack_thread_session.py b/tests/unit/test_slack_thread_session.py new file mode 100644 index 0000000000..0fc7d3dd72 --- /dev/null +++ b/tests/unit/test_slack_thread_session.py @@ -0,0 +1,576 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import Plain +from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.platform.sources.slack import ( + slack_send_utils as slack_send_utils_module, +) +from astrbot.core.platform.sources.slack.session_codec import ( + build_slack_text_fallbacks, + decode_slack_session_id, + resolve_slack_message_target, + resolve_target_from_event, + resolve_target_from_session, +) +from astrbot.core.platform.sources.slack.slack_adapter import SlackAdapter +from astrbot.core.platform.sources.slack.slack_event import SlackMessageEvent +from astrbot.core.utils.metrics import Metric + + +@pytest.fixture(autouse=True) +def _disable_metric_upload(monkeypatch): + monkeypatch.setattr(Metric, "upload", AsyncMock()) + + +@pytest.fixture +def slack_adapter(): + adapter = SlackAdapter( + platform_config={ + "id": "slack_test", + "bot_token": "xoxb-test", + "app_token": "xapp-test", + }, + platform_settings={}, + event_queue=asyncio.Queue(), + ) + adapter.bot_self_id = "B0001" + return adapter + + +@pytest.mark.asyncio +async def test_convert_message_group_thread_uses_thread_session(slack_adapter): + slack_adapter.web_client = AsyncMock() + slack_adapter.web_client.users_info = AsyncMock( + return_value={"user": {"real_name": "Alice", "name": "alice"}}, + ) + slack_adapter.web_client.conversations_info = AsyncMock( + return_value={"channel": {"is_im": False}}, + ) + + abm = await slack_adapter.convert_message( + { + "user": "U0001", + "channel": "C0001", + "text": "hello", + "client_msg_id": "m-1", + "ts": "1710000001.100", + "thread_ts": "1710000000.500", + }, + ) + + assert abm.type == MessageType.GROUP_MESSAGE + assert abm.group_id == "C0001" + assert abm.session_id == "C0001__thread__1710000000.500" + + +@pytest.mark.asyncio +async def test_convert_message_friend_thread_uses_thread_session(slack_adapter): + slack_adapter.web_client = AsyncMock() + slack_adapter.web_client.users_info = AsyncMock( + return_value={"user": {"real_name": "Alice", "name": "alice"}}, + ) + slack_adapter.web_client.conversations_info = AsyncMock( + return_value={"channel": {"is_im": True}}, + ) + + abm = await slack_adapter.convert_message( + { + "user": "U0001", + "channel": "D0001", + "text": "hello in dm thread", + "client_msg_id": "m-friend-1", + "ts": "1710000010.100", + "thread_ts": "1710000009.500", + }, + ) + + assert abm.type == MessageType.FRIEND_MESSAGE + assert abm.session_id == "D0001__thread__1710000009.500" + + +@pytest.mark.asyncio +async def test_convert_message_unwraps_message_replied_event(slack_adapter): + slack_adapter.web_client = AsyncMock() + slack_adapter.web_client.users_info = AsyncMock( + return_value={"user": {"real_name": "Alice", "name": "alice"}}, + ) + slack_adapter.web_client.conversations_info = AsyncMock( + return_value={"channel": {"is_im": False}}, + ) + + abm = await slack_adapter.convert_message( + { + "type": "message", + "subtype": "message_replied", + "channel": "C0001", + "message": { + "user": "U0001", + "text": "reply", + "ts": "1710000200.001", + "thread_ts": "1710000000.500", + "client_msg_id": "m-replied-1", + }, + }, + ) + + assert abm.sender.user_id == "U0001" + assert abm.message_str == "reply" + assert abm.session_id == "C0001__thread__1710000000.500" + + +@pytest.mark.asyncio +async def test_send_by_session_group_thread_posts_with_thread_ts(slack_adapter, monkeypatch): + slack_adapter.web_client = AsyncMock() + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock(return_value=([], "reply")), + ) + session = MessageSession( + platform_name="slack_test", + message_type=MessageType.GROUP_MESSAGE, + session_id="C0001__thread__1710000000.500", + ) + + await slack_adapter.send_by_session( + session=session, + message_chain=MessageChain([Plain(text="reply")]), + ) + + slack_adapter.web_client.chat_postMessage.assert_awaited_once_with( + channel="C0001", + text="reply", + blocks=None, + thread_ts="1710000000.500", + ) + + +@pytest.mark.asyncio +async def test_send_by_session_friend_thread_posts_with_thread_ts(slack_adapter, monkeypatch): + slack_adapter.web_client = AsyncMock() + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock(return_value=([], "reply")), + ) + session = MessageSession( + platform_name="slack_test", + message_type=MessageType.FRIEND_MESSAGE, + session_id="D0001__thread__1710000000.500", + ) + + await slack_adapter.send_by_session( + session=session, + message_chain=MessageChain([Plain(text="reply")]), + ) + + slack_adapter.web_client.chat_postMessage.assert_awaited_once_with( + channel="D0001", + text="reply", + blocks=None, + thread_ts="1710000000.500", + ) + + +@pytest.mark.asyncio +async def test_slack_event_send_group_thread_posts_with_thread_ts(monkeypatch): + message_obj = AstrBotMessage() + message_obj.type = MessageType.GROUP_MESSAGE + message_obj.group_id = "C0001" + message_obj.session_id = "C0001__thread__1710000000.500" + message_obj.message_id = "m-2" + message_obj.sender = MessageMember(user_id="U0001", nickname="Alice") + message_obj.message = [Plain(text="hello")] + message_obj.message_str = "hello" + message_obj.raw_message = {"thread_ts": "1710000000.500"} + + web_client = AsyncMock() + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock(return_value=([], "reply")), + ) + + event = SlackMessageEvent( + message_str=message_obj.message_str, + message_obj=message_obj, + platform_meta=PlatformMetadata( + name="slack", + description="Slack test", + id="slack_test", + ), + session_id=message_obj.session_id, + web_client=web_client, + ) + + await event.send(MessageChain([Plain(text="reply")])) + + web_client.chat_postMessage.assert_awaited_once_with( + channel="C0001", + text="reply", + blocks=None, + thread_ts="1710000000.500", + ) + + +@pytest.mark.asyncio +async def test_slack_event_send_friend_thread_posts_with_thread_ts(monkeypatch): + message_obj = AstrBotMessage() + message_obj.type = MessageType.FRIEND_MESSAGE + message_obj.session_id = "D0001__thread__1710000000.500" + message_obj.message_id = "m-friend-2" + message_obj.sender = MessageMember(user_id="U0001", nickname="Alice") + message_obj.message = [Plain(text="hello")] + message_obj.message_str = "hello" + message_obj.raw_message = {"channel": "D0001", "thread_ts": "1710000000.500"} + + web_client = AsyncMock() + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock(return_value=([], "reply")), + ) + + event = SlackMessageEvent( + message_str=message_obj.message_str, + message_obj=message_obj, + platform_meta=PlatformMetadata( + name="slack", + description="Slack test", + id="slack_test", + ), + session_id=message_obj.session_id, + web_client=web_client, + ) + + await event.send(MessageChain([Plain(text="reply")])) + + web_client.chat_postMessage.assert_awaited_once_with( + channel="D0001", + text="reply", + blocks=None, + thread_ts="1710000000.500", + ) + + +@pytest.mark.asyncio +async def test_slack_event_send_logs_exception_before_text_fallback(monkeypatch): + message_obj = AstrBotMessage() + message_obj.type = MessageType.GROUP_MESSAGE + message_obj.group_id = "C0001" + message_obj.session_id = "C0001__thread__1710000000.500" + message_obj.message_id = "m-err-1" + message_obj.sender = MessageMember(user_id="U0001", nickname="Alice") + message_obj.message = [Plain(text="hello")] + message_obj.message_str = "hello" + message_obj.raw_message = {"channel": "C0001", "thread_ts": "1710000000.500"} + + web_client = AsyncMock() + web_client.chat_postMessage = AsyncMock(side_effect=[RuntimeError("boom"), None]) + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock( + return_value=( + [{"type": "section", "text": {"type": "mrkdwn", "text": "reply"}}], + "reply", + ) + ), + ) + mocked_exception_logger = MagicMock() + monkeypatch.setattr( + slack_send_utils_module.logger, + "exception", + mocked_exception_logger, + ) + + event = SlackMessageEvent( + message_str=message_obj.message_str, + message_obj=message_obj, + platform_meta=PlatformMetadata( + name="slack", + description="Slack test", + id="slack_test", + ), + session_id=message_obj.session_id, + web_client=web_client, + ) + + await event.send(MessageChain([Plain(text="reply")])) + + assert web_client.chat_postMessage.await_count == 2 + first_call_kwargs = web_client.chat_postMessage.await_args_list[0].kwargs + second_call_kwargs = web_client.chat_postMessage.await_args_list[1].kwargs + assert first_call_kwargs["channel"] == "C0001" + assert first_call_kwargs["thread_ts"] == "1710000000.500" + assert first_call_kwargs["blocks"] + assert second_call_kwargs["channel"] == "C0001" + assert second_call_kwargs["thread_ts"] == "1710000000.500" + assert second_call_kwargs["text"] == "reply" + assert "blocks" not in second_call_kwargs + + assert mocked_exception_logger.called + assert mocked_exception_logger.call_args.args[1] == "C0001__thread__1710000000.500" + assert mocked_exception_logger.call_args.args[2] == "C0001" + assert mocked_exception_logger.call_args.args[3] == "1710000000.500" + + +@pytest.mark.asyncio +async def test_parse_slack_blocks_includes_non_empty_fallback_text(): + blocks, text = await SlackMessageEvent._parse_slack_blocks( + MessageChain([Plain(text="hello")]), + AsyncMock(), + ) + + assert blocks + assert text == "hello" + + +@pytest.mark.asyncio +async def test_parse_slack_blocks_whitespace_plain_returns_safe_fallback_text(): + custom_fallbacks = build_slack_text_fallbacks({"safe_text": "fallback-message"}) + blocks, text = await SlackMessageEvent._parse_slack_blocks( + MessageChain([Plain(text=" ")]), + AsyncMock(), + custom_fallbacks, + ) + + assert blocks == [] + assert text == "fallback-message" + + +@pytest.mark.asyncio +async def test_send_with_blocks_and_fallback_skips_when_channel_empty(monkeypatch): + web_client = AsyncMock() + mocked_warning_logger = MagicMock() + monkeypatch.setattr( + slack_send_utils_module.logger, + "warning", + mocked_warning_logger, + ) + + await slack_send_utils_module.send_with_blocks_and_fallback( + web_client=web_client, + channel="", + thread_ts="1710000000.500", + message_chain=MessageChain([Plain(text="reply")]), + fallbacks=build_slack_text_fallbacks(None), + session_id="C0001__thread__1710000000.500", + ) + + web_client.chat_postMessage.assert_not_awaited() + assert mocked_warning_logger.called + + +@pytest.mark.asyncio +async def test_send_with_blocks_and_fallback_uses_safe_text_when_custom_builder_empty(): + web_client = AsyncMock() + web_client.chat_postMessage = AsyncMock(side_effect=[RuntimeError("boom"), None]) + + async def _parse_blocks(_message_chain, _web_client, _fallbacks): + return ( + [{"type": "section", "text": {"type": "mrkdwn", "text": "reply"}}], + "reply", + ) + + def _empty_builder(_message_chain, _fallbacks): + return "" + + await slack_send_utils_module.send_with_blocks_and_fallback( + web_client=web_client, + channel="C0001", + thread_ts="1710000000.500", + message_chain=MessageChain([Plain(text="reply")]), + fallbacks=build_slack_text_fallbacks({"safe_text": "fallback-message"}), + parse_blocks=_parse_blocks, + build_text_fallback=_empty_builder, + session_id="C0001__thread__1710000000.500", + ) + + assert web_client.chat_postMessage.await_count == 2 + second_call_kwargs = web_client.chat_postMessage.await_args_list[1].kwargs + assert second_call_kwargs["text"] == "fallback-message" + assert "blocks" not in second_call_kwargs + + +@pytest.mark.asyncio +async def test_send_by_session_uses_configured_safe_text_fallback(monkeypatch): + adapter = SlackAdapter( + platform_config={ + "id": "slack_test", + "bot_token": "xoxb-test", + "app_token": "xapp-test", + "text_fallbacks": {"safe_text": "fallback-message"}, + }, + platform_settings={}, + event_queue=asyncio.Queue(), + ) + adapter.bot_self_id = "B0001" + adapter.web_client = AsyncMock() + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock(return_value=([], "")), + ) + session = MessageSession( + platform_name="slack_test", + message_type=MessageType.GROUP_MESSAGE, + session_id="C0001", + ) + + await adapter.send_by_session( + session=session, + message_chain=MessageChain([Plain(text="ignored")]), + ) + + adapter.web_client.chat_postMessage.assert_awaited_once_with( + channel="C0001", + text="fallback-message", + blocks=None, + ) + + +@pytest.mark.asyncio +async def test_send_by_session_retries_text_only_when_block_send_fails(monkeypatch): + adapter = SlackAdapter( + platform_config={ + "id": "slack_test", + "bot_token": "xoxb-test", + "app_token": "xapp-test", + }, + platform_settings={}, + event_queue=asyncio.Queue(), + ) + adapter.bot_self_id = "B0001" + adapter.web_client = AsyncMock() + adapter.web_client.chat_postMessage = AsyncMock( + side_effect=[RuntimeError("boom"), None] + ) + monkeypatch.setattr( + SlackMessageEvent, + "_parse_slack_blocks", + AsyncMock( + return_value=( + [{"type": "section", "text": {"type": "mrkdwn", "text": "reply"}}], + "reply", + ) + ), + ) + mocked_exception_logger = MagicMock() + monkeypatch.setattr( + slack_send_utils_module.logger, + "exception", + mocked_exception_logger, + ) + + session = MessageSession( + platform_name="slack_test", + message_type=MessageType.GROUP_MESSAGE, + session_id="C0001__thread__1710000000.500", + ) + await adapter.send_by_session( + session=session, + message_chain=MessageChain([Plain(text="reply")]), + ) + + assert adapter.web_client.chat_postMessage.await_count == 2 + first_call_kwargs = adapter.web_client.chat_postMessage.await_args_list[0].kwargs + second_call_kwargs = adapter.web_client.chat_postMessage.await_args_list[1].kwargs + assert first_call_kwargs["channel"] == "C0001" + assert first_call_kwargs["thread_ts"] == "1710000000.500" + assert first_call_kwargs["blocks"] + assert second_call_kwargs["channel"] == "C0001" + assert second_call_kwargs["thread_ts"] == "1710000000.500" + assert second_call_kwargs["text"] == "reply" + assert "blocks" not in second_call_kwargs + assert mocked_exception_logger.called + + +def test_build_slack_text_fallbacks_accepts_overrides(): + fallbacks = build_slack_text_fallbacks( + { + "safe_text": "msg", + "image": "[img]", + "file_template": "[doc:{name}]", + } + ) + + assert fallbacks["safe_text"] == "msg" + assert fallbacks["image"] == "[img]" + assert fallbacks["file_template"] == "[doc:{name}]" + assert "generic" in fallbacks + + +def test_decode_slack_session_id_thread_marker_does_not_fallback_to_legacy(): + channel_id, thread_ts = decode_slack_session_id("C123__thread__") + + assert channel_id == "C123" + assert thread_ts is None + + +def test_decode_slack_session_id_supports_legacy_group_prefix(): + channel_id, thread_ts = decode_slack_session_id("group_C123") + + assert channel_id == "C123" + assert thread_ts is None + + +def test_resolve_slack_message_target_prefers_raw_message_thread_ts(): + channel_id, thread_ts = resolve_slack_message_target( + session_id="C123__thread__111.222", + raw_message={"channel": "C123", "thread_ts": "333.444"}, + group_id="", + sender_id="U123", + ) + + assert channel_id == "C123" + assert thread_ts == "333.444" + + +def test_resolve_target_from_event_prefers_raw_thread_and_group_precedence(): + channel_id, thread_ts = resolve_target_from_event( + session_id="C123__thread__111.222", + raw_message={"channel": "C333", "thread_ts": "333.444"}, + group_id="C999", + ) + + assert channel_id == "C999" + assert thread_ts == "333.444" + + +def test_resolve_target_from_session_uses_parsed_thread(): + channel_id, thread_ts = resolve_target_from_session( + session_id="D123__thread__1710000000.500", + ) + + assert channel_id == "D123" + assert thread_ts == "1710000000.500" + + +def test_target_resolution_helpers_share_same_precedence(): + event_resolved = resolve_target_from_event( + session_id="C123__thread__111.222", + raw_message={"channel": "C333", "thread_ts": "333.444"}, + group_id="C999", + ) + legacy_resolved = resolve_slack_message_target( + session_id="C123__thread__111.222", + raw_message={"channel": "C333", "thread_ts": "333.444"}, + group_id="C999", + sender_id="U123", + ) + session_resolved = resolve_target_from_session( + session_id="", + group_id="", + fallback_channel_id="U123", + ) + + assert event_resolved == legacy_resolved + assert session_resolved == ("U123", None)