mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 06:58:51 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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."]
|
||||
Reference in New Issue
Block a user