From 1d474855a1b737860a124be0e2ed586fcc077b32 Mon Sep 17 00:00:00 2001 From: Wei Chengqian Date: Sun, 11 Oct 2026 09:56:54 +0800 Subject: [PATCH] fix: deliver Slack TTS audio with text fallback (#10496) * fix: deliver Slack TTS audio with text fallback * fix: avoid duplicate Slack TTS fallback text * fix: preserve Slack audio order and retry text delivery --- .../platform/sources/slack/slack_adapter.py | 32 +--- .../platform/sources/slack/slack_event.py | 162 +++++++++++----- tests/test_slack_tts.py | 175 ++++++++++++++++++ 3 files changed, 300 insertions(+), 69 deletions(-) create mode 100644 tests/test_slack_tts.py diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index fa5b28088..66d1880c1 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -83,34 +83,14 @@ class SlackAdapter(Platform): session: MessageSesion, message_chain: MessageChain, ) -> None: - blocks, text = await SlackMessageEvent._parse_slack_blocks( - message_chain=message_chain, + channel_id = session.session_id + if session.message_type == MessageType.GROUP_MESSAGE: + channel_id = channel_id.split("_")[-1] + await SlackMessageEvent._send_message( + message=message_chain, web_client=self.web_client, + channel_id=channel_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) async def convert_message(self, event: dict) -> AstrBotMessage: diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py index 7135d172e..ae96e6861 100644 --- a/astrbot/core/platform/sources/slack/slack_event.py +++ b/astrbot/core/platform/sources/slack/slack_event.py @@ -15,6 +15,7 @@ from astrbot.api.message_components import ( File, Image, Plain, + Record, ) from astrbot.api.platform import Group, MessageMember from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path @@ -114,16 +115,56 @@ class SlackMessageEvent(AstrMessageEvent): async def _parse_slack_blocks( message_chain: MessageChain, web_client: AsyncWebClient, + channel_id: str | None = None, + plain_texts: set[str] | None = None, ): - """解析成 Slack 块格式""" + """Build Slack blocks and upload audio to the destination conversation. + + Args: + message_chain: Message components to send. + web_client: Slack client used for uploads. + channel_id: Destination channel or user ID, required for audio uploads. + plain_texts: Text already included in the reply, possibly sent separately. + + Returns: + Blocks and fallback text. Both are empty for audio-only uploads. + """ blocks = [] text_content = "" + plain_texts = (plain_texts or set()) | { + segment.text + for segment in message_chain.chain + if isinstance(segment, Plain) + } for segment in message_chain.chain: if isinstance(segment, Plain): text_content += segment.text elif isinstance(segment, At): text_content += f"<@{segment.qq}>" + elif isinstance(segment, Record): + try: + if not channel_id: + raise ValueError("Slack audio upload requires a destination.") + path = Path(await segment.convert_to_file_path()) + # File uploads require a conversation ID, not a user ID. + if channel_id.startswith(("U", "W")): + response = await web_client.conversations_open(users=channel_id) + channel_id = response["channel"]["id"] + response = await web_client.files_upload_v2( + file=await asyncio.to_thread(path.read_bytes), + filename=path.name, + channel=channel_id, + ) + if not response["ok"]: + raise RuntimeError(response.get("error", "Audio upload failed")) + except Exception: + logger.warning( + "Slack audio upload failed.", + exc_info=True, + ) + if segment.text not in plain_texts: + text_content += segment.text or "[Audio]" else: # 如果有文本内容,先添加文本块 if text_content.strip(): @@ -151,52 +192,87 @@ class SlackMessageEvent(AstrMessageEvent): return blocks, "" if blocks else text_content + @staticmethod + async def _send_message( + message: MessageChain, + web_client: AsyncWebClient, + channel_id: str, + plain_texts: set[str] | None = None, + ) -> None: + """Send ordered Slack message parts with a plain-text retry. + + Args: + message: Message components in delivery order. + web_client: Slack client used to deliver messages. + channel_id: Destination channel or user ID. + plain_texts: Text also delivered separately by the response pipeline. + """ + plain_texts = (plain_texts or set()) | { + segment.text for segment in message.chain if isinstance(segment, Plain) + } + # Upload each audio segment only after delivering preceding components. + chains = [] + start = 0 + for index, segment in enumerate(message.chain): + if isinstance(segment, Record): + if start < index: + chains.append(message.derive(message.chain[start:index])) + chains.append(message.derive([segment])) + start = index + 1 + if start < len(message.chain): + chains.append(message.derive(message.chain[start:])) + + for chain in chains: + blocks, text = await SlackMessageEvent._parse_slack_blocks( + chain, + web_client, + channel_id=channel_id, + plain_texts=plain_texts, + ) + if not blocks and not text.strip(): + continue + + try: + await web_client.chat_postMessage( + channel=channel_id, + text=text, + blocks=blocks or None, + ) + except Exception: + # Retry only this part so already delivered parts are not repeated. + parts = [] + for segment in chain.chain: + if isinstance(segment, Plain): + parts.append(segment.text) + elif isinstance(segment, At): + parts.append(f"<@{segment.qq}>") + elif isinstance(segment, File): + parts.append(f" [文件: {segment.name}] ") + elif isinstance(segment, Image): + parts.append(" [图片] ") + elif ( + isinstance(segment, Record) and segment.text not in plain_texts + ): + parts.append(segment.text or "[Audio]") + await web_client.chat_postMessage( + channel=channel_id, + text="".join(parts), + ) + async def send(self, message: MessageChain) -> None: - blocks, text = await SlackMessageEvent._parse_slack_blocks( + # RespondStage sends audio separately from the remaining dual-output text. + result = self.get_result() + plain_texts = { + segment.text + for segment in (result.chain if result else []) + if isinstance(segment, Plain) + } + await self._send_message( message, self.web_client, + channel_id=self.get_group_id() or self.get_sender_id(), + plain_texts=plain_texts, ) - - 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, At): - parts.append(f"<@{segment.qq}>") - 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, - ) - await super().send(message) async def send_streaming( diff --git a/tests/test_slack_tts.py b/tests/test_slack_tts.py new file mode 100644 index 000000000..1c95157de --- /dev/null +++ b/tests/test_slack_tts.py @@ -0,0 +1,175 @@ +import wave +from functools import partial +from unittest.mock import AsyncMock + +import pytest + +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import Plain, Record +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + Platform, + PlatformMetadata, +) +from astrbot.core.message.message_event_result import MessageEventResult +from astrbot.core.pipeline.respond.stage import RespondStage +from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.sources.slack.slack_adapter import SlackAdapter +from astrbot.core.platform.sources.slack.slack_event import SlackMessageEvent + + +@pytest.fixture +def audio_path(tmp_path): + path = tmp_path / "speech.wav" + with wave.open(str(path), "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(16000) + audio.writeframes(b"\x00\x00" * 160) + return path + + +@pytest.fixture +def slack_event(monkeypatch): + monkeypatch.setattr(AstrMessageEvent, "send", AsyncMock()) + message = AstrBotMessage() + message.type = MessageType.GROUP_MESSAGE + message.group_id = "C123" + message.sender = MessageMember(user_id="U123", nickname="Tester") + message.message = [] + client = AsyncMock() + client.files_upload_v2.return_value = {"ok": True} + return SlackMessageEvent( + "test", + message, + PlatformMetadata(name="slack", id="slack", description="Slack"), + "C123", + client, + ) + + +@pytest.fixture(params=["event", "session"]) +def slack_sender(request, slack_event, monkeypatch): + if request.param == "event": + return slack_event.send, slack_event.web_client + monkeypatch.setattr(Platform, "send_by_session", AsyncMock()) + adapter = object.__new__(SlackAdapter) + adapter.web_client = slack_event.web_client + session = MessageSesion( + platform_name="slack", + message_type=MessageType.GROUP_MESSAGE, + session_id="C123", + ) + return partial(adapter.send_by_session, session), adapter.web_client + + +@pytest.mark.asyncio +async def test_slack_tts_uploads_audio_without_empty_messages(slack_sender, audio_path): + send, client = slack_sender + original = audio_path.read_bytes() + + await send( + MessageChain([Record(file=str(audio_path), text="Speech test.")]) + ) + + client.files_upload_v2.assert_awaited_once_with( + file=original, filename="speech.wav", channel="C123" + ) + client.chat_postMessage.assert_not_awaited() + assert audio_path.read_bytes() == original + + +@pytest.mark.asyncio +@pytest.mark.parametrize("dual_output", [False, True]) +async def test_slack_tts_upload_failure_preserves_source_text( + slack_event, audio_path, monkeypatch, dual_output +): + client = slack_event.web_client + client.files_upload_v2.side_effect = RuntimeError("upload failed") + chain = [Record(file=str(audio_path), text="Speech test.")] + if dual_output: + chain.append(Plain("Speech test.")) + slack_event.set_result(MessageEventResult(chain=chain)) + stage = RespondStage() + stage.platform_settings = {} + stage.enable_seg = False + monkeypatch.setattr( + "astrbot.core.pipeline.respond.stage.call_event_hook", + AsyncMock(return_value=False), + ) + + await stage.process(slack_event) + + client.files_upload_v2.assert_awaited_once() + client.chat_postMessage.assert_awaited_once() + payload = client.chat_postMessage.await_args.kwargs + assert payload["channel"] == "C123" + assert payload["blocks"][0]["text"]["text"] == "Speech test." + + +@pytest.mark.asyncio +async def test_slack_tts_block_failure_retries_source_text(slack_sender, audio_path): + send, client = slack_sender + client.files_upload_v2.side_effect = RuntimeError("upload failed") + client.chat_postMessage.side_effect = [ + RuntimeError("blocks rejected"), + {"ok": True}, + ] + + await send( + MessageChain([Record(file=str(audio_path), text="Speech test.")]) + ) + + client.files_upload_v2.assert_awaited_once() + assert client.chat_postMessage.await_count == 2 + assert client.chat_postMessage.await_args_list[0].kwargs == { + "channel": "C123", + "text": "", + "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Speech test."}} + ], + } + assert client.chat_postMessage.await_args.kwargs == { + "channel": "C123", + "text": "Speech test.", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [None, "upload", "blocks"]) +async def test_slack_tts_preserves_mixed_message_order( + slack_sender, audio_path, failure +): + send, client = slack_sender + if failure == "upload": + client.files_upload_v2.side_effect = RuntimeError("upload failed") + elif failure == "blocks": + client.chat_postMessage.side_effect = [ + {"ok": True}, + RuntimeError("blocks rejected"), + {"ok": True}, + ] + + await send( + MessageChain( + [ + Plain("Speech test."), + Record(file=str(audio_path), text="Speech test."), + Plain("After."), + ] + ) + ) + + expected_calls = ["chat_postMessage", "files_upload_v2", "chat_postMessage"] + if failure == "blocks": + expected_calls.append("chat_postMessage") + assert client.chat_postMessage.await_args.kwargs == { + "channel": "C123", "text": "After." + } + assert [call[0] for call in client.mock_calls] == expected_calls + assert [ + call.kwargs["blocks"][0]["text"]["text"] + for call in client.chat_postMessage.await_args_list[:2] + ] == ["Speech test.", "After."]