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
This commit is contained in:
Wei Chengqian
2026-10-11 09:56:54 +08:00
committed by GitHub
parent f9ad3003af
commit 1d474855a1
3 changed files with 300 additions and 69 deletions
@@ -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:
@@ -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(
+175
View File
@@ -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."]