From d8fcca469b6bd5fc9ee11d6f07f4ca5737e34652 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:10 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat(deps):=20=E6=B7=BB=E5=8A=A0=20discor?= =?UTF-8?q?d.py=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2ab556b0f..c79e77ce6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [ "tzdata>=2025.2", "uvicorn[standard]>=0.30.0", "wecom-aibot-python-sdk>=1.0.2", - "dingtalk-stream>=0.24.3,<0.25.0" + "dingtalk-stream>=0.24.3,<0.25.0", + "discord.py>=2.3.0,<2.6.0" ] [project.optional-dependencies] From 00d1545db7a9e0af6d119c0980ecd1787714c562 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:13 +0900 Subject: [PATCH 02/27] =?UTF-8?q?test(channels):=20=E6=B7=BB=E5=8A=A0=20di?= =?UTF-8?q?scord=20=E9=80=82=E9=85=8D=E5=99=A8=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_channel_discord.py | 319 ++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 backend/tests/test_channel_discord.py diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py new file mode 100644 index 000000000..b953e0d5b --- /dev/null +++ b/backend/tests/test_channel_discord.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from app.channels.adapters.discord import ( + DISCORD_API_BASE, + DiscordAdapter, + DiscordPermanentError, + DiscordTransientError, + normalize_discord_message, + validate_discord_credentials, +) +from app.channels.crypto import encrypt_channel_secret +from app.channels.service_discord_inbox import ( + discord_account_key, + stage_discord_inbound, +) +from app.channels.service_durable_inbox import StageDisposition +from app.db.models import ChannelBinding, ChannelInboundEvent, Tenant + + +def _engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _raw(**overrides): + value = { + "id": "msg-1", + "channel_id": "channel-1", + "guild_id": "guild-1", + "author_id": "user-1", + "author_name": "Alice", + "content": "hello", + "mentions": ["bot-1"], + "bot_user_id": "bot-1", + "is_group": True, + } + value.update(overrides) + return value + + +def test_normalize_discord_dm_and_group(): + dm = normalize_discord_message( + _raw(guild_id="", is_group=False, content="hi bot", mentions=[]) + ) + assert dm is not None + assert dm.channel == "discord" + assert dm.event_id == "msg-1" + assert dm.from_user_id == "user-1" + assert dm.to_user_id == "bot-1" + assert dm.session_id == "dm:user-1" + assert dm.group_id == "" + assert dm.is_group is False + assert dm.text == "hi bot" + assert dm.sender_name == "Alice" + + group = normalize_discord_message(_raw()) + assert group is not None + assert group.is_group is True + assert group.group_id == "guild-1" + assert group.session_id == "channel-1" + + +def test_normalize_discord_filters_own_and_invalid(): + assert normalize_discord_message(_raw(author_id="bot-1")) is None + assert normalize_discord_message(_raw(content=" ")) is None + assert normalize_discord_message(_raw(id="")) is None + assert normalize_discord_message(None) is None + # 群聊未 @bot 的消息不响应。 + assert normalize_discord_message(_raw(mentions=[])) is None + + +def test_normalize_discord_strips_bot_mention_in_group(): + group = normalize_discord_message(_raw(content="<@!123456789> hello")) + assert group is not None + assert group.text == "hello" + # 没有提到的内容保留原样。 + plain = normalize_discord_message(_raw(content="hello <@!123456789>")) + assert plain is not None + assert plain.text == "hello <@!123456789>" + + +class _Response: + def __init__(self, status_code=200, payload=None): + self.status_code = status_code + self._payload = {} if payload is None else payload + + def json(self): + return self._payload + + +class _RoutingClient: + """按 URL 片段路由的假 httpx client;每个队列的最后一项会被重复返回。""" + + def __init__(self, routes): + self.routes = routes + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, json=None, headers=None, **_kwargs): + self.calls.append({"url": url, "body": json, "headers": headers or {}}) + for fragment, queue in self.routes.items(): + if fragment in url: + return queue.pop(0) if len(queue) > 1 else queue[0] + raise AssertionError(f"未预期的请求地址 {url}") + + def get(self, url, headers=None, **_kwargs): + self.calls.append({"url": url, "headers": headers or {}}) + for fragment, queue in self.routes.items(): + if fragment in url: + return queue.pop(0) if len(queue) > 1 else queue[0] + raise AssertionError(f"未预期的请求地址 {url}") + + def calls_to(self, fragment): + return [call for call in self.calls if fragment in call["url"]] + + +def _binding(**overrides): + values = { + "tenant_id": "tenant-1", + "agent_id": "agent-1", + "channel": "discord", + "status": "active", + "credentials_enc": encrypt_channel_secret("secret"), + "config_json": {"bot_id": "bot-1"}, + "external_account_key": discord_account_key("bot-1"), + "config_revision": 1, + } + values.update(overrides) + return ChannelBinding(**values) + + +def test_discord_send_posts_to_channel_with_bot_auth(): + class Client: + def __init__(self): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, json=None, headers=None, **_kwargs): + self.calls.append({"url": url, "body": json, "headers": headers or {}}) + return _Response(200) + + client = Client() + adapter = DiscordAdapter(client_factory=lambda: client) + adapter.send( + _binding(), + {"channel_id": "channel-1"}, + "hello", + idempotency_key="delivery-1", + ) + assert len(client.calls) == 1 + call = client.calls[0] + assert call["url"] == f"{DISCORD_API_BASE}/channels/channel-1/messages" + assert call["headers"]["Authorization"] == "Bot secret" + assert call["body"] == {"content": "hello"} + + +def test_discord_send_splits_long_text(): + class Client: + def __init__(self): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, json=None, headers=None, **_kwargs): + self.calls.append(json) + return _Response(200) + + client = Client() + adapter = DiscordAdapter(client_factory=lambda: client) + adapter.send( + _binding(), + {"channel_id": "channel-1"}, + "x" * 2500, + idempotency_key="delivery-1", + ) + assert len(client.calls) == 2 + assert sum(len(call["content"]) for call in client.calls) == 2500 + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (500, DiscordTransientError), + (429, DiscordTransientError), + (401, DiscordPermanentError), + (403, DiscordPermanentError), + ], +) +def test_discord_send_error_classification(status, expected): + class Client: + def post(self, url, json=None, headers=None, **_kwargs): + return _Response(status) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + adapter = DiscordAdapter(client_factory=lambda: Client()) + with pytest.raises(expected): + adapter.send(_binding(), {"channel_id": "channel-1"}, "hello") + + +def test_discord_send_rejects_missing_channel(): + adapter = DiscordAdapter() + with pytest.raises(DiscordPermanentError): + adapter.send(_binding(), {}, "hello") + + +def test_validate_discord_credentials_ok(): + client = _RoutingClient( + {"users/@me": [_Response(200, {"id": "bot-1", "username": "MyBot"})]} + ) + info = validate_discord_credentials("secret", client_factory=lambda: client) + assert info == {"bot_id": "bot-1", "bot_name": "MyBot"} + call = client.calls_to("users/@me")[0] + assert call["headers"]["Authorization"] == "Bot secret" + + +def test_validate_discord_credentials_errors(): + bad_token = validate_discord_credentials("", client_factory=lambda: _RoutingClient({})) + assert bad_token is None + with pytest.raises(DiscordPermanentError): + validate_discord_credentials( + "secret", + client_factory=lambda: _RoutingClient({"users/@me": [_Response(401)]}), + ) + with pytest.raises(DiscordTransientError): + validate_discord_credentials( + "secret", + client_factory=lambda: _RoutingClient({"users/@me": [_Response(500)]}), + ) + + +def test_stage_discord_inbound_is_deduplicated(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + binding = _binding() + db.add(binding) + db.commit() + binding_id = binding.id + inbound = normalize_discord_message(_raw(), account_scope="") + assert inbound is not None + first = stage_discord_inbound( + db_engine=db_engine, + binding_id=binding_id, + expected_revision=1, + bot_id="bot-1", + inbound=inbound, + ) + second = stage_discord_inbound( + db_engine=db_engine, + binding_id=binding_id, + expected_revision=1, + bot_id="bot-1", + inbound=inbound, + ) + assert first.disposition is StageDisposition.STAGED + assert second.disposition is StageDisposition.DUPLICATE + with Session(db_engine) as db: + events = db.exec(select(ChannelInboundEvent)).all() + assert len(events) == 1 + assert events[0].target_json["to_user_id"] == "guild-1" # 群聊 to_user_id=conv_key + assert events[0].target_json["channel_id"] == "channel-1" + + +def test_stage_discord_inbound_fence_rejections(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + binding = _binding() + db.add(binding) + db.commit() + binding_id = binding.id + inbound = normalize_discord_message(_raw(), account_scope="") + assert inbound is not None + + missing = stage_discord_inbound( + db_engine=db_engine, binding_id="missing", expected_revision=1, + bot_id="bot-1", inbound=inbound, + ) + assert missing.disposition is StageDisposition.SECURITY_DROP + + wrong_channel = stage_discord_inbound( + db_engine=db_engine, binding_id=binding_id, expected_revision=1, + bot_id="bot-2", inbound=inbound, + ) + assert wrong_channel.disposition is StageDisposition.SECURITY_DROP + + wrong_revision = stage_discord_inbound( + db_engine=db_engine, binding_id=binding_id, expected_revision=99, + bot_id="bot-1", inbound=inbound, + ) + assert wrong_revision.disposition is StageDisposition.SECURITY_DROP From 9c3ae0611e8062adc608a92afe62bb9426930582 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:16 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat(channels):=20=E5=AE=9E=E7=8E=B0=20di?= =?UTF-8?q?scord=20=E9=80=82=E9=85=8D=E5=99=A8=E4=B8=8E=E5=85=A5=E7=AB=99?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/adapters/discord.py | 432 ++++++++++++++++++ backend/app/channels/discord_runtime.py | 65 +++ backend/app/channels/service_discord_inbox.py | 101 ++++ 3 files changed, 598 insertions(+) create mode 100644 backend/app/channels/adapters/discord.py create mode 100644 backend/app/channels/discord_runtime.py create mode 100644 backend/app/channels/service_discord_inbox.py diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py new file mode 100644 index 000000000..f57ba5548 --- /dev/null +++ b/backend/app/channels/adapters/discord.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import logging +import re +import threading +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import httpx + +from app.channels.adapters.base import ( + CHANNEL_TEXT_LIMIT, + ChannelAdapter, + ChannelInbound, + register_channel_adapter, + split_channel_text, +) +from app.channels.crypto import decrypt_channel_secret + +if TYPE_CHECKING: + from app.db.models import ChannelBinding + +logger = logging.getLogger(__name__) + +DISCORD_API_BASE = "https://discord.com/api/v10" +DISCORD_USERS_ME_API = f"{DISCORD_API_BASE}/users/@me" +DISCORD_MESSAGE_API = f"{DISCORD_API_BASE}/channels/{{channel_id}}/messages" + +# Discord 机器人 mention 语法: <@123456789012345678> 或带昵称 <@!123456789012345678> +_DISCORD_MENTION_PATTERN = re.compile(r"^\s*<@!?\d+>\s*") + + +class DiscordSendError(RuntimeError): + """Discord 发送失败的基类,默认可重试。""" + + retryable = True + + +class DiscordPermanentError(DiscordSendError): + """凭证失效/权限不足等重试无意义的错误。""" + + retryable = False + + +class DiscordTransientError(DiscordSendError): + """网络抖动/限流/服务端错误等可重试错误。""" + + retryable = True + + +def normalize_discord_message(raw: Any, *, account_scope: str = "") -> ChannelInbound | None: + """把一条 Discord 消息归一化为 ChannelInbound。 + + raw 由网关线程从 discord.py 的 Message 对象提取,字段: + id / channel_id / guild_id / author_id / author_name / content / + mentions(被 @ 的用户 id 列表) / bot_user_id(本机器人的用户 id) / is_group + """ + if not isinstance(raw, dict): + return None + message_id = str(raw.get("id") or "").strip() + channel_id = str(raw.get("channel_id") or "").strip() + author_id = str(raw.get("author_id") or "").strip() + bot_user_id = str(raw.get("bot_user_id") or "").strip() + text = str(raw.get("content") or "").strip() + if not message_id or not channel_id or not author_id: + return None + # 忽略机器人自己发的消息。 + if bot_user_id and author_id == bot_user_id: + return None + is_group = bool(raw.get("is_group")) + mentions = [str(m) for m in (raw.get("mentions") or [])] + # 群聊只响应明确 @bot 的消息;私聊不受此限制。 + if is_group and not (bot_user_id and bot_user_id in mentions): + return None + if not text: + return None + # 去掉消息开头的机器人 mention,保留其余内容。 + cleaned = _DISCORD_MENTION_PATTERN.sub("", text).strip() + if not cleaned: + return None + if is_group: + guild_id = str(raw.get("guild_id") or "").strip() + session_id = channel_id + group_id = guild_id or channel_id + else: + # 私聊以发送者为会话维度,便于跨 DM 频道稳定关联。 + session_id = f"dm:{author_id}" + group_id = "" + return ChannelInbound( + channel="discord", + event_id=message_id, + from_user_id=author_id, + to_user_id=bot_user_id, + session_id=session_id, + group_id=group_id, + context_token="", + text=cleaned, + is_group=is_group, + raw=raw, + sender_name=str(raw.get("author_name") or "").strip(), + account_scope=account_scope.strip(), + ) + + +def _credential(binding: ChannelBinding) -> tuple[str, str]: + """返回 (bot_id, bot_token);缺凭证抛 DiscordPermanentError。""" + config = dict(binding.config_json or {}) + bot_id = str(config.get("bot_id") or "").strip() + token = decrypt_channel_secret(binding.credentials_enc) if binding.credentials_enc else "" + if not bot_id or not token: + raise DiscordPermanentError("Discord 绑定缺少应用凭证") + return bot_id, token + + +def _classify_http_error(exc: Exception, *, permanent_codes: frozenset[int]) -> DiscordSendError: + if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)): + return DiscordTransientError(str(exc)) + if isinstance(exc, httpx.HTTPStatusError): + status = exc.response.status_code + if status in permanent_codes: + return DiscordPermanentError(f"Discord 接口拒绝请求 (HTTP {status})") + return DiscordTransientError(f"Discord 接口暂不可用 (HTTP {status})") + return DiscordTransientError(str(exc)) + + +def validate_discord_credentials(bot_token: str, *, client_factory: Callable[[], httpx.Client] | None = None) -> dict[str, str] | None: + """调用 Discord 官方接口校验 Bot Token,返回 {bot_id, bot_name}。 + + 空 token 返回 None;凭证错误抛 DiscordPermanentError;网络问题抛 DiscordTransientError。 + """ + token = (bot_token or "").strip() + if not token: + return None + client_factory = client_factory or (lambda: httpx.Client(timeout=15.0)) + try: + with client_factory() as client: + response = client.get( + DISCORD_USERS_ME_API, + headers={"Authorization": f"Bot {token}"}, + ) + if response.status_code in (401, 403): + raise DiscordPermanentError("Discord Bot Token 无效或已被吊销") + if response.status_code >= 500 or response.status_code == 429: + raise DiscordTransientError(f"Discord 接口暂不可用 (HTTP {response.status_code})") + if response.status_code >= 400: + raise DiscordPermanentError(f"Discord 接口拒绝请求 (HTTP {response.status_code})") + data = response.json() + bot_id = str(data.get("id") or "").strip() + bot_name = str(data.get("username") or "").strip() + if not bot_id: + raise DiscordPermanentError("Discord 接口未返回机器人标识") + return {"bot_id": bot_id, "bot_name": bot_name or "Discord 机器人"} + except DiscordSendError: + raise + except (httpx.HTTPError, ValueError) as exc: + raise DiscordTransientError(str(exc)) from exc + + +class DiscordAdapter(ChannelAdapter): + """Discord 渠道适配器:Gateway 长连接入站(线程模式) + REST 出站。""" + + def __init__(self, *, client_factory: Callable[[], httpx.Client] | None = None) -> None: + self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0)) + + def normalize(self, raw: Any, *, account_scope: str = "") -> ChannelInbound | None: + return normalize_discord_message(raw, account_scope=account_scope) + + def send( + self, + binding: ChannelBinding, + target: dict[str, Any], + text: str, + *, + idempotency_key: str | None = None, + ) -> None: + channel_id = str(target.get("channel_id") or "").strip() + if not channel_id: + raise DiscordPermanentError("Discord 目标缺少 channel_id") + _bot_id, token = _credential(binding) + headers = { + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + } + try: + with self._client_factory() as client: + for chunk in split_channel_text(text, CHANNEL_TEXT_LIMIT): + response = client.post( + DISCORD_MESSAGE_API.format(channel_id=channel_id), + json={"content": chunk}, + headers=headers, + ) + if response.status_code in (401, 403): + raise DiscordPermanentError( + f"Discord 拒绝发送 (HTTP {response.status_code})" + ) + if response.status_code >= 500 or response.status_code == 429: + raise DiscordTransientError( + f"Discord 接口暂不可用 (HTTP {response.status_code})" + ) + if response.status_code >= 400: + raise DiscordPermanentError( + f"Discord 拒绝发送 (HTTP {response.status_code})" + ) + except DiscordSendError: + raise + except (httpx.HTTPError, ValueError) as exc: + raise DiscordTransientError(str(exc)) from exc + + def start_ingress(self, binding_id: str) -> None: + from app.channels import get_discord_stream_manager + + get_discord_stream_manager().ensure_binding(binding_id) + + def stop_ingress(self, binding_id: str) -> None: + from app.channels import get_discord_stream_manager + + get_discord_stream_manager().stop_binding(binding_id) + + +class DiscordStreamManager: + """每 binding 一个 daemon 线程 + 线程内独立 asyncio loop 跑 discord.py 客户端。 + + discord.py 2.x 的 loop 由 _async_setup_hook 从 asyncio.get_running_loop() 绑定, + 天然 per-instance,无需飞书那样的子进程隔离。 + """ + + def __init__( + self, + *, + db_engine=None, + client_factory: Callable[..., Any] | None = None, + ) -> None: + from app.db.engine import engine + + self._engine = db_engine or engine + # client_factory(bot_token, on_message) -> 已挂载回调的 discord.Client 实例 + self._client_factory = client_factory + self._threads: dict[str, threading.Thread] = {} + self._stops: dict[str, threading.Event] = {} + self._paused: set[str] = set() + self._lock = threading.RLock() + self._reconcile_stop = threading.Event() + self._reconcile_thread: threading.Thread | None = None + + def ensure_binding(self, binding_id: str) -> None: + with self._lock: + if binding_id in self._paused: + return + thread = self._threads.get(binding_id) + if thread is not None and thread.is_alive(): + return + stop = threading.Event() + thread = threading.Thread( + target=self._run_binding, + args=(binding_id, stop), + name=f"staffdeck-discord-{binding_id}", + daemon=True, + ) + self._stops[binding_id] = stop + self._threads[binding_id] = thread + thread.start() + + def _run_binding(self, binding_id: str, stop: threading.Event) -> None: + try: + from app.channels.discord_runtime import DiscordEventHandler + + from app.db.models import ChannelBinding + from sqlmodel import Session, select + + with Session(self._engine) as db: + binding = db.exec( + select(ChannelBinding).where(ChannelBinding.id == binding_id) + ).first() + if binding is None or binding.channel != "discord" or binding.status != "active": + return + bot_id, token = _credential(binding) + expected_revision = binding.config_revision + handler = DiscordEventHandler( + db_engine=self._engine, + binding_id=binding_id, + expected_revision=expected_revision, + bot_id=bot_id, + ) + factory = self._client_factory or self._default_client_factory + import asyncio + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + loop.run_until_complete( + self._run_gateway(factory, token, handler, stop, binding_id) + ) + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + loop.close() + except Exception: + logger.exception("discord 绑定连接退出 binding=%s", binding_id) + finally: + with self._lock: + self._threads.pop(binding_id, None) + self._stops.pop(binding_id, None) + + def _default_client_factory(self, token: str, on_message): + import discord + + intents = discord.Intents.default() + intents.message_content = True + client = discord.Client(intents=intents) + + @client.event + async def _on_message(message) -> None: + await on_message(message) + + return client + + async def _run_gateway(self, factory, token: str, handler, stop: threading.Event, binding_id: str) -> None: + import asyncio + + client = factory(token, handler.handle_message) + + async def mark_connected(connected: bool) -> None: + await asyncio.to_thread(self._set_connected, binding_id, handler.expected_revision, connected) + + try: + await mark_connected(True) + await client.start(token) + except Exception: + logger.exception("discord 网关异常退出 binding=%s", binding_id) + finally: + try: + await client.close() + except Exception: + logger.exception("关闭 discord 客户端失败 binding=%s", binding_id) + await mark_connected(False) + + def _set_connected(self, binding_id: str, revision: int, connected: bool) -> None: + try: + from app.db.models import ChannelBinding + from sqlmodel import Session, update + + with Session(self._engine) as db: + db.exec( + update(ChannelBinding) + .where( + ChannelBinding.id == binding_id, + ChannelBinding.channel == "discord", + ChannelBinding.config_revision == revision, + ) + .values(connected=connected) + ) + db.commit() + except Exception: + logger.exception("更新 discord 连接状态失败 binding=%s", binding_id) + + def stop_binding(self, binding_id: str) -> None: + stop = self._stops.get(binding_id) + if stop is not None: + stop.set() + + def pause_binding(self, binding_id: str) -> None: + with self._lock: + self._paused.add(binding_id) + self.stop_binding(binding_id) + + def resume_binding(self, binding_id: str, *, start: bool = True) -> None: + with self._lock: + self._paused.discard(binding_id) + if start: + self.ensure_binding(binding_id) + + def wait_binding_stopped(self, binding_id: str, *, timeout_seconds: float = 5.0) -> bool: + with self._lock: + thread = self._threads.get(binding_id) + if thread is None: + return True + thread.join(timeout=timeout_seconds) + return not thread.is_alive() + + def _reconcile_loop(self) -> None: + from app.db.models import ChannelBinding + from sqlmodel import Session, select + + while not self._reconcile_stop.wait(5.0): + try: + with Session(self._engine) as db: + active = { + str(b.id) + for b in db.exec( + select(ChannelBinding).where( + ChannelBinding.channel == "discord", + ChannelBinding.status == "active", + ) + ).all() + } + with self._lock: + for binding_id in active: + if binding_id not in self._paused: + self.ensure_binding(binding_id) + stale = set(self._threads) - active + for binding_id in stale: + self.stop_binding(binding_id) + except Exception: + logger.exception("discord reconcile 循环异常") + + def start(self) -> None: + self._reconcile_thread = threading.Thread( + target=self._reconcile_loop, + name="staffdeck-discord-reconcile", + daemon=True, + ) + self._reconcile_thread.start() + + def stop(self, *, timeout_seconds: float = 5.0) -> bool: + self._reconcile_stop.set() + reconcile_thread = self._reconcile_thread + if reconcile_thread is not None: + reconcile_thread.join(timeout=timeout_seconds) + with self._lock: + binding_ids = list(self._threads) + for binding_id in binding_ids: + self.stop_binding(binding_id) + stopped = True + for binding_id in binding_ids: + if not self.wait_binding_stopped(binding_id, timeout_seconds=timeout_seconds): + stopped = False + reconcile_alive = reconcile_thread is not None and reconcile_thread.is_alive() + return stopped and not reconcile_alive + + +register_channel_adapter("discord", DiscordAdapter()) diff --git a/backend/app/channels/discord_runtime.py b/backend/app/channels/discord_runtime.py new file mode 100644 index 000000000..8be3cd07f --- /dev/null +++ b/backend/app/channels/discord_runtime.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from typing import Any + +from app.channels.adapters.discord import normalize_discord_message +from app.channels.service_discord_inbox import stage_discord_inbound + +logger = logging.getLogger(__name__) + + +class DiscordEventHandler: + """把 discord.py 网关线程收到的 Message 归一化并暂存到 durable inbox。""" + + def __init__( + self, + *, + db_engine, + binding_id: str, + expected_revision: int, + bot_id: str, + ) -> None: + self.db_engine = db_engine + self.binding_id = binding_id + self.expected_revision = expected_revision + self.bot_id = bot_id + + async def handle_message(self, message: Any) -> None: + """message 为 discord.py 的 Message 对象或已序列化的 dict。""" + if hasattr(message, "to_dict") or not isinstance(message, dict): + raw = self._serialize(message) + else: + raw = message + inbound = normalize_discord_message(raw, account_scope="") + if inbound is None: + return + result = stage_discord_inbound( + db_engine=self.db_engine, + binding_id=self.binding_id, + expected_revision=self.expected_revision, + bot_id=self.bot_id, + inbound=inbound, + ) + if result.should_ack: + from app.channels.service_intake import wake_staged_inbound_worker + + wake_staged_inbound_worker() + + def _serialize(self, message: Any) -> dict[str, Any]: + """把 discord.py Message 对象序列化为 normalize 需要的 dict。""" + author = getattr(message, "author", None) + channel = getattr(message, "channel", None) + guild = getattr(message, "guild", None) + mentions = getattr(message, "mentions", None) or [] + return { + "id": str(getattr(message, "id", "") or ""), + "channel_id": str(getattr(channel, "id", "") or ""), + "guild_id": str(getattr(guild, "id", "") or "") if guild else "", + "author_id": str(getattr(author, "id", "") or ""), + "author_name": str(getattr(author, "name", "") or ""), + "content": str(getattr(message, "content", "") or ""), + "mentions": [str(getattr(u, "id", "") or "") for u in mentions], + "bot_user_id": self.bot_id, + "is_group": guild is not None, + } diff --git a/backend/app/channels/service_discord_inbox.py b/backend/app/channels/service_discord_inbox.py new file mode 100644 index 000000000..6e4fcfbda --- /dev/null +++ b/backend/app/channels/service_discord_inbox.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +from dataclasses import asdict +from typing import Any + +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlmodel import Session, select + +from app.channels.adapters.base import ChannelInbound +from app.channels.service_durable_inbox import StageDisposition, StageResult +from app.db.models import ChannelBinding, ChannelInboundEvent, new_id + +DISCORD_ENVELOPE_VERSION = 1 +MAX_ENVELOPE_BYTES = 256 * 1024 + + +def discord_account_key(bot_id: str) -> str: + bot_id = bot_id.strip() + return f"discord:bot:{len(bot_id)}:{bot_id}" + + +def encode_replay_envelope(inbound: ChannelInbound, *, bot_id: str) -> dict[str, Any]: + return { + "schema_version": DISCORD_ENVELOPE_VERSION, + "account": {"bot_id": bot_id}, + "inbound": asdict(inbound), + } + + +def decode_replay_envelope(payload: object) -> ChannelInbound: + if not isinstance(payload, dict) or payload.get("schema_version") != DISCORD_ENVELOPE_VERSION: + raise ValueError("unsupported_envelope_version") + normalized = payload.get("inbound") + if not isinstance(normalized, dict): + raise ValueError("invalid_envelope_inbound") + allowed = set(ChannelInbound.__dataclass_fields__) + if not set(normalized) <= allowed: + raise ValueError("invalid_envelope_fields") + inbound = ChannelInbound(**normalized) + if inbound.channel != "discord": + raise ValueError("invalid_envelope_channel") + return inbound + + +def stage_discord_inbound( + *, + db_engine, + binding_id: str, + expected_revision: int, + bot_id: str, + inbound: ChannelInbound, +) -> StageResult: + bot_id = bot_id.strip() + if inbound.channel != "discord" or not inbound.event_id or not bot_id: + return StageResult(StageDisposition.SECURITY_DROP, error_code="invalid_event_identity") + envelope = encode_replay_envelope(inbound, bot_id=bot_id) + try: + if len(json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode()) > MAX_ENVELOPE_BYTES: + return StageResult(StageDisposition.SECURITY_DROP, error_code="event_payload_too_large") + with Session(db_engine) as db: + binding = db.get(ChannelBinding, binding_id) + expected_account = discord_account_key(bot_id) + if ( + not binding + or binding.channel != "discord" + or binding.status != "active" + or binding.config_revision != expected_revision + or binding.external_account_key != expected_account + or str((binding.config_json or {}).get("bot_id") or "").strip() != bot_id + ): + return StageResult(StageDisposition.SECURITY_DROP, error_code="binding_fence_mismatch") + # Discord 没有企业租户维度,identity_scope 为空,无需修补。 + raw = inbound.raw if isinstance(inbound.raw, dict) else {} + target = { + # 兼容现有 intake/outbox 的通用目标校验;channel_id 用于 REST 出站定位。 + "to_user_id": inbound.conv_key if inbound.is_group else inbound.from_user_id, + "channel_id": str(raw.get("channel_id") or "").strip(), + "guild_id": str(raw.get("guild_id") or "").strip(), + "message_id": inbound.event_id, + } + event = ChannelInboundEvent( + id=new_id("chevt"), tenant_id=binding.tenant_id, binding_id=binding.id, + channel="discord", event_id=inbound.event_id, payload_json=envelope, + config_revision=expected_revision, target_json=target, status="received", + ) + db.add(event) + try: + db.commit() + except IntegrityError: + db.rollback() + existing = db.exec(select(ChannelInboundEvent).where( + ChannelInboundEvent.binding_id == binding_id, + ChannelInboundEvent.event_id == inbound.event_id, + )).first() + if existing: + return StageResult(StageDisposition.DUPLICATE, event_pk=existing.id) + return StageResult(StageDisposition.NACK, error_code="inbox_integrity_error") + return StageResult(StageDisposition.STAGED, event_pk=event.id) + except SQLAlchemyError: + return StageResult(StageDisposition.NACK, error_code="inbox_database_error") From 019633b83349529cf1bd589d99ea88569a964389 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:20 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat(channels):=20=E6=B3=A8=E5=86=8C=20di?= =?UTF-8?q?scord=20=E6=B8=A0=E9=81=93=E5=B9=B6=E6=8E=A5=E5=85=A5=E5=85=A5?= =?UTF-8?q?=E7=AB=99=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUPPORTED_CHANNELS/CHANNEL_META 增加 discord 条目 - channels/__init__.py 注册 DiscordStreamManager 单例与启停 - service_identity 增加 discord 标签与账号稳定键 - service_intake 将 discord 纳入 durable 渠道与 replay 解码 --- backend/app/api/channels.py | 11 ++++++++++- backend/app/channels/__init__.py | 21 ++++++++++++++++++++ backend/app/channels/service_identity.py | 5 ++++- backend/app/channels/service_intake.py | 25 ++++++++++++++++++------ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index ea8a25ed5..c35b015b4 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -120,7 +120,7 @@ def _patch_binding_config_key( if result.rowcount != 1: raise HTTPException(status_code=404, detail="渠道绑定不存在") -SUPPORTED_CHANNELS = {"wechat", "wecom", "feishu", "dingtalk"} +SUPPORTED_CHANNELS = {"wechat", "wecom", "feishu", "dingtalk", "discord"} INGRESS_QUIESCE_TIMEOUT_SECONDS = 5.0 # 渠道描述:前端接入页据此渲染渠道卡片与凭证表单,新渠道只加条目不动页面骨架 @@ -169,6 +169,15 @@ def _patch_binding_config_key( ], "capabilities": [], }, + { + "channel": "discord", + "name": "Discord", + "setup": "credentials", + "credential_fields": [ + {"key": "bot_token", "label": "Bot Token", "placeholder": "Discord Developer Portal 获取", "secret": True}, + ], + "capabilities": [], + }, ] diff --git a/backend/app/channels/__init__.py b/backend/app/channels/__init__.py index ee2da2560..74e2c0b8d 100644 --- a/backend/app/channels/__init__.py +++ b/backend/app/channels/__init__.py @@ -17,6 +17,7 @@ _wecom_stream_manager = None _feishu_process_manager = None _dingtalk_stream_manager = None +_discord_stream_manager = None _binding_lifecycle_locks: dict[str, threading.RLock] = {} _binding_lifecycle_locks_guard = threading.Lock() _connector_lock_file: IO[bytes] | None = None @@ -126,6 +127,15 @@ def get_dingtalk_stream_manager(): return _dingtalk_stream_manager +def get_discord_stream_manager(): + global _discord_stream_manager + if _discord_stream_manager is None: + from app.channels.adapters.discord import DiscordStreamManager + + _discord_stream_manager = DiscordStreamManager() + return _discord_stream_manager + + def channel_services_enabled() -> bool: # staffdeck_role 预留角色拆分:all=单体全量,connector=仅渠道连接器 return get_settings().staffdeck_role in {"all", "connector"} @@ -135,6 +145,7 @@ def _ensure_adapters_registered() -> None: # 各适配器模块导入即自注册(模块级 register_channel_adapter) import app.channels.adapters.feishu # noqa: F401 import app.channels.adapters.dingtalk # noqa: F401 + import app.channels.adapters.discord # noqa: F401 import app.channels.adapters.wechat # noqa: F401 import app.channels.adapters.wecom # noqa: F401 @@ -167,6 +178,8 @@ def _ingress_manager(channel: str): return get_feishu_process_manager() if channel == "dingtalk": return get_dingtalk_stream_manager() + if channel == "discord": + return get_discord_stream_manager() return None @@ -213,6 +226,8 @@ def wait_binding_ingress_stopped(channel: str, binding_id: str, timeout_seconds: return get_feishu_process_manager().wait_binding_stopped(binding_id, timeout_seconds) if channel == "dingtalk": return get_dingtalk_stream_manager().wait_binding_stopped(binding_id, timeout_seconds) + if channel == "discord": + return get_discord_stream_manager().wait_binding_stopped(binding_id, timeout_seconds) return True @@ -243,6 +258,7 @@ def start_channel_services() -> None: get_wecom_stream_manager().start() get_feishu_process_manager().start() get_dingtalk_stream_manager().start() + get_discord_stream_manager().start() start_delivery_daemon() start_staged_inbound_daemon() # 启动恢复:一次性清扫崩溃残留的 processing 入站事件(独立线程,不阻塞启动) @@ -285,6 +301,10 @@ def stop_channel_services(timeout_seconds: float = 5.0) -> bool: dingtalk_stopped = dingtalk_manager is None or dingtalk_manager.stop( timeout_seconds=max(0.0, deadline - time.monotonic()) ) + discord_manager = _discord_stream_manager + discord_stopped = discord_manager is None or discord_manager.stop( + timeout_seconds=max(0.0, deadline - time.monotonic()) + ) sweep_thread = _intake_sweep_thread if sweep_thread and sweep_thread.is_alive(): sweep_thread.join(timeout=max(0.0, deadline - time.monotonic())) @@ -296,6 +316,7 @@ def stop_channel_services(timeout_seconds: float = 5.0) -> bool: and wecom_stopped and feishu_stopped and dingtalk_stopped + and discord_stopped and sweep_stopped ) if stopped: diff --git a/backend/app/channels/service_identity.py b/backend/app/channels/service_identity.py index 53b703fae..6608240ee 100644 --- a/backend/app/channels/service_identity.py +++ b/backend/app/channels/service_identity.py @@ -24,7 +24,7 @@ _USERNAME_UNSAFE = re.compile(r"[^a-zA-Z0-9_.@-]") # 渠道显示名前缀(用户回复与懒建账号 display_name 共用) -_CHANNEL_LABELS = {"wechat": "微信", "wecom": "企业微信", "feishu": "飞书", "dingtalk": "钉钉"} +_CHANNEL_LABELS = {"wechat": "微信", "wecom": "企业微信", "feishu": "飞书", "dingtalk": "钉钉", "discord": "Discord"} class IdentityScopeConflict(RuntimeError): @@ -69,6 +69,9 @@ def external_account_key(channel: str, config: dict) -> str | None: if channel == "dingtalk": client_id = str(config.get("client_id") or "").strip() return f"dingtalk:app:{len(client_id)}:{client_id}" if client_id else None + if channel == "discord": + bot_id = str(config.get("bot_id") or "").strip() + return f"discord:bot:{len(bot_id)}:{bot_id}" if bot_id else None return None diff --git a/backend/app/channels/service_intake.py b/backend/app/channels/service_intake.py index f18a5d464..a6080e5c6 100644 --- a/backend/app/channels/service_intake.py +++ b/backend/app/channels/service_intake.py @@ -172,7 +172,7 @@ def claim_staged_inbound(event_id: str, *, db_engine=None) -> bool: update(ChannelInboundEvent) .where( ChannelInboundEvent.id == event_id, - ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk"}), + ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk", "discord"}), ChannelInboundEvent.status == "received", ) .values( @@ -452,6 +452,8 @@ def _stage_notice( def _valid_notice_target(channel: str, target: dict) -> bool: if channel == "feishu": return bool(target.get("message_id") or target.get("receive_id")) + if channel == "discord": + return bool(target.get("channel_id")) return bool(target.get("to_user_id") and target.get("context_token")) @@ -1002,7 +1004,7 @@ def process_staged_inbound(event_pk: str, *, db_engine=None) -> bool: update(ChannelInboundEvent) .where( ChannelInboundEvent.id == event_pk, - ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk"}), + ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk", "discord"}), ChannelInboundEvent.status == "processing", ChannelInboundEvent.processor_run_id == current_processor_run_id(), ) @@ -1098,7 +1100,7 @@ def run_staged_inbound_daemon( event_ids = db.exec( select(ChannelInboundEvent.id) .where( - ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk"}), + ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk", "discord"}), ChannelInboundEvent.status == "received", ) .order_by(ChannelInboundEvent.created_at) @@ -1196,6 +1198,17 @@ def _decode_and_validate_staged_event( ): raise ValueError("replay_account_mismatch") return inbound + if event.channel == "discord": + from app.channels.service_discord_inbox import ( + decode_replay_envelope, + discord_account_key, + ) + + inbound = decode_replay_envelope(payload) + bot_id = str((account or {}).get("bot_id") or "").strip() + if not bot_id or binding.external_account_key != discord_account_key(bot_id): + raise ValueError("replay_account_mismatch") + return inbound raise ValueError("unsupported_envelope_channel") @@ -1245,7 +1258,7 @@ def _recover_stale_durable_event(event_pk: str, *, db_engine=None) -> bool: update(ChannelInboundEvent) .where( ChannelInboundEvent.id == event_pk, - ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk"}), + ChannelInboundEvent.channel.in_({"feishu", "wecom", "dingtalk", "discord"}), ChannelInboundEvent.status == "processing", or_( ChannelInboundEvent.processor_run_id.is_(None), @@ -1287,11 +1300,11 @@ def sweep_stale_inbound_events(*, db_engine=None) -> int: binding = db.get(ChannelBinding, binding_id) if not binding: continue - if channel not in {"feishu", "wecom", "dingtalk"} and binding.status != "active": + if channel not in {"feishu", "wecom", "dingtalk", "discord"} and binding.status != "active": continue db.expunge(binding) try: - if channel in {"feishu", "wecom", "dingtalk"}: + if channel in {"feishu", "wecom", "dingtalk", "discord"}: if _recover_stale_durable_event(event_pk, db_engine=use_engine): taken += 1 continue From 5dbd1998a10f173d55f5122d57d89d602393104a Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:24 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=20discord?= =?UTF-8?q?=20=E5=87=AD=E8=AF=81=E4=BF=9D=E5=AD=98=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/channels.py | 81 +++++++++++++ backend/app/channels/schema.py | 5 + backend/tests/test_discord_api.py | 185 ++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 backend/tests/test_discord_api.py diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index c35b015b4..80a551dec 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -23,6 +23,10 @@ DingTalkPermanentError, validate_dingtalk_credentials, ) +from app.channels.adapters.discord import ( + DiscordPermanentError, + validate_discord_credentials, +) from app.channels.adapters.feishu import ( FeishuPermanentError, validate_feishu_credentials, @@ -46,6 +50,7 @@ ChannelQRCodeRead, ChannelQRCodeStatusRead, DingTalkCredentialsRequest, + DiscordCredentialsRequest, FeishuCredentialsRequest, MyIdentityBindingRead, WeComCredentialsRequest, @@ -1094,6 +1099,82 @@ def save_dingtalk_credentials( return channel_binding_read(db, binding) +@router.post("/{binding_id}/discord/credentials", response_model=ChannelBindingRead) +def save_discord_credentials( + binding_id: str, + request: DiscordCredentialsRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> ChannelBindingRead: + """Validate and save Discord Bot Token, then start its connector.""" + ensure_current_user_tenant(request.tenant_id, current_user) + binding = _get_binding(db, request.tenant_id, binding_id) + _ensure_binding_manager(db, request.tenant_id, binding, current_user) + if binding.channel != "discord": + raise HTTPException(status_code=400, detail="该绑定不是 Discord 渠道") + bot_token = request.bot_token.strip() + if not bot_token: + raise HTTPException(status_code=400, detail="Bot Token 不能为空") + old_bot_id = str((binding.config_json or {}).get("bot_id") or "").strip() + try: + bot_info = validate_discord_credentials(bot_token) + except DiscordPermanentError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.warning("验证 Discord 凭证失败 binding=%s", binding_id, exc_info=True) + raise HTTPException(status_code=502, detail="Discord 凭证验证暂时失败,请稍后重试") from exc + bot_id = str(bot_info.get("bot_id") or "").strip() + bot_name = str(bot_info.get("bot_name") or "").strip() + if not bot_id: + raise HTTPException(status_code=400, detail="Bot 信息无效") + if old_bot_id and old_bot_id != bot_id: + raise HTTPException(status_code=400, detail="应用变更不允许直接修改,请删除后重新创建绑定") + account_key = external_account_key("discord", {"bot_id": bot_id}) + if not account_key: + raise HTTPException(status_code=400, detail="Bot 信息无效") + _ensure_external_account_available(db, account_key, binding_id) + db.rollback() + with binding_lifecycle_lock(binding_id): + binding = _get_binding(db, request.tenant_id, binding_id) + expected_revision = binding.config_revision + should_run = bool(binding.status == "active" and binding.credentials_enc) + current_bot_id = str((binding.config_json or {}).get("bot_id") or "").strip() + if current_bot_id and current_bot_id != bot_id: + raise HTTPException(status_code=409, detail="渠道配置已被其他请求修改,请重试") + db.rollback() + _quiesce_binding_or_409(binding.channel, binding_id, should_run=should_run) + try: + binding = _get_binding(db, request.tenant_id, binding_id) + _ensure_revision(binding, expected_revision) + latest_bot_id = str((binding.config_json or {}).get("bot_id") or "").strip() + if latest_bot_id and latest_bot_id != bot_id: + raise HTTPException(status_code=409, detail="渠道配置已被其他请求修改,请重试") + _ensure_external_account_available(db, account_key, binding_id) + config = dict(binding.config_json or {}) + config.update({"bot_id": bot_id, "bot_name": bot_name, "bound_at": utc_now().isoformat()}) + binding.credentials_enc = encrypt_channel_secret(bot_token) + binding.config_json = config + binding.external_account_key = account_key + binding.config_revision += 1 + binding.status = "active" + binding.connected = False + binding.updated_at = utc_now() + db.add(binding) + adopt_orphan_channel_sessions(db, binding) + db.commit() + db.refresh(binding) + except IntegrityError as exc: + db.rollback() + _resume_binding(binding.channel, binding_id, start=should_run) + raise HTTPException(status_code=409, detail="该 Discord 机器人已被其他渠道绑定使用") from exc + except Exception: + db.rollback() + _resume_binding(binding.channel, binding_id, start=should_run) + raise + _resume_binding(binding.channel, binding_id, start=True) + return channel_binding_read(db, binding) + + @router.get("/delivery-audit", response_model=ChannelDeliveryPage) def list_tenant_delivery_audit( tenant_id: str = Query(...), diff --git a/backend/app/channels/schema.py b/backend/app/channels/schema.py index 6a7cddaa8..ae9101d5c 100644 --- a/backend/app/channels/schema.py +++ b/backend/app/channels/schema.py @@ -105,6 +105,11 @@ class DingTalkCredentialsRequest(BaseModel): client_secret: str +class DiscordCredentialsRequest(BaseModel): + tenant_id: str + bot_token: str + + class ChannelCredentialFieldRead(BaseModel): key: str label: str diff --git a/backend/tests/test_discord_api.py b/backend/tests/test_discord_api.py new file mode 100644 index 000000000..3e6d9b874 --- /dev/null +++ b/backend/tests/test_discord_api.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +import app.api.channels as channels_api +from app.channels.adapters.discord import DiscordPermanentError +from app.channels.crypto import decrypt_channel_secret +from app.db import get_session +from app.db.models import AgentProfile, ChannelBinding, Tenant, User +from app.security.auth import create_access_token + + +def _engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _client(engine) -> TestClient: + app = FastAPI() + app.include_router(channels_api.router) + + def override_session(): + with Session(engine) as db: + yield db + + app.dependency_overrides[get_session] = override_session + return TestClient(app) + + +def _seed(engine) -> User: + with Session(engine) as db: + db.add(Tenant(id="tenant_a", name="A")) + owner = User( + id="user_owner", + tenant_id="tenant_a", + username="owner", + password_hash="x", + ) + db.add(owner) + db.add( + AgentProfile( + id="agent_a", + tenant_id="tenant_a", + name="Agent A", + metadata_json={"owner_user_id": owner.id}, + ) + ) + db.commit() + db.refresh(owner) + db.expunge(owner) + return owner + + +def _auth(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(user)}"} + + +def _create_binding(client: TestClient, owner: User) -> str: + created = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_a", "agent_id": "agent_a", "channel": "discord"}, + headers=_auth(owner), + ) + assert created.status_code == 200 + return created.json()["id"] + + +def test_discord_binding_credentials_activate_without_exposing_secret(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + client = _client(engine) + monkeypatch.setattr(channels_api, "channel_services_enabled", lambda: False) + monkeypatch.setattr( + channels_api, + "validate_discord_credentials", + lambda token: {"bot_id": "bot-123", "bot_name": "StaffDeck Bot"}, + ) + + binding_id = _create_binding(client, owner) + response = client.post( + f"/api/enterprise/channels/{binding_id}/discord/credentials", + json={"tenant_id": "tenant_a", "bot_token": "secret-token"}, + headers=_auth(owner), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "active" + assert payload["bot_id"] == "bot-123" + assert payload["bot_name"] == "StaffDeck Bot" + assert "secret-token" not in response.text + with Session(engine) as db: + binding = db.get(ChannelBinding, binding_id) + assert decrypt_channel_secret(binding.credentials_enc) == "secret-token" + assert binding.external_account_key == "discord:bot:7:bot-123" + assert binding.config_revision == 1 + + +def test_discord_empty_token_rejected(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + client = _client(engine) + monkeypatch.setattr(channels_api, "channel_services_enabled", lambda: False) + binding_id = _create_binding(client, owner) + response = client.post( + f"/api/enterprise/channels/{binding_id}/discord/credentials", + json={"tenant_id": "tenant_a", "bot_token": " "}, + headers=_auth(owner), + ) + assert response.status_code == 400 + assert "Bot Token" in response.json()["detail"] + + +def test_discord_permanent_validation_error_is_400(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + client = _client(engine) + monkeypatch.setattr(channels_api, "channel_services_enabled", lambda: False) + monkeypatch.setattr( + channels_api, + "validate_discord_credentials", + lambda token: (_ for _ in ()).throw(DiscordPermanentError("无效或已被吊销")), + ) + binding_id = _create_binding(client, owner) + response = client.post( + f"/api/enterprise/channels/{binding_id}/discord/credentials", + json={"tenant_id": "tenant_a", "bot_token": "bad-token"}, + headers=_auth(owner), + ) + assert response.status_code == 400 + assert "无效" in response.json()["detail"] + + +def test_discord_bot_id_is_immutable_after_activation(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + with Session(engine) as db: + binding = ChannelBinding( + id="chan_discord", + tenant_id="tenant_a", + agent_id="agent_a", + channel="discord", + status="active", + config_json={"bot_id": "bot-old"}, + created_by_user_id=owner.id, + ) + db.add(binding) + db.commit() + client = _client(engine) + called = False + + def validate(_token): + nonlocal called + called = True + return {"bot_id": "bot-new", "bot_name": "Bot"} + + monkeypatch.setattr(channels_api, "validate_discord_credentials", validate) + response = client.post( + "/api/enterprise/channels/chan_discord/discord/credentials", + json={"tenant_id": "tenant_a", "bot_token": "secret"}, + headers=_auth(owner), + ) + assert response.status_code == 400 + assert called is True + + +def test_channel_meta_exposes_discord_secret_field() -> None: + engine = _engine() + owner = _seed(engine) + response = _client(engine).get( + "/api/enterprise/channels/meta?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + discord = next(row for row in response.json() if row["channel"] == "discord") + fields = {field["key"]: field for field in discord["credential_fields"]} + assert fields["bot_token"]["secret"] is True From f865e19c2abb1b1f56f8bb031e19b00bcf33ae3a Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:28 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20discord?= =?UTF-8?q?=20=E6=B8=A0=E9=81=93=E9=85=8D=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/ChannelsPage.tsx | 9 ++ .../src/pages/channelPresentation.ts | 7 + .../src/pages/channels/DiscordSetup.test.tsx | 127 ++++++++++++++++++ .../src/pages/channels/DiscordSetup.tsx | 83 ++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 frontend-enterprise/src/pages/channels/DiscordSetup.test.tsx create mode 100644 frontend-enterprise/src/pages/channels/DiscordSetup.tsx diff --git a/frontend-enterprise/src/pages/ChannelsPage.tsx b/frontend-enterprise/src/pages/ChannelsPage.tsx index 13249d1c4..2f643343c 100644 --- a/frontend-enterprise/src/pages/ChannelsPage.tsx +++ b/frontend-enterprise/src/pages/ChannelsPage.tsx @@ -45,6 +45,7 @@ import WechatSetup from './channels/WechatSetup'; import WecomSetup from './channels/WecomSetup'; import FeishuSetup from './channels/FeishuSetup'; import DingTalkSetup from './channels/DingTalkSetup'; +import DiscordSetup from './channels/DiscordSetup'; import { getChannelPresentation } from './channelPresentation'; import { StatusBadge } from './scheduled-tasks/StatusBadge'; import { formatTime, type BadgeTone } from './scheduled-tasks/shared'; @@ -751,6 +752,14 @@ export default function ChannelsPage({ setBindings((current) => current.map((item) => (item.id === updated.id ? updated : item))) } /> + ) : binding.channel === 'discord' ? ( + + setBindings((current) => current.map((item) => (item.id === updated.id ? updated : item))) + } + /> ) : setupKindFor(binding.channel) === 'credentials' ? ( = { blurb: '填入钉钉 Stream 应用凭证,通过长连接接入数字员工。', disconnectDescription: '断开后钉钉接入将停止服务,需要重新配置应用凭证才能恢复;对话记录保留。确定断开接入吗?', }, + discord: { + name: 'Discord', + identifierLabel: 'Bot ID', + userLabel: 'Discord 用户', + blurb: '填入 Discord Bot Token,通过 Gateway 长连接接入数字员工。', + disconnectDescription: '断开后 Discord 接入将停止服务,需要重新配置 Bot Token 才能恢复;对话记录保留。确定断开接入吗?', + }, }; export function getChannelPresentation(channel: string, configuredName?: string): ChannelPresentation { diff --git a/frontend-enterprise/src/pages/channels/DiscordSetup.test.tsx b/frontend-enterprise/src/pages/channels/DiscordSetup.test.tsx new file mode 100644 index 000000000..5bcdfc165 --- /dev/null +++ b/frontend-enterprise/src/pages/channels/DiscordSetup.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ChannelBindingRead } from '../../types'; +import DiscordSetup from './DiscordSetup'; + +const { notify } = vi.hoisted(() => ({ notify: { success: vi.fn(), error: vi.fn() } })); + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: (value: string) => value, + locale: 'zh-CN', + setLocale: () => {}, + toggleLocale: () => {}, + }), + I18nProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('@/components/ui/app-toast', () => ({ notify })); + +const updatedBinding: ChannelBindingRead = { + id: 'chan_discord', + tenant_id: 'tenant_demo', + agent_id: 'agent_a', + channel: 'discord', + status: 'active', + bot_id: 'bot-123', + bot_name: 'StaffDeck Bot', + config_revision: 1, + connected: false, + agents: [], + created_at: '2026-08-07T00:00:00Z', + updated_at: '2026-08-07T00:00:00Z', +}; + +const baseBinding = { + id: 'chan_discord', + tenant_id: 'tenant_demo', + agent_id: 'agent_a', + channel: 'discord', + status: 'active', + config_revision: 0, + connected: false, + agents: [], + created_at: '2026-08-07T00:00:00Z', + updated_at: '2026-08-07T00:00:00Z', +}; + +const binding = (overrides: Partial = {}): ChannelBindingRead => ({ + ...baseBinding, + ...overrides, +}); + +const postMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../api/client', () => ({ + api: { + post: (...args: unknown[]) => postMock(...args), + }, + TENANT_ID: 'tenant_demo', +})); + +function renderSetup(b: ChannelBindingRead, onChanged: () => void = () => {}) { + return render(); +} + +beforeEach(() => { + postMock.mockReset(); + notify.success.mockReset(); + notify.error.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('DiscordSetup', () => { + it('renders configured state with bot id and without exposing the token', () => { + renderSetup(binding({ bot_id: 'bot-123'})); + + expect(screen.getByText('凭证已配置')).toBeTruthy(); + expect(screen.getByText(/Bot ID:bot-123/)).toBeTruthy(); + expect(screen.getByText('未连接')).toBeTruthy(); + expect(screen.queryByText('Bot Token')).toBeNull(); + }); + + it('posts the bot token to the discord credentials endpoint on save', async () => { + postMock.mockResolvedValue(updatedBinding); + const onChanged = vi.fn(); + + renderSetup(binding(), onChanged); + + fireEvent.change(screen.getByLabelText('Bot Token'), { target: { value: 'token-abc' } }); + fireEvent.click(screen.getByText('保存')); + + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord/discord/credentials', + { tenant_id: 'tenant_demo', bot_token: 'token-abc' }, + ); + }); + await waitFor(() => expect(notify.success).toHaveBeenCalledWith('已保存')); + expect(onChanged).toHaveBeenCalledWith(updatedBinding); + }); + + it('stays in edit state and reports the error when saving fails', async () => { + postMock.mockRejectedValue(new Error('无效或已被吊销')); + + renderSetup(binding()); + + fireEvent.change(screen.getByLabelText('Bot Token'), { target: { value: 'bad-token' } }); + fireEvent.click(screen.getByText('保存')); + + await waitFor(() => expect(notify.error).toHaveBeenCalledWith('无效或已被吊销')); + expect(screen.getByLabelText('Bot Token')).toBeTruthy(); + }); + + it('rejects saving when the token is empty', () => { + renderSetup(binding()); + + fireEvent.click(screen.getByText('保存')); + + expect(notify.error).toHaveBeenCalledWith('请填写完整凭证'); + expect(postMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-enterprise/src/pages/channels/DiscordSetup.tsx b/frontend-enterprise/src/pages/channels/DiscordSetup.tsx new file mode 100644 index 000000000..6bbe7c869 --- /dev/null +++ b/frontend-enterprise/src/pages/channels/DiscordSetup.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { notify } from '@/components/ui/app-toast'; + +import { Input } from '@/components/ui'; +import { Button as UIButton } from '@/components/ui/button'; +import { api, TENANT_ID } from '../../api/client'; +import type { ChannelBindingRead } from '../../types'; +import { StatusBadge } from '../scheduled-tasks/StatusBadge'; + +const PRIMARY_BUTTON_CLASS = + 'h-8 gap-1 rounded-[10px] bg-[#18181a] px-5 text-[12px] font-normal text-white hover:bg-[#303030]'; +const OUTLINE_BUTTON_CLASS = + 'h-8 gap-1 rounded-[10px] border-[#e3e7f1] px-5 text-[12px] font-normal text-[#464c5e] hover:bg-[#f6f6f6] hover:text-[#18181a]'; + +export default function DiscordSetup({ + binding, + onChanged, +}: { + binding: ChannelBindingRead; + onChanged: (updated: ChannelBindingRead) => void; +}) { + const configuredBotId = binding.bot_id || String(binding.config_json?.bot_id || ''); + const [editing, setEditing] = useState(!configuredBotId); + const [botToken, setBotToken] = useState(''); + const [saving, setSaving] = useState(false); + + async function save() { + if (!botToken.trim()) { + notify.error('请填写完整凭证'); + return; + } + setSaving(true); + try { + const updated = await api.post( + `/api/enterprise/channels/${binding.id}/discord/credentials`, + { tenant_id: TENANT_ID, bot_token: botToken.trim() }, + ); + setBotToken(''); + setEditing(false); + onChanged(updated); + notify.success('已保存'); + } catch (error) { + notify.error(error instanceof Error ? error.message : '保存凭证失败'); + } finally { + setSaving(false); + } + } + + if (configuredBotId && !editing) { + return ( +
+ 凭证已配置 + Bot ID:{configuredBotId} + + {binding.connected ? '已连接' : '未连接'} + + { setBotToken(''); setEditing(true); }} + className={OUTLINE_BUTTON_CLASS} + > + 轮换 Token + +
+ ); + } + + return ( +
+ + 凭证获取路径:Discord Developer Portal → Applications → Bot → Token。需开启 Message Content Intent,否则群聊中未提及机器人的消息将无法读取。 + + +
+ {configuredBotId && setEditing(false)} className={OUTLINE_BUTTON_CLASS}>取消} + void save()} disabled={saving} className={PRIMARY_BUTTON_CLASS}>保存 +
+
+ ); +} From 8cf472d3a396b75fdb6cb5fa7c00c55e04fca954 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:31 +0900 Subject: [PATCH 07/27] =?UTF-8?q?fix(channels):=20=E4=BF=AE=E6=AD=A3=20dis?= =?UTF-8?q?cord=20stream=20manager=20=E7=9A=84=20engine=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/adapters/discord.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py index f57ba5548..622b85753 100644 --- a/backend/app/channels/adapters/discord.py +++ b/backend/app/channels/adapters/discord.py @@ -230,7 +230,7 @@ def __init__( db_engine=None, client_factory: Callable[..., Any] | None = None, ) -> None: - from app.db.engine import engine + from app.db import engine self._engine = db_engine or engine # client_factory(bot_token, on_message) -> 已挂载回调的 discord.Client 实例 From adc4cd205bbd798b027798a4ec530c6796dab28e Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:35 +0900 Subject: [PATCH 08/27] =?UTF-8?q?fix(channels):=20=E4=BF=AE=E5=A4=8D=20dis?= =?UTF-8?q?cord=20=E7=BD=91=E5=85=B3=E5=81=9C=E6=AD=A2=E4=B8=8E=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=8A=B6=E6=80=81=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/adapters/discord.py | 50 +++++++----- backend/tests/test_channel_discord.py | 98 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 19 deletions(-) diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py index 622b85753..c6438d05e 100644 --- a/backend/app/channels/adapters/discord.py +++ b/backend/app/channels/adapters/discord.py @@ -112,17 +112,6 @@ def _credential(binding: ChannelBinding) -> tuple[str, str]: return bot_id, token -def _classify_http_error(exc: Exception, *, permanent_codes: frozenset[int]) -> DiscordSendError: - if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)): - return DiscordTransientError(str(exc)) - if isinstance(exc, httpx.HTTPStatusError): - status = exc.response.status_code - if status in permanent_codes: - return DiscordPermanentError(f"Discord 接口拒绝请求 (HTTP {status})") - return DiscordTransientError(f"Discord 接口暂不可用 (HTTP {status})") - return DiscordTransientError(str(exc)) - - def validate_discord_credentials(bot_token: str, *, client_factory: Callable[[], httpx.Client] | None = None) -> dict[str, str] | None: """调用 Discord 官方接口校验 Bot Token,返回 {bot_id, bot_name}。 @@ -323,9 +312,28 @@ async def _run_gateway(self, factory, token: str, handler, stop: threading.Event async def mark_connected(connected: bool) -> None: await asyncio.to_thread(self._set_connected, binding_id, handler.expected_revision, connected) - try: + register_event = getattr(client, "event", None) + if callable(register_event): + async def _on_ready() -> None: + await mark_connected(True) + + register_event(_on_ready) + else: await mark_connected(True) - await client.start(token) + + try: + start_task = asyncio.create_task(client.start(token)) + stop_task = asyncio.create_task(asyncio.to_thread(stop.wait)) + done, _ = await asyncio.wait( + {start_task, stop_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if stop.is_set(): + try: + await client.close() + except Exception: + logger.exception("关闭 discord 客户端失败 binding=%s", binding_id) + await start_task except Exception: logger.exception("discord 网关异常退出 binding=%s", binding_id) finally: @@ -405,12 +413,16 @@ def _reconcile_loop(self) -> None: logger.exception("discord reconcile 循环异常") def start(self) -> None: - self._reconcile_thread = threading.Thread( - target=self._reconcile_loop, - name="staffdeck-discord-reconcile", - daemon=True, - ) - self._reconcile_thread.start() + with self._lock: + if self._reconcile_thread is not None and self._reconcile_thread.is_alive(): + return + self._reconcile_stop.clear() + self._reconcile_thread = threading.Thread( + target=self._reconcile_loop, + name="staffdeck-discord-reconcile", + daemon=True, + ) + self._reconcile_thread.start() def stop(self, *, timeout_seconds: float = 5.0) -> bool: self._reconcile_stop.set() diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py index b953e0d5b..7ab3c12f8 100644 --- a/backend/tests/test_channel_discord.py +++ b/backend/tests/test_channel_discord.py @@ -1,5 +1,9 @@ from __future__ import annotations +import asyncio +import threading +import time + import pytest from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine, select @@ -8,6 +12,7 @@ DISCORD_API_BASE, DiscordAdapter, DiscordPermanentError, + DiscordStreamManager, DiscordTransientError, normalize_discord_message, validate_discord_credentials, @@ -317,3 +322,96 @@ def test_stage_discord_inbound_fence_rejections(): bot_id="bot-1", inbound=inbound, ) assert wrong_revision.disposition is StageDisposition.SECURITY_DROP + + +class _FakeDiscordClient: + """最小 discord.Client 替身:start() 阻塞直到 close(),支持挂载 on_ready。""" + + def __init__(self): + self._closed = threading.Event() + self._handlers = {} + self.on_ready_fired = threading.Event() + + def event(self, coro): + self._handlers[coro.__name__.lstrip("_")] = coro + return coro + + async def start(self, token: str) -> None: + if "on_ready" in self._handlers: + await self._handlers["on_ready"]() + self.on_ready_fired.set() + await asyncio.to_thread(self._closed.wait) + + async def close(self) -> None: + self._closed.set() + + +def _stream_manager(db_engine): + clients = [] + + def factory(token, on_message): + client = _FakeDiscordClient() + clients.append(client) + return client + + manager = DiscordStreamManager(db_engine=db_engine, client_factory=factory) + manager._test_clients = clients + return manager + + +def _wait_until(predicate, timeout_seconds: float = 3.0) -> bool: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return predicate() + + +def test_discord_stream_manager_stop_terminates_gateway_thread(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1")) + db.commit() + + manager = _stream_manager(db_engine) + manager.ensure_binding("chan-1") + assert _wait_until(lambda: bool(manager._test_clients)) + assert manager._threads["chan-1"].is_alive() + + manager.stop_binding("chan-1") + assert manager.wait_binding_stopped("chan-1", timeout_seconds=3.0) + assert manager._test_clients[0]._closed.is_set() + assert "chan-1" not in manager._threads + + +def test_discord_stream_manager_connected_only_after_ready(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1")) + db.commit() + + manager = _stream_manager(db_engine) + manager.ensure_binding("chan-1") + assert _wait_until(lambda: bool(manager._test_clients)) + assert _wait_until(lambda: manager._test_clients[0].on_ready_fired.is_set()) + with Session(db_engine) as db: + binding = db.get(ChannelBinding, "chan-1") + assert binding.connected is True + + manager.stop_binding("chan-1") + assert manager.wait_binding_stopped("chan-1", timeout_seconds=3.0) + with Session(db_engine) as db: + binding = db.get(ChannelBinding, "chan-1") + assert binding.connected is False + + +def test_discord_stream_manager_start_is_idempotent(): + manager = _stream_manager(_engine()) + manager.start() + first = manager._reconcile_thread + manager.start() + assert manager._reconcile_thread is first + manager.stop(timeout_seconds=2.0) From e6a471ca3312666eb8bd4c94363d967e3622746d Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:39 +0900 Subject: [PATCH 09/27] =?UTF-8?q?fix(channels):=20=E4=BF=AE=E5=A4=8D=20dis?= =?UTF-8?q?cord=20wait=5Fbinding=5Fstopped=20=E7=AD=BE=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E8=B0=83=E7=94=A8=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/adapters/discord.py | 2 +- backend/tests/test_channel_discord.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py index c6438d05e..f29aaeb65 100644 --- a/backend/app/channels/adapters/discord.py +++ b/backend/app/channels/adapters/discord.py @@ -378,7 +378,7 @@ def resume_binding(self, binding_id: str, *, start: bool = True) -> None: if start: self.ensure_binding(binding_id) - def wait_binding_stopped(self, binding_id: str, *, timeout_seconds: float = 5.0) -> bool: + def wait_binding_stopped(self, binding_id: str, timeout_seconds: float = 5.0) -> bool: with self._lock: thread = self._threads.get(binding_id) if thread is None: diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py index 7ab3c12f8..306d4850a 100644 --- a/backend/tests/test_channel_discord.py +++ b/backend/tests/test_channel_discord.py @@ -415,3 +415,11 @@ def test_discord_stream_manager_start_is_idempotent(): manager.start() assert manager._reconcile_thread is first manager.stop(timeout_seconds=2.0) + + +def test_discord_wait_binding_stopped_accepts_positional_timeout(): + """__init__.py wait_binding_ingress_stopped 以位置参数调用 wait_binding_stopped(binding_id, timeout_seconds)。""" + manager = _stream_manager(_engine()) + # 位置参数形式(与 channels/__init__.py:230 一致)不应抛 TypeError + result = manager.wait_binding_stopped("chan-none", 0.1) + assert result is True From d080e084d92b19d1e13e994288148069c2d59f2b Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:43 +0900 Subject: [PATCH 10/27] =?UTF-8?q?test(channels):=20=E6=B7=BB=E5=8A=A0=20hu?= =?UTF-8?q?b=20=E5=B1=82=20discord=20=E7=AD=89=E5=BE=85=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_channel_discord.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py index 306d4850a..a6942a98c 100644 --- a/backend/tests/test_channel_discord.py +++ b/backend/tests/test_channel_discord.py @@ -423,3 +423,11 @@ def test_discord_wait_binding_stopped_accepts_positional_timeout(): # 位置参数形式(与 channels/__init__.py:230 一致)不应抛 TypeError result = manager.wait_binding_stopped("chan-none", 0.1) assert result is True + + +def test_discord_hub_wait_binding_ingress_stopped_discord_branch(): + """hub 层 wait_binding_ingress_stopped("discord", ...) 以位置参数走 discord 分支(__init__.py:230)。""" + from app.channels import wait_binding_ingress_stopped + + result = wait_binding_ingress_stopped("discord", "chan-none", 0.1) + assert result is True From 8641ac23ecdb208c36c34d9dafdb80454803cdf0 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:46 +0900 Subject: [PATCH 11/27] =?UTF-8?q?fix(channels):=20=E4=BF=AE=E5=A4=8D=20dis?= =?UTF-8?q?cord=20=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86=E5=99=A8=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=90=8D=E4=B8=8E=20gateway=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/adapters/discord.py | 5 ++++- backend/tests/test_channel_discord.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py index f29aaeb65..fd525cb68 100644 --- a/backend/app/channels/adapters/discord.py +++ b/backend/app/channels/adapters/discord.py @@ -298,10 +298,12 @@ def _default_client_factory(self, token: str, on_message): intents.message_content = True client = discord.Client(intents=intents) - @client.event async def _on_message(message) -> None: await on_message(message) + _on_message.__name__ = "on_message" + client.event(_on_message) + return client async def _run_gateway(self, factory, token: str, handler, stop: threading.Event, binding_id: str) -> None: @@ -317,6 +319,7 @@ async def mark_connected(connected: bool) -> None: async def _on_ready() -> None: await mark_connected(True) + _on_ready.__name__ = "on_ready" register_event(_on_ready) else: await mark_connected(True) diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py index a6942a98c..4e12c13ee 100644 --- a/backend/tests/test_channel_discord.py +++ b/backend/tests/test_channel_discord.py @@ -333,7 +333,8 @@ def __init__(self): self.on_ready_fired = threading.Event() def event(self, coro): - self._handlers[coro.__name__.lstrip("_")] = coro + # 与真实 discord.py Client.event() 语义一致:setattr(self, coro.__name__, coro) + self._handlers[coro.__name__] = coro return coro async def start(self, token: str) -> None: From e9ba5c70009f9ab936d3be09de6a197372a9605f Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:50 +0900 Subject: [PATCH 12/27] =?UTF-8?q?fix(channels):=20=E4=BF=AE=E5=A4=8D=20dis?= =?UTF-8?q?cord=20=E5=9B=9E=E5=A4=8D=E6=8A=95=E9=80=92=20target=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E8=AF=AF=E5=88=A4=20delivery=5Ftarget=5Fmiss?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/service_outbox.py | 2 ++ backend/tests/test_channel_outbox.py | 38 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/backend/app/channels/service_outbox.py b/backend/app/channels/service_outbox.py index d6e5ef01d..bf2351e5d 100644 --- a/backend/app/channels/service_outbox.py +++ b/backend/app/channels/service_outbox.py @@ -193,6 +193,8 @@ def stage_channel_delivery(db: Session, chat_session: ChatSession, message: Mess return if binding.channel == "feishu": valid_target = bool(target.get("message_id") or target.get("receive_id")) + elif binding.channel == "discord": + valid_target = bool(target.get("channel_id")) else: valid_target = bool(target.get("to_user_id") and target.get("context_token")) if not valid_target: diff --git a/backend/tests/test_channel_outbox.py b/backend/tests/test_channel_outbox.py index 33fa5a2be..18a470a70 100644 --- a/backend/tests/test_channel_outbox.py +++ b/backend/tests/test_channel_outbox.py @@ -1478,3 +1478,41 @@ def test_notify_identity_fallback_skips_when_scope_missing() -> None: notify_binding_creator(db, db.get(ChannelBinding, binding_b.id), "测试告警") assert db.exec(select(ChannelDelivery)).all() == [] + + +def test_discord_session_stages_delivery_without_context_token() -> None: + """Discord target 无 context_token(仅 channel_id),stage 不得报 delivery_target_missing。""" + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + chat_session = ChatSession( + id="session_discord", + tenant_id=binding.tenant_id, + user_id="user_1", + agent_id=binding.agent_id, + channel="discord", + external_conv_id="discord_p2p_1503739991854026902", + channel_target_json={ + "to_user_id": "1503739991854026902", + "channel_id": "1503739992722378835", + "guild_id": "1503739991854026902", + "message_id": "1535173171014275072", + }, + channel_binding_id=binding.id, + channel_account_key=binding.external_account_key, + ) + message = _assistant_message(chat_session.id, "msg_discord", "你好,我是 StaffDeck") + db.add(chat_session) + db.add(message) + db.commit() + + stage_channel_delivery(db, chat_session, message) + db.commit() + + deliveries = db.exec(select(ChannelDelivery)).all() + assert len(deliveries) == 1 + delivery = deliveries[0] + assert delivery.status == "pending" + assert delivery.kind == "reply" + assert delivery.last_error is None + assert delivery.target_json["channel_id"] == "1503739992722378835" From 1d749520b52961ff8bbb2efbbfae663dde772571 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:53 +0900 Subject: [PATCH 13/27] =?UTF-8?q?test(channels):=20=E6=B7=BB=E5=8A=A0=20di?= =?UTF-8?q?scord=20=E6=8A=95=E9=80=92=E9=93=BE=E8=B7=AF=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_channel_outbox.py | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/backend/tests/test_channel_outbox.py b/backend/tests/test_channel_outbox.py index 18a470a70..a8c8255ac 100644 --- a/backend/tests/test_channel_outbox.py +++ b/backend/tests/test_channel_outbox.py @@ -1516,3 +1516,83 @@ def test_discord_session_stages_delivery_without_context_token() -> None: assert delivery.kind == "reply" assert delivery.last_error is None assert delivery.target_json["channel_id"] == "1503739992722378835" + + +def test_discord_daemon_delivers_via_real_adapter_send() -> None: + """discord binding → delivery daemon → 真实 DiscordAdapter.send 集成路径。 + + 回归防线:修复前 stage 误判 delivery_target_missing,本条验证完整投递链路 + (真实 adapter 走 discord REST POST,而非 FakeAdapter)。 + """ + import httpx + + from app.channels.adapters.discord import DISCORD_MESSAGE_API, DiscordAdapter + from app.channels.service_discord_inbox import discord_account_key + + engine = _test_engine() + + class _RecordingClient(httpx.Client): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.posted: list[tuple[str, dict, dict]] = [] + + def post(self, url: str, *, json=None, headers=None, **kwargs): # noqa: D102 + self.posted.append((url, json or {}, dict(headers or {}))) + return _FakeResponse(200, {"id": "msg-ok"}) + + class _FakeResponse: + def __init__(self, status_code: int, payload: dict) -> None: + self.status_code = status_code + self._payload = payload + + def json(self) -> dict: + return self._payload + + client = _RecordingClient() + adapter = DiscordAdapter(client_factory=lambda: client) + register_channel_adapter("discord", adapter) + + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + binding.credentials_enc = encrypt_channel_secret("secret-token") + binding.config_json = {"bot_id": "bot-1", "bot_name": "StaffDeck Bot"} + binding.external_account_key = discord_account_key("bot-1") + chat_session = ChatSession( + id="session_discord_int", + tenant_id=binding.tenant_id, + user_id="user_1", + agent_id=binding.agent_id, + channel="discord", + external_conv_id="discord_p2p_u1", + channel_target_json={ + "to_user_id": "1503739991854026902", + "channel_id": "1503739992722378835", + "guild_id": "1503739991854026902", + "message_id": "1535173171014275072", + }, + channel_binding_id=binding.id, + channel_account_key=binding.external_account_key, + ) + message = _assistant_message(chat_session.id, "msg_discord_int", "你好,我是 StaffDeck") + db.add(chat_session) + db.add(message) + db.commit() + + stage_channel_delivery(db, chat_session, message) + db.commit() + delivery_id = db.exec(select(ChannelDelivery)).one().id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + delivery = db.get(ChannelDelivery, delivery_id) + assert delivery.status == "delivered" + assert delivery.attempts == 1 + assert delivery.last_error is None + + expected_url = DISCORD_MESSAGE_API.format(channel_id="1503739992722378835") + assert len(client.posted) == 1 + url, body, headers = client.posted[0] + assert url == expected_url + assert body["content"] == "你好,我是 StaffDeck" + assert headers["Authorization"] == "Bot secret-token" From 15d4d9cdac2d8f331c0aee3d1ea0783852ee575e Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:27:57 +0900 Subject: [PATCH 14/27] =?UTF-8?q?fix(channels):=20discord=20=E6=8A=95?= =?UTF-8?q?=E9=80=92=20nonce=20=E5=B9=82=E7=AD=89=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E4=B8=8E=E5=88=9B=E5=BB=BA=E8=80=85=E5=91=8A=E8=AD=A6=20target?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/channels/adapters/discord.py | 13 +++++- backend/app/channels/service_outbox.py | 6 +++ backend/tests/test_channel_discord.py | 55 +++++++++++++++++++++++- backend/tests/test_channel_outbox.py | 38 ++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py index fd525cb68..80be78319 100644 --- a/backend/app/channels/adapters/discord.py +++ b/backend/app/channels/adapters/discord.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import logging import re import threading @@ -172,10 +173,18 @@ def send( } try: with self._client_factory() as client: - for chunk in split_channel_text(text, CHANNEL_TEXT_LIMIT): + for index, chunk in enumerate(split_channel_text(text, CHANNEL_TEXT_LIMIT)): + payload: dict[str, Any] = {"content": chunk} + if idempotency_key: + # Discord nonce 限 25 字符;按 idempotency_key+分片序号稳定派生, + # 重试时同一分片得到相同 nonce,避免分片中断后重发产生重复消息 + digest = hashlib.sha256( + f"{idempotency_key}:{index}".encode("utf-8") + ).hexdigest() + payload["nonce"] = digest[:24] response = client.post( DISCORD_MESSAGE_API.format(channel_id=channel_id), - json={"content": chunk}, + json=payload, headers=headers, ) if response.status_code in (401, 403): diff --git a/backend/app/channels/service_outbox.py b/backend/app/channels/service_outbox.py index bf2351e5d..de4a37c7e 100644 --- a/backend/app/channels/service_outbox.py +++ b/backend/app/channels/service_outbox.py @@ -788,6 +788,12 @@ def notify_binding_creator(db: Session, binding: ChannelBinding, text: str) -> N if chat_session and (chat_session.channel_target_json or {}).get("to_user_id"): target = dict(chat_session.channel_target_json) session_id = chat_session.id + elif binding.channel == "discord": + # discord 无 context_token 体系,fallback 缺 channel_id 必然永久失败,跳过 + logger.info( + "渠道告警跳过:discord 创建者无可用会话目标 binding=%s", binding.id + ) + return else: target = {"to_user_id": identity.external_user_id, "context_token": ""} session_id = f"alert:{identity.id}" diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py index 4e12c13ee..90b23a60d 100644 --- a/backend/tests/test_channel_discord.py +++ b/backend/tests/test_channel_discord.py @@ -175,7 +175,8 @@ def post(self, url, json=None, headers=None, **_kwargs): call = client.calls[0] assert call["url"] == f"{DISCORD_API_BASE}/channels/channel-1/messages" assert call["headers"]["Authorization"] == "Bot secret" - assert call["body"] == {"content": "hello"} + assert call["body"]["content"] == "hello" + assert call["body"].get("nonce") # idempotency_key 映射为 nonce def test_discord_send_splits_long_text(): @@ -236,6 +237,58 @@ def test_discord_send_rejects_missing_channel(): adapter.send(_binding(), {}, "hello") +def test_discord_send_maps_idempotency_key_to_nonce(): + """idempotency_key 应映射为 Discord nonce(≤25 字符、分片间稳定可复现),防止分片重试重复消息。""" + + class Client: + def __init__(self): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, json=None, headers=None, **_kwargs): + self.calls.append(json) + return _Response(200) + + client = Client() + adapter = DiscordAdapter(client_factory=lambda: client) + adapter.send( + _binding(), + {"channel_id": "channel-1"}, + "hello", + idempotency_key="chdeliv_testkey1234567890", + ) + assert len(client.calls) == 1 + nonce = client.calls[0].get("nonce") + assert nonce, "send 应把 idempotency_key 映射为 nonce" + assert len(nonce) <= 25 + # 同一 idempotency_key 重复调用应产生相同的 nonce(重试可复现) + client2 = Client() + adapter2 = DiscordAdapter(client_factory=lambda: client2) + adapter2.send( + _binding(), + {"channel_id": "channel-1"}, + "hello", + idempotency_key="chdeliv_testkey1234567890", + ) + assert client2.calls[0].get("nonce") == nonce + # 分片文本:每片 nonce 不同但可复现 + client3 = Client() + adapter3 = DiscordAdapter(client_factory=lambda: client3) + adapter3.send( + _binding(), + {"channel_id": "channel-1"}, + "x" * 2500, + idempotency_key="chdeliv_testkey1234567890", + ) + assert len(client3.calls) == 2 + assert client3.calls[0]["nonce"] != client3.calls[1]["nonce"] + + def test_validate_discord_credentials_ok(): client = _RoutingClient( {"users/@me": [_Response(200, {"id": "bot-1", "username": "MyBot"})]} diff --git a/backend/tests/test_channel_outbox.py b/backend/tests/test_channel_outbox.py index a8c8255ac..5d263061b 100644 --- a/backend/tests/test_channel_outbox.py +++ b/backend/tests/test_channel_outbox.py @@ -1294,6 +1294,44 @@ def test_notify_uses_identity_basics_without_session() -> None: assert alerts[0].session_id.startswith("alert:") +def test_notify_skips_discord_creator_without_session() -> None: + """discord 无会话时创建者告警应跳过:缺 channel_id 的 fallback target 必然永久失败。""" + from app.channels.service_outbox import notify_binding_creator + + engine = _test_engine() + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add(User(id="user_dc", tenant_id="tenant_demo", username="creator", password_hash="x")) + binding = ChannelBinding( + id="chan_dc", + tenant_id="tenant_demo", + agent_id="agent_1", + channel="discord", + status="active", + connected=True, + credentials_enc=encrypt_channel_secret("tok"), + config_json={"bot_id": "bot-1"}, + created_by_user_id="user_dc", + ) + db.add(binding) + db.flush() + db.add( + ChannelIdentity( + tenant_id="tenant_demo", + channel="discord", + external_account_scope="", + external_user_id="discord_user_1", + staffdeck_user_id="user_dc", + display_name="创建者", + ) + ) + db.commit() + + notify_binding_creator(db, db.get(ChannelBinding, "chan_dc"), "测试告警") + # 无会话 → 不构造必败 delivery(缺 channel_id) + assert db.exec(select(ChannelDelivery)).all() == [] + + # ---------- sending 重置陈旧阈值 ---------- From 5872d99fc43d0e7491ae59b76fd14cad6d9542c6 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Fri, 14 Aug 2026 17:28:01 +0900 Subject: [PATCH 15/27] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0=20uv.lo?= =?UTF-8?q?ck=20=E9=94=81=E5=AE=9A=20discord.py=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/uv.lock | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/backend/uv.lock b/backend/uv.lock index fed03e889..f9863cd2d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [[package]] name = "aiohappyeyeballs" @@ -210,6 +214,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -497,6 +557,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, ] +[[package]] +name = "discord-py" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/dd/5817c7af5e614e45cdf38cbf6c3f4597590c442822a648121a34dee7fa0f/discord_py-2.5.2.tar.gz", hash = "sha256:01cd362023bfea1a4a1d43f5280b5ef00cad2c7eba80098909f98bf28e578524", size = 1054879, upload-time = "2025-03-05T01:15:29.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/a8/dc908a0fe4cd7e3950c9fa6906f7bf2e5d92d36b432f84897185e1b77138/discord_py-2.5.2-py3-none-any.whl", hash = "sha256:81f23a17c50509ffebe0668441cb80c139e74da5115305f70e27ce821361295a", size = 1155105, upload-time = "2025-03-05T01:15:27.323Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -1954,6 +2027,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "cryptography" }, { name = "dingtalk-stream" }, + { name = "discord-py" }, { name = "fastapi" }, { name = "greenlet" }, { name = "httpx" }, @@ -1994,6 +2068,7 @@ requires-dist = [ { name = "certifi", marker = "extra == 'packaging'", specifier = ">=2024.2.2" }, { name = "cryptography", specifier = ">=42.0.0,<49.0.0" }, { name = "dingtalk-stream", specifier = ">=0.24.3,<0.25.0" }, + { name = "discord-py", specifier = ">=2.3.0,<2.6.0" }, { name = "fastapi", specifier = ">=0.111.0" }, { name = "greenlet", specifier = ">=3.0.3" }, { name = "httpx", specifier = ">=0.27.0" }, From 174c0f7af150862af1f9d5513899a430caf4e511 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Sat, 15 Aug 2026 23:58:29 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat(channels):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E8=83=BD=E5=8A=9B=E5=8D=8F=E8=AE=AE=E4=B8=8E?= =?UTF-8?q?=E6=8A=95=E9=80=92=E6=A8=A1=E5=9E=8B=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Discord 渠道 8 项功能扩展奠定共享地基: - ChannelCapability 枚举 + ChannelCapabilityAdapter 可选协议, 存量渠道自动降级为空能力集 - ChannelDelivery 新增 payload_json/thread_id/batch_id/delivery_kind 字段 - ChannelInboundEvent 新增 thread_id/mention_user_ids/command 字段(信封 v2) - SQLite 就地迁移 _migrate_channel_envelope_v2_schema, 幂等补列 - channel_capabilities_of 双防御(callable+isinstance) 保证向后兼容 本地验证: pytest 9 个新测试通过, ruff 通过 --- backend/app/channels/adapters/base.py | 89 +++++++++++ backend/app/db/database.py | 44 ++++++ backend/app/db/models.py | 14 ++ backend/tests/test_channel_capabilities.py | 162 +++++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 backend/tests/test_channel_capabilities.py diff --git a/backend/app/channels/adapters/base.py b/backend/app/channels/adapters/base.py index f190f4071..ac64ee158 100644 --- a/backend/app/channels/adapters/base.py +++ b/backend/app/channels/adapters/base.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import StrEnum from typing import Any, Protocol import httpx @@ -154,6 +155,28 @@ def remove_reaction( ) -> None: ... +class ChannelCapability(StrEnum): + """渠道能力枚举:8 项 Discord 功能扩展的运行时能力声明。""" + + SLASH_COMMANDS = "slash_commands" # 原生斜杠命令 + THREADS = "threads" # 线程 + BATCH_SEND = "batch_send" # 批量投递 + BACKFILL = "backfill" # 历史回填 + TYPING = "typing" # typing indicator + VOICE = "voice" # 语音 + RICH_MEDIA = "rich_media" # embeds/附件 + + +class ChannelCapabilityAdapter(Protocol): + """可选混入:声明该渠道支持的能力集合。 + + 与 ChannelReactionAdapter 一样是可选协议;未实现此协议的渠道 + 自动视为无任何扩展能力,所有门禁降级跳过。 + """ + + def channel_capabilities(self) -> set[ChannelCapability]: ... + + _adapters: dict[str, ChannelAdapter] = {} @@ -184,6 +207,72 @@ def channel_reaction_token(channel: str) -> str | None: return token or None +def channel_capabilities_of(adapter: object) -> set[ChannelCapability]: + """安全获取适配器的能力声明集合。 + + 实现了 ChannelCapabilityAdapter 协议(有可调用的 channel_capabilities + 方法)时返回其声明集合,否则返回空集。存量渠道(微信/企微/飞书/钉钉) + 未实现该协议,自动降级为无能力,保证向后兼容。 + """ + capabilities = getattr(adapter, "channel_capabilities", None) + if not callable(capabilities): + return set() + declared = capabilities() + if not isinstance(declared, set): + return set() + return set(declared) + + +def evaluate_allowlist(message_ctx: dict[str, Any], allowlist: dict[str, Any] | None) -> bool: + """入站白名单判定:True 放行,False 拒绝(功能5)。 + + allowlist schema(§3.1/§4.5): + {mode: "allow_all"|"deny_all"(缺省 allow_all), + guild_ids/channel_ids/role_ids/user_ids: [str], + deny: [str]}(条目支持 "dimension:id" 前缀,纯 id 匹配任意维度) + 判定序:deny 命中→拒绝;deny_all 且 allow 未命中→拒绝;allow_all 且 allow + 非空未命中→拒绝;其余放行。role_ids 需要 members intent,首版(P2)不参与。 + """ + if not isinstance(allowlist, dict) or not allowlist: + return True + mode = str(allowlist.get("mode") or "").strip() or "allow_all" + if mode not in ("allow_all", "deny_all"): + mode = "allow_all" + guild_id = str(message_ctx.get("guild_id") or "").strip() + channel_id = str(message_ctx.get("channel_id") or "").strip() + author_id = str(message_ctx.get("author_id") or "").strip() + for entry in allowlist.get("deny") or []: + token = str(entry).strip() + if not token: + continue + if ":" in token: + dimension, _, value = token.partition(":") + value = value.strip() + if not value or dimension not in {"guild", "channel", "user"}: + continue + if dimension == "guild" and value == guild_id: + return False + if dimension == "channel" and value == channel_id: + return False + if dimension == "user" and value == author_id: + return False + elif token in (guild_id, channel_id, author_id): + return False + allow_guilds = _allowlist_ids(allowlist, "guild_ids") + allow_channels = _allowlist_ids(allowlist, "channel_ids") + allow_users = _allowlist_ids(allowlist, "user_ids") + allow_hit = guild_id in allow_guilds or channel_id in allow_channels or author_id in allow_users + if mode == "deny_all": + return allow_hit + if not allow_guilds and not allow_channels and not allow_users: + return True + return allow_hit + + +def _allowlist_ids(allowlist: dict[str, Any], key: str) -> set[str]: + return {str(value).strip() for value in (allowlist.get(key) or []) if str(value).strip()} + + def split_channel_text(text: str, limit: int = CHANNEL_TEXT_LIMIT) -> list[str]: """按渠道 2000 字上限拆分长文本,优先 \n\n / \n / 空格边界,找不到则硬切。""" if not text: diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 3dfbb768c..564d66d17 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -49,6 +49,7 @@ def _normalize_database_url(url: str) -> str: _CHANNEL_BINDINGS_MULTI_MIGRATION_ID = "20260721_channel_bindings_multi" _CHANNEL_ACCOUNT_KEY_MIGRATION_ID = "20260723_channel_account_key_v1" _FEISHU_CHANNEL_SCHEMA_MIGRATION_ID = "20260724_feishu_channel_schema_v1" +_CHANNEL_ENVELOPE_V2_MIGRATION_ID = "20260815_channel_envelope_v2" _CAPABILITY_SCOPE_TABLES = ( "general_skills", "tools", @@ -99,6 +100,7 @@ def _migrate_sqlite_skill_schema() -> None: _migrate_channel_bind_code_constraints(conn, tables) _migrate_capability_scope_schema(conn, inspector, tables) _migrate_harness_v2_schema(conn, inspector, tables) + _migrate_channel_envelope_v2_schema(conn, tables) if "users" in tables: user_columns = {column["name"] for column in inspector.get_columns("users")} @@ -1076,6 +1078,48 @@ def _migrate_feishu_channel_schema(conn, tables: set[str]) -> None: ) +def _migrate_channel_envelope_v2_schema(conn, tables: set[str]) -> None: + """Add the v2 envelope columns for threads / slash commands / batch / voice / rich media. + + Column presence is authoritative so an interrupted or manually modified + database is repaired on the next startup (idempotent ALTER TABLE ADD COLUMN). + """ + required_tables = {"channel_inbound_events", "channel_deliveries"} + if not required_tables <= tables: + return + + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_data_migrations ( + id VARCHAR PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + inbound_columns = { + str(row[1]) for row in conn.execute(text("PRAGMA table_info(channel_inbound_events)")) + } + for column in ("thread_id", "mention_user_ids", "command"): + if column not in inbound_columns: + conn.execute( + text(f"ALTER TABLE channel_inbound_events ADD COLUMN {column} VARCHAR") + ) + delivery_columns = { + str(row[1]) for row in conn.execute(text("PRAGMA table_info(channel_deliveries)")) + } + for column in ("payload_json", "thread_id", "batch_id", "delivery_kind"): + if column not in delivery_columns: + conn.execute( + text(f"ALTER TABLE channel_deliveries ADD COLUMN {column} VARCHAR") + ) + conn.execute( + text("INSERT OR IGNORE INTO app_data_migrations (id) VALUES (:id)"), + {"id": _CHANNEL_ENVELOPE_V2_MIGRATION_ID}, + ) + + def _migrate_channel_bind_code_constraints(conn, tables: set[str]) -> None: if "channel_bind_codes" not in tables: return diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 321bc0751..8c507b9d4 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -905,6 +905,12 @@ class ChannelInboundEvent(SQLModel, table=True): # 收到确认标记的句柄;最终回复送达后据此异步撤回。飞书存远端 reaction_id; # 钉钉 emotion 接口不返回 ID,存本地哨兵值表示"已挂上待撤回"。 reaction_id: Optional[str] = Field(default=None, index=True) + # Discord 线程 ID:线程内消息的会话锚点(信封 v2,功能2用) + thread_id: Optional[str] = None + # 群聊中被 @ 的用户 ID 列表(JSON 数组字符串,信封 v2,功能1用) + mention_user_ids: Optional[str] = None + # 原生斜杠命令名(信封 v2,功能1用);文本指令走 content,不填此字段 + command: Optional[str] = None # received/processing/done/failed status: str = Field(default="received", index=True) # 创建/接管该事件的进程启动代次;当前代次仍在运行时禁止按墙钟误接管。 @@ -928,6 +934,14 @@ class ChannelDelivery(SQLModel, table=True): # reply/error_notice kind: str = Field(default="reply", index=True) text: str + # 富媒体结构化载荷(embeds/files)的 JSON 字符串,功能8用 + payload_json: Optional[str] = None + # Discord 线程/子频道 ID,功能2用 + thread_id: Optional[str] = None + # 批处理作业 ID,功能3用 + batch_id: Optional[str] = None + # 投递类别:不填视为 "text";"voice" 表示语音投递,功能7用 + delivery_kind: Optional[str] = None # pending/sending/delivered/failed status: str = Field(default="pending", index=True) attempts: int = 0 diff --git a/backend/tests/test_channel_capabilities.py b/backend/tests/test_channel_capabilities.py new file mode 100644 index 000000000..800730849 --- /dev/null +++ b/backend/tests/test_channel_capabilities.py @@ -0,0 +1,162 @@ +"""渠道能力声明协议与投递/入站数据模型地基测试。""" + +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from app.channels.adapters.base import ( + ChannelCapability, + channel_capabilities_of, +) +from app.db.models import ChannelDelivery, ChannelInboundEvent + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +class _CapabilityAdapter: + """实现了 ChannelCapabilityAdapter 协议的假适配器。""" + + def channel_capabilities(self) -> set[ChannelCapability]: + return {ChannelCapability.SLASH_COMMANDS, ChannelCapability.THREADS} + + +class _PlainAdapter: + """只有 send 的存量适配器,未实现能力声明协议。""" + + def send(self, *args, **kwargs) -> None: + return None + + +class _PropertyAdapter: + """channel_capabilities 是属性而非方法的错误实现,应安全降级。""" + + @property + def channel_capabilities(self) -> set[ChannelCapability]: # type: ignore[return] + return {ChannelCapability.VOICE} + + +def test_channel_capabilities_of_declared_adapter() -> None: + adapter = _CapabilityAdapter() + assert channel_capabilities_of(adapter) == { + ChannelCapability.SLASH_COMMANDS, + ChannelCapability.THREADS, + } + + +def test_channel_capabilities_of_plain_adapter_returns_empty() -> None: + """核心向后兼容断言:未实现协议的存量适配器自动降级为空集。""" + assert channel_capabilities_of(_PlainAdapter()) == set() + + +def test_channel_capabilities_of_property_adapter_returns_empty() -> None: + assert channel_capabilities_of(_PropertyAdapter()) == set() + + +def test_channel_capabilities_of_none_returns_empty() -> None: + assert channel_capabilities_of(None) == set() + + +def test_channel_capability_values() -> None: + assert ChannelCapability.SLASH_COMMANDS == "slash_commands" + assert ChannelCapability.THREADS == "threads" + assert ChannelCapability.BATCH_SEND == "batch_send" + assert ChannelCapability.BACKFILL == "backfill" + assert ChannelCapability.TYPING == "typing" + assert ChannelCapability.VOICE == "voice" + assert ChannelCapability.RICH_MEDIA == "rich_media" + + +def test_channel_delivery_new_fields_roundtrip() -> None: + engine = _test_engine() + delivery = ChannelDelivery( + tenant_id="tenant_demo", + binding_id="binding_1", + session_id="session_1", + text="hello", + payload_json='{"embeds": [{"title": "t"}]}', + thread_id="thread_123", + batch_id="batch_42", + delivery_kind="voice", + idempotency_key="idem_1", + ) + with Session(engine) as db: + db.add(delivery) + db.commit() + with Session(engine) as db: + stored = db.exec(select(ChannelDelivery)).first() + assert stored is not None + assert stored.payload_json == '{"embeds": [{"title": "t"}]}' + assert stored.thread_id == "thread_123" + assert stored.batch_id == "batch_42" + assert stored.delivery_kind == "voice" + + +def test_channel_delivery_new_fields_default_null() -> None: + """新字段默认 None,存量行为(纯文本投递)不受影响。""" + engine = _test_engine() + delivery = ChannelDelivery( + tenant_id="tenant_demo", + binding_id="binding_1", + session_id="session_1", + text="plain", + idempotency_key="idem_2", + ) + with Session(engine) as db: + db.add(delivery) + db.commit() + with Session(engine) as db: + stored = db.exec(select(ChannelDelivery)).first() + assert stored is not None + assert stored.payload_json is None + assert stored.thread_id is None + assert stored.batch_id is None + assert stored.delivery_kind is None + + +def test_channel_inbound_event_new_fields_roundtrip() -> None: + engine = _test_engine() + event = ChannelInboundEvent( + tenant_id="tenant_demo", + binding_id="binding_1", + channel="discord", + event_id="evt_1", + thread_id="thread_123", + mention_user_ids='["user_a", "user_b"]', + command="/employee", + ) + with Session(engine) as db: + db.add(event) + db.commit() + with Session(engine) as db: + stored = db.exec(select(ChannelInboundEvent)).first() + assert stored is not None + assert stored.thread_id == "thread_123" + assert stored.mention_user_ids == '["user_a", "user_b"]' + assert stored.command == "/employee" + + +def test_channel_inbound_event_new_fields_default_null() -> None: + """文本指令入站事件不填 command,存量字段不受影响。""" + engine = _test_engine() + event = ChannelInboundEvent( + tenant_id="tenant_demo", + binding_id="binding_1", + channel="wechat", + event_id="evt_2", + ) + with Session(engine) as db: + db.add(event) + db.commit() + with Session(engine) as db: + stored = db.exec(select(ChannelInboundEvent)).first() + assert stored is not None + assert stored.thread_id is None + assert stored.mention_user_ids is None + assert stored.command is None From fc4d583053f2d889d4a7b5edbf7cd307d15f30eb Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Sat, 15 Aug 2026 23:58:52 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat(channels):=20=E5=AE=9E=E7=8E=B0=20Di?= =?UTF-8?q?scord=20=E6=B8=A0=E9=81=93=208=20=E9=A1=B9=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 design-discord-channel-features.md 实现全部功能: - 原生斜杠命令: commands.Bot+CommandTree 注册 5 命令, 回调走 durable inbox 管道复用幂等/重试 - 线程: 入站识别 is_thread/thread_id, 出站 target.thread_id 优先, 白名单按父频道 - 批处理: TokenBucket 限流(容量5/每5s补1) + BatchJob 状态机 + 幂等键 batch:{job}:{index} - 回填: fetch_history REST 分页 + status=backfilled 不触发 agent + message_id 幂等 + web 合并可见 - 权限白名单: config_json.allowlist 六重 fence 校验, 拒绝落库 rejected 可审计 - typing: send_typing(每8s) + TypingManager 三进门禁(hasattr+能力声明+features 开关) - 语音: VOICE 默认关闭, ffmpeg 缺失自动不声明, outbox delivery_kind==voice 分派 - 富媒体: embeds(≤10裁剪)+files(≤8MiB multipart)+payload_json 全链路传递+入站附件提取 另修复真实链路缺陷: ChannelInbound dataclass 无 .get() 致回填必崩, 新增 _backfill_message_dict 归一化; features.slash_commands/typing 开关接线。 本地验证: pytest 相关集合 1511 passed(5 预存/flaky 与本次无关), ruff 通过 --- backend/app/api/channels.py | 285 ++++++- backend/app/channels/adapters/discord.py | 662 ++++++++++++++- backend/app/channels/batch_service.py | 448 ++++++++++ backend/app/channels/discord_runtime.py | 24 +- backend/app/channels/schema.py | 141 +++- backend/app/channels/service_discord_inbox.py | 65 +- backend/app/channels/service_intake.py | 4 + backend/app/channels/service_outbox.py | 34 +- backend/app/channels/service_routing.py | 11 +- backend/app/channels/typing_manager.py | 158 ++++ .../tests/test_channel_batch_backfill_api.py | 687 ++++++++++++++++ backend/tests/test_channel_batch_service.py | 414 ++++++++++ backend/tests/test_channel_outbox.py | 157 ++++ backend/tests/test_channel_routing.py | 12 + backend/tests/test_channel_typing_manager.py | 204 +++++ backend/tests/test_discord_features.py | 771 ++++++++++++++++++ 16 files changed, 4016 insertions(+), 61 deletions(-) create mode 100644 backend/app/channels/batch_service.py create mode 100644 backend/app/channels/typing_manager.py create mode 100644 backend/tests/test_channel_batch_backfill_api.py create mode 100644 backend/tests/test_channel_batch_service.py create mode 100644 backend/tests/test_channel_typing_manager.py create mode 100644 backend/tests/test_discord_features.py diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index 80a551dec..de7c46ec4 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -5,7 +5,8 @@ import secrets import threading import time -from datetime import timedelta +from datetime import datetime, timedelta, timezone +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy import case, text, update @@ -32,8 +33,14 @@ validate_feishu_credentials, ) from app.channels.adapters.wechat import WeChatClient, sanitize_wechat_baseurl, validate_wechat_host +from app.channels.batch_service import batch_service from app.channels.crypto import decrypt_channel_secret, encrypt_channel_secret from app.channels.schema import ( + BackfillJobRead, + BackfillRequest, + BatchJobRead, + BatchSendRequest, + BatchSubmitRead, ChannelBindCodeRead, ChannelBindingAgentRead, ChannelBindingAgentsUpdate, @@ -181,7 +188,15 @@ def _patch_binding_config_key( "credential_fields": [ {"key": "bot_token", "label": "Bot Token", "placeholder": "Discord Developer Portal 获取", "secret": True}, ], - "capabilities": [], + "capabilities": [ + "slash_commands", + "threads", + "batch_send", + "backfill", + "typing", + "voice", + "rich_media", + ], }, ] @@ -482,7 +497,12 @@ def update_channel_binding_agents( ensure_current_user_tenant(tenant_id, current_user) binding = _get_binding(db, tenant_id, binding_id) _ensure_binding_manager(db, tenant_id, binding, current_user) - if request.agents is None and request.auto_route is None: + if ( + request.agents is None + and request.auto_route is None + and request.allowlist is None + and request.features is None + ): raise HTTPException(status_code=400, detail="无有效更新内容") default_agent_id: str | None = None if request.agents is not None: @@ -531,6 +551,20 @@ def update_channel_binding_agents( request.auto_route, ) db.commit() + if request.allowlist is not None or request.features is not None: + # 配置结构化段(allowlist/features)变更 → config_revision+=1, + # _reconcile_loop 按 revision 条件 5s 内重建线程/刷新 fence 缓存 + binding = _get_binding(db, tenant_id, binding_id) + config = dict(binding.config_json or {}) + if request.allowlist is not None: + config["allowlist"] = request.allowlist.model_dump() + if request.features is not None: + config["features"] = request.features.model_dump() + binding.config_json = config + binding.config_revision += 1 + binding.updated_at = utc_now() + db.add(binding) + db.commit() binding = _get_binding(db, tenant_id, binding_id) db.refresh(binding) return channel_binding_read(db, binding) @@ -1175,6 +1209,149 @@ def save_discord_credentials( return channel_binding_read(db, binding) +def _batch_target_for(binding: ChannelBinding, channel_id: str, thread_id: str | None) -> dict[str, Any]: + """构造批量投递目标:channel_id 定位 REST 出站,thread_id 走线程(功能2)。""" + target: dict[str, Any] = { + "to_user_id": "", + "channel_id": channel_id, + "guild_id": str((binding.config_json or {}).get("guild_id") or ""), + } + if thread_id: + target["thread_id"] = thread_id + return target + + +def _binding_channel_id(binding: ChannelBinding, config_key: str) -> str: + """批量/回填默认目标频道:请求显式传入优先,否则回退 config_json 段。""" + config = binding.config_json or {} + section = config.get(config_key) + if isinstance(section, dict): + return str(section.get("channel_id") or "").strip() + return "" + + +@router.post("/{binding_id}/batch", response_model=BatchSubmitRead) +def submit_channel_batch( + binding_id: str, + request: BatchSendRequest, + tenant_id: str = Query(...), + channel_id: str | None = Query(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> BatchSubmitRead: + """提交批量投递作业(功能3):items 逐条限流整形发送,返回 job_id 供进度轮询。""" + ensure_current_user_tenant(tenant_id, current_user) + binding = _get_binding(db, tenant_id, binding_id) + _ensure_binding_manager(db, tenant_id, binding, current_user) + target_channel_id = (channel_id or "").strip() or _binding_channel_id(binding, "batch") + if not target_channel_id: + raise HTTPException(status_code=400, detail="缺少目标频道 ID(channel_id 或 config_json.batch.channel_id)") + target = _batch_target_for(binding, target_channel_id, request.thread_id) + job_id = batch_service.submit_batch( + binding, + tenant_id, + target, + [item if isinstance(item, str) else item.model_dump() for item in request.items], + thread_id=request.thread_id, + client_batch_id=request.client_batch_id, + ) + job = batch_service.get_batch(job_id) + return BatchSubmitRead(job_id=job_id, status=job.status if job else "pending") + + +@router.get("/{binding_id}/batch/{job_id}", response_model=BatchJobRead) +def get_channel_batch_job( + binding_id: str, + job_id: str, + tenant_id: str = Query(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> BatchJobRead: + """查询批量作业进度(功能3):status/progress/succeeded/failed/errors。""" + ensure_current_user_tenant(tenant_id, current_user) + binding = _get_binding(db, tenant_id, binding_id) + _ensure_binding_manager(db, tenant_id, binding, current_user) + job = batch_service.get_batch(job_id) + if job is None or job.binding_id != binding_id: + raise HTTPException(status_code=404, detail="批量作业不存在") + return BatchJobRead( + job_id=job.job_id, + status=job.status, + progress=job.progress, + total=len(job.items), + succeeded=job.succeeded, + failed=job.failed, + errors=list(job.errors), + ) + + +@router.post("/{binding_id}/backfill", response_model=BackfillJobRead) +def submit_channel_backfill( + binding_id: str, + request: BackfillRequest, + tenant_id: str = Query(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> BackfillJobRead: + """手动触发历史回填(功能4):拉取频道历史写入 backfilled 事件,不触发 agent。 + + 自动回填(绑定激活时按 config_json.features.backfill 触发)为后续演进, + 首版仅提供管理员手动触发端点。 + """ + ensure_current_user_tenant(tenant_id, current_user) + binding = _get_binding(db, tenant_id, binding_id) + _ensure_binding_manager(db, tenant_id, binding, current_user) + if binding.channel != "discord": + raise HTTPException(status_code=400, detail="该绑定不是 Discord 渠道") + target_channel_id = (request.channel_id or "").strip() or _binding_channel_id(binding, "backfill") + if not target_channel_id: + raise HTTPException(status_code=400, detail="缺少目标频道 ID(channel_id 或 config_json.backfill.channel_id)") + config = binding.config_json or {} + backfill_config = config.get("backfill") + limit = request.limit or ( + int(backfill_config.get("limit_per_fetch", 100)) if isinstance(backfill_config, dict) else 100 + ) + job_id = batch_service.submit_backfill( + binding, + channel_id=target_channel_id, + limit=min(limit, 100), + after=request.after, + before=request.before, + ) + job = batch_service.get_backfill(job_id) + return BackfillJobRead( + job_id=job_id, + status=job.status if job else "pending", + written=job.written if job else 0, + duplicates=job.duplicates if job else 0, + errors=list(job.errors) if job else [], + ) + + +@router.get("/{binding_id}/backfill/{job_id}", response_model=BackfillJobRead) +def get_channel_backfill_job( + binding_id: str, + job_id: str, + tenant_id: str = Query(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> BackfillJobRead: + """查询回填作业进度(功能4)。""" + ensure_current_user_tenant(tenant_id, current_user) + binding = _get_binding(db, tenant_id, binding_id) + _ensure_binding_manager(db, tenant_id, binding, current_user) + job = batch_service.get_backfill(job_id) + if job is None or job.binding_id != binding_id: + raise HTTPException(status_code=404, detail="回填作业不存在") + return BackfillJobRead( + job_id=job.job_id, + status=job.status, + written=job.written, + duplicates=job.duplicates, + errors=list(job.errors), + ) + + @router.get("/delivery-audit", response_model=ChannelDeliveryPage) def list_tenant_delivery_audit( tenant_id: str = Query(...), @@ -1410,13 +1587,13 @@ def list_channel_conversation_messages( session_ids = {row.id for row in _binding_channel_sessions(db, binding)} if session_id not in session_ids: raise HTTPException(status_code=404, detail="Channel conversation not found") + chat_session = db.get(ChatSession, session_id) rows = db.exec( select(Message) .where(Message.session_id == session_id) .order_by(Message.created_at) - .limit(200) ).all() - return [ + items = [ ChannelConversationMessageRead( id=row.id, role=row.role, @@ -1436,3 +1613,101 @@ def list_channel_conversation_messages( ) for row in rows ] + # §4.4 回填可见性:合并同频道的 backfilled 入站事件(历史消息不触发 agent, + # 不在 Message 表),按真实消息时间与对话消息统一排序。 + if chat_session is not None: + items.extend(_backfilled_conversation_messages(db, binding, chat_session)) + items.sort(key=lambda item: _message_sort_key(item.created_at)) + return items[:200] + + +def _backfill_channel_key(external_conv_id: str | None) -> str | None: + """从会话 external_conv_id 反推回填频道键。 + + Discord 群聊会话形如 `discord_group_{conv_key}`,conv_key 与回填事件 + target_json 的 guild_id/channel_id 对应;私聊与未知格式返回 None(不合并)。 + """ + marker = "_group_" + if not external_conv_id: + return None + index = external_conv_id.rfind(marker) + if index == -1: + return None + return external_conv_id[index + len(marker):] or None + + +def _backfill_event_matches_channel(event: ChannelInboundEvent, channel_key: str) -> bool: + target = event.target_json + if not isinstance(target, dict): + return False + return channel_key in { + str(target.get("channel_id") or ""), + str(target.get("guild_id") or ""), + } + + +def _message_sort_key(created_at: str) -> datetime: + """对话消息与回填消息的统一排序键;时间戳不可解析时兜底到最早。 + + Message.created_at(naive UTC)与回填事件里的 Discord ISO 时间戳(带 +00:00 + 偏移)混排,统一补 UTC 时区后再比较。 + """ + try: + parsed = datetime.fromisoformat(created_at) + except (TypeError, ValueError): + return datetime.min + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _backfilled_conversation_messages( + db: Session, + binding: ChannelBinding, + chat_session: ChatSession, +) -> list[ChannelConversationMessageRead]: + """会话同频道的 backfilled 事件 → web 消息视图(方案 A,§4.4)。""" + channel_key = _backfill_channel_key(chat_session.external_conv_id) + if channel_key is None: + return [] + events = db.exec( + select(ChannelInboundEvent).where( + ChannelInboundEvent.binding_id == binding.id, + ChannelInboundEvent.status == "backfilled", + ) + ).all() + bot_id = str((binding.config_json or {}).get("bot_id") or "") + result: list[ChannelConversationMessageRead] = [] + for event in events: + if not _backfill_event_matches_channel(event, channel_key): + continue + message = event.payload_json.get("message") or {} + if not isinstance(message, dict): + continue + content = str(message.get("content") or "").strip() + author_id = str(message.get("author_id") or "") + role = "assistant" if author_id and author_id == bot_id else "user" + created_at = str(message.get("created_at") or "").strip() + if not created_at: + created_at = event.created_at.isoformat() + result.append( + ChannelConversationMessageRead( + id=str(event.event_id or event.id), + role=role, + content=content, + created_at=created_at, + attachments=[ + ChannelConversationAttachmentRead( + id=item.get("id"), + filename=item.get("filename"), + content_type=item.get("content_type"), + size=item.get("size"), + kind=item.get("kind"), + ) + for item in (message.get("attachments") or []) + if isinstance(item, dict) + ] + or None, + ) + ) + return result diff --git a/backend/app/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py index 80be78319..959ac98fd 100644 --- a/backend/app/channels/adapters/discord.py +++ b/backend/app/channels/adapters/discord.py @@ -1,20 +1,27 @@ from __future__ import annotations +import base64 import hashlib +import json import logging +import os import re +import shutil import threading from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx from app.channels.adapters.base import ( CHANNEL_TEXT_LIMIT, ChannelAdapter, + ChannelCapability, ChannelInbound, + ChannelInboundAttachment, register_channel_adapter, split_channel_text, + stream_download_with_limit, ) from app.channels.crypto import decrypt_channel_secret @@ -30,6 +37,27 @@ # Discord 机器人 mention 语法: <@123456789012345678> 或带昵称 <@!123456789012345678> _DISCORD_MENTION_PATTERN = re.compile(r"^\s*<@!?\d+>\s*") +DISCORD_TYPING_API = f"{DISCORD_API_BASE}/channels/{{channel_id}}/typing" + +# 出站富媒体限制(与 Discord v10 一致):embeds≤10,各字段长度裁剪而非拒绝。 +_MAX_EMBEDS = 10 +_MAX_EMBED_TITLE = 256 +_MAX_EMBED_DESCRIPTION = 4096 +_MAX_EMBED_FIELDS = 25 +_MAX_EMBED_FIELD_NAME = 256 +_MAX_EMBED_FIELD_VALUE = 1024 +_MAX_EMBED_FOOTER = 2048 +# Discord 免费档单文件上限。 +_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024 +# fetch_history 的 Discord 单次上限。 +_MAX_HISTORY_LIMIT = 100 + + +def _features_slash_commands(binding: "ChannelBinding") -> bool: + """§3.1 features.slash_commands 开关:缺失时默认开启,保持存量行为。""" + features = (binding.config_json or {}).get("features") or {} + return bool(features.get("slash_commands", True)) + class DiscordSendError(RuntimeError): """Discord 发送失败的基类,默认可重试。""" @@ -49,12 +77,24 @@ class DiscordTransientError(DiscordSendError): retryable = True -def normalize_discord_message(raw: Any, *, account_scope: str = "") -> ChannelInbound | None: +def normalize_discord_message( + raw: Any, + *, + account_scope: str = "", + include_bot: bool = False, + require_mention: bool = True, +) -> ChannelInbound | None: """把一条 Discord 消息归一化为 ChannelInbound。 raw 由网关线程从 discord.py 的 Message 对象提取,字段: id / channel_id / guild_id / author_id / author_name / content / mentions(被 @ 的用户 id 列表) / bot_user_id(本机器人的用户 id) / is_group + 扩展字段(功能 2/5/8): + is_thread / thread_id / parent_id(线程上下文,parent_id 用于白名单判定) + attachments(每项 id/filename/content_type/size/url) + command(原生斜杠命令名,命令消息不要求 @bot) + include_bot=True 时不跳过机器人自身消息(回填用);require_mention=False 时 + 群聊不要求 @bot(回填历史上下文用),均不影响入站默认行为。 """ if not isinstance(raw, dict): return None @@ -65,13 +105,14 @@ def normalize_discord_message(raw: Any, *, account_scope: str = "") -> ChannelIn text = str(raw.get("content") or "").strip() if not message_id or not channel_id or not author_id: return None - # 忽略机器人自己发的消息。 - if bot_user_id and author_id == bot_user_id: + # 忽略机器人自己发的消息;回填需要看到 bot 说过的话,由 include_bot 覆盖。 + if not include_bot and bot_user_id and author_id == bot_user_id: return None is_group = bool(raw.get("is_group")) + command = str(raw.get("command") or "").strip() mentions = [str(m) for m in (raw.get("mentions") or [])] - # 群聊只响应明确 @bot 的消息;私聊不受此限制。 - if is_group and not (bot_user_id and bot_user_id in mentions): + # 群聊只响应明确 @bot 的消息;私聊与命令消息不受此限制。 + if is_group and require_mention and not command and not (bot_user_id and bot_user_id in mentions): return None if not text: return None @@ -81,7 +122,8 @@ def normalize_discord_message(raw: Any, *, account_scope: str = "") -> ChannelIn return None if is_group: guild_id = str(raw.get("guild_id") or "").strip() - session_id = channel_id + # 线程消息的会话锚点=线程自身 ID(Discord 线程 channel_id 天然独立)。 + session_id = str(raw.get("thread_id") or "").strip() or channel_id group_id = guild_id or channel_id else: # 私聊以发送者为会话维度,便于跨 DM 频道稳定关联。 @@ -100,9 +142,90 @@ def normalize_discord_message(raw: Any, *, account_scope: str = "") -> ChannelIn raw=raw, sender_name=str(raw.get("author_name") or "").strip(), account_scope=account_scope.strip(), + attachments=_extract_attachments(raw.get("attachments")), ) +def _extract_attachments(items: Any) -> list[ChannelInboundAttachment]: + """把 Discord 消息事件 attachments 数组转为 ChannelInboundAttachment 列表。""" + result: list[ChannelInboundAttachment] = [] + for item in items or []: + if not isinstance(item, dict): + continue + media_id = str(item.get("id") or "").strip() + url = str(item.get("url") or "").strip() + if not media_id or not url: + continue + content_type = str(item.get("content_type") or "").strip() + result.append( + ChannelInboundAttachment( + media_id=media_id, + kind="image" if content_type.startswith("image/") else "file", + filename=str(item.get("filename") or "").strip(), + content_type=content_type, + size=int(item.get("size") or 0), + download_params={"url": url}, + ) + ) + return result + + +def _validate_embeds(embeds: Any) -> list[dict[str, Any]]: + """裁剪 embeds 到 Discord 限制内,超限降级而非拒绝(功能8,§4.8 R8)。 + + 规则:数量≤10、title≤256、description≤4096、fields≤25、 + field name≤256/value≤1024、footer≤2048;未知字段丢弃。 + """ + if not isinstance(embeds, list): + return [] + cleaned: list[dict[str, Any]] = [] + for item in embeds[: _MAX_EMBEDS]: + if not isinstance(item, dict): + continue + embed: dict[str, Any] = {} + title = str(item.get("title") or "").strip() + if title: + embed["title"] = title[:_MAX_EMBED_TITLE] + description = str(item.get("description") or "").strip() + if description: + embed["description"] = description[:_MAX_EMBED_DESCRIPTION] + color = item.get("color") + if color is not None: + try: + embed["color"] = int(color) + except (TypeError, ValueError): + logger.warning("discord embed color 非法,已丢弃: %r", color) + url = str(item.get("url") or "").strip() + if url: + embed["url"] = url + image = item.get("image") + if isinstance(image, dict) and str(image.get("url") or "").strip(): + embed["image"] = {"url": str(image["url"]).strip()} + footer = item.get("footer") + if isinstance(footer, dict) and str(footer.get("text") or "").strip(): + embed["footer"] = {"text": str(footer["text"]).strip()[:_MAX_EMBED_FOOTER]} + fields: list[dict[str, Any]] = [] + for field in (item.get("fields") or [])[: _MAX_EMBED_FIELDS]: + if not isinstance(field, dict): + continue + name = str(field.get("name") or "").strip() + value = str(field.get("value") or "").strip() + if not name or not value: + continue + entry: dict[str, Any] = { + "name": name[:_MAX_EMBED_FIELD_NAME], + "value": value[:_MAX_EMBED_FIELD_VALUE], + } + if bool(field.get("inline")): + entry["inline"] = True + fields.append(entry) + if fields: + embed["fields"] = fields + if embed: + cleaned.append(embed) + return cleaned + + def _credential(binding: ChannelBinding) -> tuple[str, str]: """返回 (bot_id, bot_token);缺凭证抛 DiscordPermanentError。""" config = dict(binding.config_json or {}) @@ -162,8 +285,15 @@ def send( text: str, *, idempotency_key: str | None = None, + payload_json: str | None = None, ) -> None: - channel_id = str(target.get("channel_id") or "").strip() + """出站投递:纯文本分片发送;payload_json 携带 embeds/files 时走富媒体路径。 + + payload_json 格式(功能8):{"content", "embeds": [...], "files": [ + {"filename", "data"(base64 或 bytes), "content_type"}]}。 + target.thread_id 优先于 channel_id(功能2:线程本质是独立频道端点)。 + """ + channel_id = str(target.get("thread_id") or target.get("channel_id") or "").strip() if not channel_id: raise DiscordPermanentError("Discord 目标缺少 channel_id") _bot_id, token = _credential(binding) @@ -171,38 +301,350 @@ def send( "Authorization": f"Bot {token}", "Content-Type": "application/json", } + rich = self._parse_rich_payload(payload_json) try: with self._client_factory() as client: - for index, chunk in enumerate(split_channel_text(text, CHANNEL_TEXT_LIMIT)): - payload: dict[str, Any] = {"content": chunk} - if idempotency_key: - # Discord nonce 限 25 字符;按 idempotency_key+分片序号稳定派生, - # 重试时同一分片得到相同 nonce,避免分片中断后重发产生重复消息 - digest = hashlib.sha256( - f"{idempotency_key}:{index}".encode("utf-8") - ).hexdigest() - payload["nonce"] = digest[:24] - response = client.post( - DISCORD_MESSAGE_API.format(channel_id=channel_id), - json=payload, - headers=headers, - ) - if response.status_code in (401, 403): - raise DiscordPermanentError( - f"Discord 拒绝发送 (HTTP {response.status_code})" - ) - if response.status_code >= 500 or response.status_code == 429: - raise DiscordTransientError( - f"Discord 接口暂不可用 (HTTP {response.status_code})" - ) - if response.status_code >= 400: - raise DiscordPermanentError( - f"Discord 拒绝发送 (HTTP {response.status_code})" - ) + if rich is None: + for index, chunk in enumerate(split_channel_text(text, CHANNEL_TEXT_LIMIT)): + payload: dict[str, Any] = {"content": chunk} + if idempotency_key: + payload["nonce"] = self._nonce(idempotency_key, index) + self._post_message(client, channel_id, payload, headers) + return + body = dict(rich) + content = str(rich.get("content") or "").strip() or text + body["content"] = content[:CHANNEL_TEXT_LIMIT] + if idempotency_key: + body["nonce"] = self._nonce(idempotency_key, 0) + if body.get("files"): + self._post_message_multipart(client, channel_id, body, headers) + else: + self._post_message(client, channel_id, body, headers) + except DiscordSendError: + raise + except (httpx.HTTPError, ValueError) as exc: + raise DiscordTransientError(str(exc)) from exc + + @staticmethod + def _nonce(idempotency_key: str, index: int) -> str: + # Discord nonce 限 25 字符;按 idempotency_key+分片序号稳定派生, + # 重试时同一分片得到相同 nonce,避免分片中断后重发产生重复消息 + digest = hashlib.sha256(f"{idempotency_key}:{index}".encode("utf-8")).hexdigest() + return digest[:24] + + @staticmethod + def _parse_rich_payload(payload_json: str | None) -> dict[str, Any] | None: + """解析投递 payload_json;无富媒体字段时返回 None(走纯文本路径)。""" + if not payload_json or not str(payload_json).strip(): + return None + try: + data = json.loads(payload_json) + except (TypeError, ValueError) as exc: + logger.warning("discord 投递 payload_json 解析失败,降级纯文本: %s", exc) + return None + if not isinstance(data, dict): + return None + embeds = _validate_embeds(data.get("embeds")) + files = data.get("files") or [] + if not embeds and not files: + return None + return {"content": str(data.get("content") or "").strip(), "embeds": embeds, "files": files} + + @staticmethod + def _prepare_files(files: Any) -> list[tuple[str, tuple[str, bytes, str]]]: + """把 payload files 转为 httpx multipart 条目,单文件超 8MiB 抛永久错误。""" + if not isinstance(files, list): + raise DiscordPermanentError("discord payload files 必须是列表") + entries: list[tuple[str, tuple[str, bytes, str]]] = [] + for index, item in enumerate(files): + if not isinstance(item, dict): + raise DiscordPermanentError("discord payload files 条目格式无效") + filename = str(item.get("filename") or "").strip() or f"attachment-{index}" + data = item.get("data") + if isinstance(data, str): + try: + data = base64.b64decode(data) + except (ValueError, TypeError) as exc: + raise DiscordPermanentError(f"discord 附件 {filename} base64 解码失败") from exc + if not isinstance(data, (bytes, bytearray)) or not data: + raise DiscordPermanentError(f"discord 附件 {filename} 缺少数据") + if len(data) > _MAX_ATTACHMENT_BYTES: + raise DiscordPermanentError( + f"discord 附件 {filename} 超过 {_MAX_ATTACHMENT_BYTES // (1024 * 1024)}MiB 上限" + ) + content_type = str(item.get("content_type") or "").strip() or "application/octet-stream" + entries.append((f"files[{index}]", (filename, bytes(data), content_type))) + return entries + + def _post_message( + self, + client: httpx.Client, + channel_id: str, + payload: dict[str, Any], + headers: dict[str, str], + ) -> None: + response = client.post( + DISCORD_MESSAGE_API.format(channel_id=channel_id), + json=payload, + headers=headers, + ) + self._raise_for_status(response, "发送") + + def _post_message_multipart( + self, + client: httpx.Client, + channel_id: str, + body: dict[str, Any], + headers: dict[str, str], + ) -> None: + files = self._prepare_files(body.get("files")) + payload_field = {key: value for key, value in body.items() if key != "files"} + response = client.post( + DISCORD_MESSAGE_API.format(channel_id=channel_id), + files=files, + data={"payload_json": json.dumps(payload_field, ensure_ascii=False)}, + headers={"Authorization": headers["Authorization"]}, + ) + self._raise_for_status(response, "发送") + + @staticmethod + def _raise_for_status(response: httpx.Response, action: str) -> None: + if response.status_code in (401, 403): + raise DiscordPermanentError(f"Discord 拒绝{action} (HTTP {response.status_code})") + if response.status_code >= 500 or response.status_code == 429: + raise DiscordTransientError(f"Discord 接口暂不可用 (HTTP {response.status_code})") + if response.status_code >= 400: + raise DiscordPermanentError(f"Discord 拒绝{action} (HTTP {response.status_code})") + + def send_typing(self, binding: ChannelBinding, target: dict[str, Any], status: int) -> None: + """发送"正在输入"指示(status 1=开始 2=结束,语义对齐微信)。 + + Discord typing 触发后约 10s 自动消失,无"停止"端点:status=2 直接返回。 + best-effort:任何失败仅记录日志,不阻塞主链路。 + """ + if int(status) != 1: + return + channel_id = str(target.get("thread_id") or target.get("channel_id") or "").strip() + if not channel_id: + return + try: + _bot_id, token = _credential(binding) + with self._client_factory() as client: + response = client.post( + DISCORD_TYPING_API.format(channel_id=channel_id), + headers={"Authorization": f"Bot {token}"}, + ) + self._raise_for_status(response, "发送 typing") + except Exception: + logger.warning( + "discord typing 发送失败(忽略) binding=%s channel=%s", + binding.id, + channel_id, + exc_info=True, + ) + + def fetch_history( + self, + binding: ChannelBinding, + target: dict[str, Any], + *, + before: str | None = None, + after: str | None = None, + limit: int = _MAX_HISTORY_LIMIT, + ) -> list[ChannelInbound]: + """拉取频道/线程历史消息并归一化(功能4);不跳过 bot 自身消息,不要求 @bot。""" + channel_id = str(target.get("thread_id") or target.get("channel_id") or "").strip() + if not channel_id: + raise DiscordPermanentError("Discord 回填目标缺少 channel_id") + limit = max(1, min(int(limit or 1), _MAX_HISTORY_LIMIT)) + _bot_id, token = _credential(binding) + params: dict[str, Any] = {"limit": limit} + if str(before or "").strip(): + params["before"] = str(before).strip() + if str(after or "").strip(): + params["after"] = str(after).strip() + try: + with self._client_factory() as client: + response = client.get( + DISCORD_MESSAGE_API.format(channel_id=channel_id), + params=params, + headers={"Authorization": f"Bot {token}"}, + ) + self._raise_for_status(response, "拉取历史") + items = response.json() except DiscordSendError: raise except (httpx.HTTPError, ValueError) as exc: raise DiscordTransientError(str(exc)) from exc + if not isinstance(items, list): + raise DiscordTransientError("Discord 历史消息响应格式无效") + result: list[ChannelInbound] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized = self._history_to_normalize_input(item) + inbound = normalize_discord_message( + normalized, + include_bot=True, + require_mention=False, + ) + if inbound is not None: + result.append(inbound) + return result + + @staticmethod + def _history_to_normalize_input(message: dict[str, Any]) -> dict[str, Any]: + """把 Discord REST 历史消息响应映射为 normalize 的 dict 输入格式。""" + channel = message.get("channel_id") + guild = message.get("guild_id") + author = message.get("author") or {} + return { + "id": str(message.get("id") or ""), + "channel_id": str(channel or ""), + "guild_id": str(guild or ""), + "author_id": str(author.get("id") or ""), + "author_name": str(author.get("global_name") or author.get("username") or ""), + "content": str(message.get("content") or ""), + "mentions": [str(user.get("id") or "") for user in (message.get("mentions") or [])], + "bot_user_id": "", + "is_group": bool(guild), + "is_thread": False, + # 历史消息原始时间戳(ISO 8601);web 回填合并按此排序 + "created_at": str(message.get("timestamp") or ""), + "attachments": [ + { + "id": str(att.get("id") or ""), + "filename": str(att.get("filename") or ""), + "content_type": str(att.get("content_type") or ""), + "size": int(att.get("size") or 0), + "url": str(att.get("url") or ""), + } + for att in (message.get("attachments") or []) + if isinstance(att, dict) and att.get("url") + ], + } + + def download_media( + self, + binding: ChannelBinding, + attachment: ChannelInboundAttachment, + *, + max_bytes: int = 0, + ) -> bytes: + """下载入站附件字节(功能8);Discord CDN 公开无需鉴权头。""" + url = str(attachment.download_params.get("url") or "").strip() + if not url: + raise DiscordPermanentError(f"discord 附件缺少下载地址 media_id={attachment.media_id}") + try: + with self._client_factory() as client: + if max_bytes > 0: + status, data = stream_download_with_limit( + client, "GET", url, max_bytes=max_bytes + ) + if status == 429 or status >= 500: + raise DiscordTransientError("Discord 附件下载服务暂时不可用") + if status >= 400: + raise DiscordPermanentError(f"Discord 拒绝附件下载 HTTP {status}") + return data + response = client.get(url) + except DiscordSendError: + raise + except ValueError: + raise + except (httpx.HTTPError, TypeError) as exc: + raise DiscordTransientError("Discord 附件下载暂时失败") from exc + if response.status_code == 429 or response.status_code >= 500: + raise DiscordTransientError("Discord 附件下载服务暂时不可用") + if response.status_code >= 400: + raise DiscordPermanentError(f"Discord 拒绝附件下载 HTTP {response.status_code}") + return response.content + + def channel_capabilities( + self, binding: ChannelBinding | None = None + ) -> set[ChannelCapability]: + """能力声明(§3.2):voice 默认关闭,由 config_json.features.voice 与 ffmpeg 决定。""" + capabilities = { + ChannelCapability.SLASH_COMMANDS, + ChannelCapability.THREADS, + ChannelCapability.BACKFILL, + ChannelCapability.TYPING, + ChannelCapability.RICH_MEDIA, + } + if binding is not None: + features = (binding.config_json or {}).get("features") or {} + voice_enabled = bool(features.get("voice")) + if voice_enabled and shutil.which("ffmpeg") is not None: + capabilities.add(ChannelCapability.VOICE) + return capabilities + + def send_voice( + self, + binding: ChannelBinding, + target: dict[str, Any], + audio: dict[str, Any], + ) -> None: + """语音播报(功能7,最小闭环):加入语音频道播放音频文件。 + + audio 结构:{"type": "tts"|"file", "text"|"file_ref"}。首版仅支持 + file 类型播放(TTS 合成未配置);ffmpeg 缺失抛永久错误明确提示。 + 实际 join/play 必须投递到 gateway 线程的 asyncio loop 执行。 + """ + if shutil.which("ffmpeg") is None: + raise DiscordPermanentError("服务器缺少 ffmpeg,无法播放语音") + audio_type = str((audio or {}).get("type") or "").strip() + if audio_type == "tts": + raise DiscordPermanentError("语音 TTS 合成未配置,请改用 file 类型") + file_ref = str((audio or {}).get("file_ref") or "").strip() + if not file_ref: + raise DiscordPermanentError("语音投递缺少音频文件(file_ref)") + voice_channel_id = str(target.get("voice_channel_id") or "").strip() + if not voice_channel_id: + raise DiscordPermanentError("语音投递目标缺少 voice_channel_id") + try: + voice_channel_int = int(voice_channel_id) + except ValueError as exc: + raise DiscordPermanentError("语音频道 ID 无效") from exc + if not os.path.isfile(file_ref): + raise DiscordPermanentError(f"语音音频文件不存在: {file_ref}") + from app.channels import get_discord_stream_manager + + manager = get_discord_stream_manager() + loop = manager.get_loop(binding.id) + client = manager.get_client(binding.id) + if loop is None or client is None: + raise DiscordTransientError("Discord 网关未连接,无法播放语音") + import asyncio + + future = asyncio.run_coroutine_threadsafe( + self._play_voice_async(client, voice_channel_int, file_ref), + loop, + ) + try: + future.result(timeout=120) + except DiscordSendError: + raise + except Exception as exc: + raise DiscordPermanentError(f"语音播报失败: {exc}") from exc + + @staticmethod + async def _play_voice_async(client: Any, voice_channel_id: int, file_path: str) -> None: + import asyncio + + import discord + + channel = client.get_channel(voice_channel_id) + if channel is None: + raise DiscordPermanentError("找不到语音频道") + voice_client = await channel.connect() + try: + voice_client.play(discord.FFmpegPCMAudio(file_path)) + while voice_client.is_playing(): + await asyncio.sleep(0.5) + finally: + try: + await voice_client.disconnect() + except Exception: + logger.exception("断开 discord 语音连接失败") def start_ingress(self, binding_id: str) -> None: from app.channels import get_discord_stream_manager @@ -235,6 +677,8 @@ def __init__( self._client_factory = client_factory self._threads: dict[str, threading.Thread] = {} self._stops: dict[str, threading.Event] = {} + self._loops: dict[str, Any] = {} + self._clients: dict[str, Any] = {} self._paused: set[str] = set() self._lock = threading.RLock() self._reconcile_stop = threading.Event() @@ -279,16 +723,31 @@ def _run_binding(self, binding_id: str, stop: threading.Event) -> None: expected_revision=expected_revision, bot_id=bot_id, ) - factory = self._client_factory or self._default_client_factory + default_factory = self._default_client_factory + factory = self._client_factory or default_factory import asyncio loop = asyncio.new_event_loop() + self._loops[binding_id] = loop try: asyncio.set_event_loop(loop) loop.run_until_complete( - self._run_gateway(factory, token, handler, stop, binding_id) + self._run_gateway( + factory, + token, + handler, + stop, + binding_id, + # 仅默认工厂支持开关参数;自定义工厂签名保持 (token, on_message) + slash_commands=( + _features_slash_commands(binding) + if factory is default_factory + else None + ), + ) ) finally: + self._loops.pop(binding_id, None) try: loop.run_until_complete(loop.shutdown_asyncgens()) finally: @@ -300,12 +759,15 @@ def _run_binding(self, binding_id: str, stop: threading.Event) -> None: self._threads.pop(binding_id, None) self._stops.pop(binding_id, None) - def _default_client_factory(self, token: str, on_message): + def _default_client_factory(self, token: str, on_message, *, slash_commands: bool = True): import discord + from discord.ext import commands intents = discord.Intents.default() intents.message_content = True - client = discord.Client(intents=intents) + # command_prefix 仅用于满足 commands.Bot 构造要求;on_message 事件仍由 + # 下方显式注册覆盖 internal handler,前缀命令解析不会执行。 + client = commands.Bot(command_prefix="", intents=intents, help_command=None) async def _on_message(message) -> None: await on_message(message) @@ -313,12 +775,125 @@ async def _on_message(message) -> None: _on_message.__name__ = "on_message" client.event(_on_message) + # §3.1 features.slash_commands 开关:关闭时只保留 on_message 文本处理, + # 不注册命令树也不同步(on_ready 因缺少 _sync_commands 自动跳过)。 + if slash_commands: + self._register_slash_commands(client, on_message) + + async def _sync_commands() -> None: + await self._sync_slash_commands(client) + + # 供 _run_gateway 的 on_ready 在连接就绪后按 guild 同步命令树。 + setattr(client, "_sync_commands", _sync_commands) + return client - async def _run_gateway(self, factory, token: str, handler, stop: threading.Event, binding_id: str) -> None: + @staticmethod + def _command_text(name: str, argument: str | None = None) -> str: + """斜杠命令序列化为等价文本指令,复用 service_routing.parse_command 单一事实来源。""" + argument = str(argument or "").strip() + if name == "employee": + return f"/切换 {argument}" if argument else "/员工" + if name == "switch": + return f"/切换 {argument}" if argument else "/切换" + if name == "current": + return "/当前" + if name == "help": + return "/帮助" + if name == "bind": + return f"/绑定 {argument}" if argument else "/绑定" + return "/帮助" + + def _register_slash_commands(self, client, on_message) -> None: + import discord + + async def _dispatch( + interaction: discord.Interaction, + command_name: str, + argument: str | None = None, + ) -> None: + # 先立即确认交互(显示"正在思考"),处理结果经 durable inbox/outbox 回写。 + try: + await interaction.response.defer() + except Exception: + logger.warning( + "discord 命令 defer 失败 command=%s", command_name, exc_info=True + ) + user = getattr(interaction, "user", None) + raw = { + "id": str(getattr(interaction, "id", "") or ""), + "channel_id": str(getattr(interaction, "channel_id", "") or ""), + "guild_id": str(getattr(interaction, "guild_id", "") or ""), + "author_id": str(getattr(user, "id", "") or ""), + "author_name": str( + getattr(user, "display_name", "") or getattr(user, "name", "") or "" + ), + "content": self._command_text(command_name, argument), + "mentions": [], + "bot_user_id": str(getattr(client.user, "id", "") or ""), + "is_group": bool(getattr(interaction, "guild_id", None)), + "command": f"/{command_name}", + } + await on_message(raw) + + @client.tree.command(name="employee", description="查看或切换可调度员工") + async def _employee( + interaction: discord.Interaction, name: str | None = None + ) -> None: + await _dispatch(interaction, "employee", name) + + @client.tree.command(name="switch", description="切换到指定员工") + async def _switch( + interaction: discord.Interaction, name: str | None = None + ) -> None: + await _dispatch(interaction, "switch", name) + + @client.tree.command(name="current", description="查看当前员工") + async def _current(interaction: discord.Interaction) -> None: + await _dispatch(interaction, "current") + + @client.tree.command(name="help", description="显示可用指令") + async def _help(interaction: discord.Interaction) -> None: + await _dispatch(interaction, "help") + + @client.tree.command(name="bind", description="触发身份绑定码") + async def _bind(interaction: discord.Interaction, code: str | None = None) -> None: + await _dispatch(interaction, "bind", code) + + async def _sync_slash_commands(self, client) -> None: + """按 guild 同步命令树;同步失败仅记日志,不影响连接状态(文本指令仍可用)。""" + try: + for guild in client.guilds: + await client.tree.sync(guild=guild) + except Exception: + logger.warning("discord 斜杠命令同步失败(文本指令仍可用)", exc_info=True) + + def get_loop(self, binding_id: str): + """返回 binding 网关线程的 asyncio loop;未运行返回 None。""" + return self._loops.get(binding_id) + + def get_client(self, binding_id: str): + """返回 binding 网关线程的 discord client;未运行返回 None。""" + return self._clients.get(binding_id) + + async def _run_gateway( + self, + factory, + token: str, + handler, + stop: threading.Event, + binding_id: str, + *, + slash_commands: bool | None = None, + ) -> None: import asyncio - client = factory(token, handler.handle_message) + if slash_commands is None: + # 外部注入的自定义 client_factory 签名固定为 (token, on_message),不转发开关 + client = factory(token, handler.handle_message) + else: + client = factory(token, handler.handle_message, slash_commands=slash_commands) + self._clients[binding_id] = client async def mark_connected(connected: bool) -> None: await asyncio.to_thread(self._set_connected, binding_id, handler.expected_revision, connected) @@ -327,6 +902,12 @@ async def mark_connected(connected: bool) -> None: if callable(register_event): async def _on_ready() -> None: await mark_connected(True) + sync_commands = cast(Callable[[], Any], getattr(client, "_sync_commands", None)) + if callable(sync_commands): + try: + await sync_commands() + except Exception: + logger.exception("discord 命令同步异常 binding=%s", binding_id) _on_ready.__name__ = "on_ready" register_event(_on_ready) @@ -349,6 +930,7 @@ async def _on_ready() -> None: except Exception: logger.exception("discord 网关异常退出 binding=%s", binding_id) finally: + self._clients.pop(binding_id, None) try: await client.close() except Exception: diff --git a/backend/app/channels/batch_service.py b/backend/app/channels/batch_service.py new file mode 100644 index 000000000..fe3b278d9 --- /dev/null +++ b/backend/app/channels/batch_service.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import json +import logging +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session + +from app.channels.adapters.base import ChannelInbound, get_channel_adapter +from app.db import engine as default_engine +from app.db.models import ( + ChannelBinding, + ChannelInboundEvent, + new_id, + utc_now, +) + +logger = logging.getLogger(__name__) + +# 批处理限流整形参数(设计文档 §4.3 D3-2):容量 5 / 每 5s 补 1 +TOKEN_BUCKET_DEFAULT_CAPACITY = 5 +TOKEN_BUCKET_DEFAULT_REFILL_SECONDS = 5.0 +TOKEN_BUCKET_DEFAULT_REFILL_AMOUNT = 1 +# 回填单次拉取上限(设计文档 §4.4 D4-1):Discord REST 单次上限 100 +BACKFILL_DEFAULT_LIMIT = 100 + + +@dataclass +class TokenBucket: + """每 binding 的令牌桶:容量 capacity,每 refill_period_seconds 补 refill_amount。""" + + capacity: int = TOKEN_BUCKET_DEFAULT_CAPACITY + refill_period_seconds: float = TOKEN_BUCKET_DEFAULT_REFILL_SECONDS + refill_amount: int = TOKEN_BUCKET_DEFAULT_REFILL_AMOUNT + _tokens: float = field(init=False) + _last_refill: float = field(init=False) + _lock: threading.Lock = field(init=False) + + def __post_init__(self) -> None: + self._tokens = float(self.capacity) + self._last_refill = time.monotonic() + self._lock = threading.Lock() + + def _refill(self, now: float) -> None: + elapsed = now - self._last_refill + if elapsed >= self.refill_period_seconds: + periods = int(elapsed // self.refill_period_seconds) + self._tokens = min(float(self.capacity), self._tokens + periods * self.refill_amount) + self._last_refill += periods * self.refill_period_seconds + + def acquire(self) -> bool: + """非阻塞获取:有令牌即消耗并返回 True,否则 False。""" + with self._lock: + now = time.monotonic() + self._refill(now) + if self._tokens >= 1.0: + self._tokens -= 1.0 + return True + return False + + def wait_until_available(self, timeout: float | None = None) -> bool: + """阻塞直到有令牌可用(限流整形);超时返回 False。""" + deadline = None if timeout is None else time.monotonic() + timeout + while True: + if self.acquire(): + return True + if deadline is not None and time.monotonic() >= deadline: + return False + time.sleep(0.05) + + +@dataclass +class BatchJob: + """批处理作业(内存首版):投递多分片/多条消息,限流整形后逐条发送。""" + + job_id: str + binding_id: str + tenant_id: str + target: dict[str, Any] + items: list[Any] + thread_id: str | None = None + client_batch_id: str | None = None + status: str = "pending" # pending/running/done/failed + progress: int = 0 + succeeded: int = 0 + failed: int = 0 + errors: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=utc_now) + + +@dataclass +class BackfillJob: + """历史回填作业(内存首版):拉取频道历史写入 backfilled 入站事件,不触发 agent。""" + + job_id: str + binding_id: str + channel_id: str + limit: int = BACKFILL_DEFAULT_LIMIT + after: str | None = None + before: str | None = None + status: str = "pending" # pending/running/done/failed + written: int = 0 + duplicates: int = 0 + errors: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=utc_now) + + +def _item_parts(item: Any) -> tuple[str, dict[str, Any] | None]: + """拆解批量项为 (text, payload):纯文本项无 payload,结构化项取 embeds/files。""" + if isinstance(item, str): + return item, None + if isinstance(item, dict): + text = str(item.get("content") or "") + payload = {key: value for key, value in item.items() if key != "content" and value} + return text, payload or None + raise TypeError(f"不支持的批量项类型: {type(item)!r}") + + +def _batch_idempotency_key(job_id: str, index: int) -> str: + """批量项幂等键(设计文档 §4.3 D3-3):batch:{job_id}:{index},失败重试不重复发送。""" + return f"batch:{job_id}:{index}" + + +class BatchService: + """内存批处理/回填作业注册表:submit 入队即起后台线程执行。 + + 首版不做 DB 持久化,进程重启丢失未完成作业;演进路径:复用 APIJob 状态机 + 语义(public_api/jobs.py)迁移到 APIJob 表,幂等键 batch:{job_id}:{index} + 已保证迁移后重试不重复发送。 + """ + + def __init__(self) -> None: + self._batch_jobs: dict[str, BatchJob] = {} + self._backfill_jobs: dict[str, BackfillJob] = {} + # (binding_id, client_batch_id) -> job_id:客户端幂等去重 + self._client_batch_keys: dict[tuple[str, str], str] = {} + self._buckets: dict[str, TokenBucket] = {} + self._lock = threading.Lock() + + # ---------- token 桶 ---------- + + def bucket_for(self, binding_id: str) -> TokenBucket: + with self._lock: + bucket = self._buckets.get(binding_id) + if bucket is None: + bucket = TokenBucket() + self._buckets[binding_id] = bucket + return bucket + + # ---------- 批处理 ---------- + + def submit_batch( + self, + binding: ChannelBinding, + tenant_id: str, + target: dict[str, Any], + items: list[Any], + *, + thread_id: str | None = None, + client_batch_id: str | None = None, + db_engine=None, + autostart: bool = True, + ) -> str: + """入队批量作业并启动后台执行线程,返回 job_id。 + + autostart=False 仅供测试直接驱动 run_batch;生产路径保持提交即执行。 + """ + job_id = new_id("chbat") + with self._lock: + if client_batch_id: + existing_id = self._client_batch_keys.get((binding.id, client_batch_id)) + if existing_id and existing_id in self._batch_jobs: + return existing_id + job = BatchJob( + job_id=job_id, + binding_id=binding.id, + tenant_id=tenant_id, + target=dict(target), + items=list(items), + thread_id=thread_id, + client_batch_id=client_batch_id, + ) + self._batch_jobs[job_id] = job + if client_batch_id: + self._client_batch_keys[(binding.id, client_batch_id)] = job_id + if not autostart: + return job_id + worker = threading.Thread( + target=self.run_batch, + args=(job_id,), + kwargs={"db_engine": db_engine}, + name=f"staffdeck-batch-{job_id}", + daemon=True, + ) + worker.start() + return job_id + + def get_batch(self, job_id: str) -> BatchJob | None: + with self._lock: + return self._batch_jobs.get(job_id) + + def run_batch(self, job_id: str, *, db_engine=None) -> None: + """后台线程主体:逐项限流整形后调用 adapter.send,单条失败不终止整体。""" + job = self.get_batch(job_id) + if job is None or job.status == "running": + return + job.status = "running" + try: + with Session(db_engine or default_engine) as db: + binding = db.get(ChannelBinding, job.binding_id) + if binding is None: + job.status = "failed" + job.errors.append("binding_not_found") + return + db.expunge(binding) + adapter = get_channel_adapter(binding.channel) + send = getattr(adapter, "send", None) + if not callable(send): + job.status = "failed" + job.errors.append("adapter_no_send") + return + bucket = self.bucket_for(binding.id) + for index, item in enumerate(job.items): + try: + text, payload = _item_parts(item) + target = dict(job.target) + if job.thread_id: + target["thread_id"] = job.thread_id + payload_json = None + if payload: + payload_json = json.dumps(payload, ensure_ascii=False) + if not bucket.wait_until_available(): + job.errors.append(f"index={index} 限流等待超时") + job.failed += 1 + continue + send_kwargs: dict[str, Any] = { + "idempotency_key": _batch_idempotency_key(job_id, index), + } + if payload_json is not None: + send_kwargs["payload_json"] = payload_json + send(binding, target, text, **send_kwargs) + job.succeeded += 1 + except Exception as exc: + job.failed += 1 + job.errors.append(f"index={index}: {str(exc)[:200]}") + logger.warning( + "批量投递单项失败 job=%s index=%s: %s", + job_id, + index, + exc, + ) + finally: + job.progress += 1 + job.status = "done" + except Exception as exc: + logger.exception("批量作业执行失败 job=%s", job_id) + job.status = "failed" + job.errors.append(str(exc)[:500]) + + def clear_batches(self, *, completed_only: bool = True) -> int: + """清理已完成/失败的作业记录;返回清理条数。""" + with self._lock: + stale = [ + job_id + for job_id, job in self._batch_jobs.items() + if (not completed_only) or job.status in {"done", "failed"} + ] + for job_id in stale: + self._batch_jobs.pop(job_id, None) + stale_keys = [ + key + for key, job_id in self._client_batch_keys.items() + if job_id not in self._batch_jobs + ] + for key in stale_keys: + self._client_batch_keys.pop(key, None) + return len(stale) + + # ---------- 回填 ---------- + + def submit_backfill( + self, + binding: ChannelBinding, + *, + channel_id: str, + limit: int = BACKFILL_DEFAULT_LIMIT, + after: str | None = None, + before: str | None = None, + db_engine=None, + autostart: bool = True, + ) -> str: + """入队回填作业并启动后台执行线程,返回 job_id。""" + job_id = new_id("chbfill") + job = BackfillJob( + job_id=job_id, + binding_id=binding.id, + channel_id=channel_id, + limit=limit, + after=after, + before=before, + ) + with self._lock: + self._backfill_jobs[job_id] = job + if not autostart: + return job_id + worker = threading.Thread( + target=self.run_backfill, + args=(job_id,), + kwargs={"db_engine": db_engine}, + name=f"staffdeck-backfill-{job_id}", + daemon=True, + ) + worker.start() + return job_id + + def get_backfill(self, job_id: str) -> BackfillJob | None: + with self._lock: + return self._backfill_jobs.get(job_id) + + def run_backfill(self, job_id: str, *, db_engine=None) -> None: + """回填执行:拉取历史写入 status=backfilled 事件,幂等查重,不触发 agent。""" + job = self.get_backfill(job_id) + if job is None or job.status == "running": + return + job.status = "running" + try: + with Session(db_engine or default_engine) as db: + binding = db.get(ChannelBinding, job.binding_id) + if binding is None: + job.status = "failed" + job.errors.append("binding_not_found") + return + adapter = get_channel_adapter(binding.channel) + fetch_history = getattr(adapter, "fetch_history", None) + if not callable(fetch_history): + job.status = "failed" + job.errors.append("adapter_no_fetch_history") + return + try: + messages = fetch_history( + binding, + {"channel_id": job.channel_id}, + before=job.before, + after=job.after, + limit=job.limit, + ) + except Exception as exc: + logger.warning("回填拉取历史失败 binding=%s: %s", binding.id, exc) + job.status = "failed" + job.errors.append(f"fetch_history: {str(exc)[:300]}") + return + if not isinstance(messages, list): + job.status = "failed" + job.errors.append("fetch_history 返回类型无效") + return + for message in messages: + message_dict = _backfill_message_dict(message) + event_id = str(message_dict.get("id") or "").strip() + if not event_id: + continue + if job.written + job.duplicates >= job.limit: + break + target = { + "to_user_id": _backfill_author_id(message_dict), + "channel_id": str(message_dict.get("channel_id") or job.channel_id), + "guild_id": str(message_dict.get("guild_id") or ""), + "message_id": event_id, + } + event = ChannelInboundEvent( + id=new_id("chevt"), + tenant_id=binding.tenant_id, + binding_id=binding.id, + channel=binding.channel, + event_id=event_id, + payload_json={ + "schema_version": 1, + "backfilled": True, + "message": message_dict, + }, + config_revision=binding.config_revision, + target_json=target, + # backfilled 不在 received 集合,durable intake 不消费, + # 回填消息天然不触发 agent(设计文档 §4.4 D4-2) + status="backfilled", + ) + db.add(event) + try: + db.commit() + except IntegrityError: + db.rollback() + job.duplicates += 1 + else: + job.written += 1 + job.status = "done" + except Exception as exc: + logger.exception("回填作业执行失败 job=%s", job_id) + job.status = "failed" + job.errors.append(str(exc)[:500]) + + def clear_backfills(self) -> int: + """清理已完成/失败的回填作业记录;返回清理条数。""" + with self._lock: + stale = [ + job_id + for job_id, job in self._backfill_jobs.items() + if job.status in {"done", "failed"} + ] + for job_id in stale: + self._backfill_jobs.pop(job_id, None) + return len(stale) + + +def _backfill_message_dict(message: Any) -> dict[str, Any]: + """把 fetch_history 返回统一为可 JSON 序列化的 dict。 + + 真实 Discord 适配器返回 ChannelInbound(dataclass),测试与未来渠道可能返回 + dict;两种形态在此归一,payload_json 落库只存 dict。 + """ + if isinstance(message, ChannelInbound): + raw = message.raw if isinstance(message.raw, dict) else {} + return { + "id": message.event_id, + "channel_id": str(raw.get("channel_id") or ""), + "guild_id": str(raw.get("guild_id") or ""), + "author_id": message.from_user_id, + "author": {"id": message.from_user_id}, + "author_name": message.sender_name, + "content": message.text, + "created_at": str(raw.get("created_at") or ""), + "attachments": raw.get("attachments") or [], + } + return dict(message) + + +def _backfill_author_id(message: dict[str, Any]) -> str: + author = message.get("author") + if isinstance(author, dict): + return str(author.get("id") or "") + return str(message.get("author_id") or "") + + +# 模块级单例:API 端点与测试共用 +batch_service = BatchService() diff --git a/backend/app/channels/discord_runtime.py b/backend/app/channels/discord_runtime.py index 8be3cd07f..797dffee7 100644 --- a/backend/app/channels/discord_runtime.py +++ b/backend/app/channels/discord_runtime.py @@ -52,7 +52,7 @@ def _serialize(self, message: Any) -> dict[str, Any]: channel = getattr(message, "channel", None) guild = getattr(message, "guild", None) mentions = getattr(message, "mentions", None) or [] - return { + raw: dict[str, Any] = { "id": str(getattr(message, "id", "") or ""), "channel_id": str(getattr(channel, "id", "") or ""), "guild_id": str(getattr(guild, "id", "") or "") if guild else "", @@ -63,3 +63,25 @@ def _serialize(self, message: Any) -> dict[str, Any]: "bot_user_id": self.bot_id, "is_group": guild is not None, } + channel_type = getattr(channel, "type", None) + if channel_type is not None and str(getattr(channel_type, "name", "")) in { + "public_thread", + "private_thread", + "news_thread", + }: + raw["is_thread"] = True + raw["thread_id"] = str(getattr(channel, "id", "") or "") + raw["parent_id"] = str(getattr(channel, "parent_id", "") or "") + attachments = getattr(message, "attachments", None) or [] + raw["attachments"] = [ + { + "id": str(getattr(att, "id", "") or ""), + "filename": str(getattr(att, "filename", "") or ""), + "content_type": str(getattr(att, "content_type", "") or ""), + "size": int(getattr(att, "size", 0) or 0), + "url": str(getattr(att, "url", "") or ""), + } + for att in attachments + if getattr(att, "url", "") + ] + return raw diff --git a/backend/app/channels/schema.py b/backend/app/channels/schema.py index ae9101d5c..d5ee1ae76 100644 --- a/backend/app/channels/schema.py +++ b/backend/app/channels/schema.py @@ -1,13 +1,72 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, Literal, Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator from sqlmodel import Session from app.db.models import ChannelBinding, ChannelDelivery, User +def _validate_allowlist_id(value: str) -> str: + """白名单 ID 宽松校验:Discord snowflake 为数字字符串,允许非空即可。 + + 首版不做严格格式约束,便于粘贴 guild/channel/role/user ID; + 非法值由渠道侧 fence 判定时自然不匹配,不会产生错误放行。 + """ + stripped = value.strip() + if not stripped: + raise ValueError("白名单 ID 不能为空") + return stripped + + +class AllowlistConfig(BaseModel): + """权限白名单配置(功能5,§4.5):入站侧访问控制,出站投递不拦截。 + + mode=allow_all 时 allow 列表为空即放行、非空则仅列表内放行(黑名单增强); + mode=deny_all 时仅 allow 列表内放行(白名单严格模式);deny 永远优先。 + """ + + mode: Literal["allow_all", "deny_all"] = "allow_all" + guild_ids: list[str] = Field(default_factory=list) + channel_ids: list[str] = Field(default_factory=list) + role_ids: list[str] = Field(default_factory=list) + user_ids: list[str] = Field(default_factory=list) + deny: list[str] = Field(default_factory=list) + + @field_validator( + "guild_ids", "channel_ids", "role_ids", "user_ids", "deny" + ) + @classmethod + def _validate_ids(cls, values: list[str]) -> list[str]: + return [_validate_allowlist_id(value) for value in values] + + +class ChannelFeaturesConfig(BaseModel): + """能力开关(§3.1 features):运行时门禁的显式开关,与适配器能力声明正交。""" + + slash_commands: bool = True + threads: bool = True + typing: bool = True + voice: bool = False + rich_media: bool = True + backfill: bool = True + + +class ChannelBatchConfig(BaseModel): + """批处理队列参数(§3.1 batch,功能3):限流整形与单次作业上限。""" + + max_per_run: int = Field(default=20, ge=1) + interval_sec: float = Field(default=1.0, gt=0) + + +class ChannelBackfillConfig(BaseModel): + """回填参数(§3.1 backfill,功能4):单次拉取上限与历史窗口。""" + + limit_per_fetch: int = Field(default=100, ge=1, le=100) + max_history_days: int = Field(default=7, ge=1) + + class ChannelBindingCreate(BaseModel): tenant_id: str agent_id: str @@ -30,6 +89,10 @@ class ChannelBindingAgentsUpdate(BaseModel): agents: Optional[list[ChannelBindingAgentInput]] = None # 智能分发开关:不传不动,传则写 config_json.auto_route auto_route: Optional[bool] = None + # 权限白名单(功能5):不传不动,传则写 config_json.allowlist 且 config_revision+=1 + allowlist: Optional[AllowlistConfig] = None + # 能力开关(§3.1 features):不传不动,传则写 config_json.features 且 config_revision+=1 + features: Optional[ChannelFeaturesConfig] = None class ChannelBindingRead(BaseModel): @@ -133,6 +196,10 @@ class ChannelDeliveryRead(BaseModel): message_id: Optional[str] = None kind: str text: str + payload_json: Optional[str] = None + thread_id: Optional[str] = None + batch_id: Optional[str] = None + delivery_kind: Optional[str] = None status: str attempts: int last_error: Optional[str] = None @@ -201,6 +268,72 @@ class ChannelDeliveryDayPage(BaseModel): limit: int +# ---------- 批处理作业(功能3,§4.3) ---------- + + +class BatchSendItem(BaseModel): + """批量投递单条消息:纯文本(content)或富媒体载荷(embeds/files 非空)。""" + + content: str = "" + embeds: list[dict[str, Any]] = Field(default_factory=list) + files: list[dict[str, Any]] = Field(default_factory=list) + + @property + def is_rich(self) -> bool: + return bool(self.embeds or self.files) + + +class BatchSendRequest(BaseModel): + """POST /batch 请求体:items 兼容纯文本列表或结构化载荷列表。""" + + items: list[Union[str, BatchSendItem]] + thread_id: Optional[str] = None + # 客户端幂等 ID:重复提交返回既有作业 + client_batch_id: Optional[str] = None + + @field_validator("items") + @classmethod + def _validate_items(cls, items: list[Union[str, BatchSendItem]]) -> list[Union[str, BatchSendItem]]: + if not items: + raise ValueError("items 不能为空") + return items + + +class BatchJobRead(BaseModel): + """批量作业查询视图(内存首版,无 DB 持久化)。""" + + job_id: str + status: str + progress: int + total: int + succeeded: int + failed: int + errors: list[str] = [] + + +class BatchSubmitRead(BaseModel): + job_id: str + status: str + + +# ---------- 历史回填(功能4,§4.4) ---------- + + +class BackfillRequest(BaseModel): + channel_id: Optional[str] = None + limit: Optional[int] = Field(default=None, ge=1, le=100) + after: Optional[str] = None + before: Optional[str] = None + + +class BackfillJobRead(BaseModel): + job_id: str + status: str + written: int = 0 + duplicates: int = 0 + errors: list[str] = [] + + def channel_binding_agents_read(db: Session, binding: ChannelBinding) -> list[ChannelBindingAgentRead]: """挂载员工列表(含存量绑定 legacy 回退),join agent_profiles 取名称。""" from app.channels.service_routing import agent_names, mounted_agents @@ -266,6 +399,10 @@ def channel_delivery_read(delivery: ChannelDelivery) -> ChannelDeliveryRead: message_id=delivery.message_id, kind=delivery.kind, text=delivery.text, + payload_json=delivery.payload_json, + thread_id=delivery.thread_id, + batch_id=delivery.batch_id, + delivery_kind=delivery.delivery_kind, status=delivery.status, attempts=delivery.attempts, last_error=delivery.last_error, diff --git a/backend/app/channels/service_discord_inbox.py b/backend/app/channels/service_discord_inbox.py index 6e4fcfbda..39715b3b0 100644 --- a/backend/app/channels/service_discord_inbox.py +++ b/backend/app/channels/service_discord_inbox.py @@ -1,16 +1,19 @@ from __future__ import annotations import json +import logging from dataclasses import asdict from typing import Any from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlmodel import Session, select -from app.channels.adapters.base import ChannelInbound +from app.channels.adapters.base import ChannelInbound, evaluate_allowlist from app.channels.service_durable_inbox import StageDisposition, StageResult from app.db.models import ChannelBinding, ChannelInboundEvent, new_id +logger = logging.getLogger(__name__) + DISCORD_ENVELOPE_VERSION = 1 MAX_ENVELOPE_BYTES = 256 * 1024 @@ -72,6 +75,9 @@ def stage_discord_inbound( return StageResult(StageDisposition.SECURITY_DROP, error_code="binding_fence_mismatch") # Discord 没有企业租户维度,identity_scope 为空,无需修补。 raw = inbound.raw if isinstance(inbound.raw, dict) else {} + config = dict(binding.config_json or {}) + if not _allowlist_allows(config, raw, inbound.from_user_id): + return _drop_rejected(db, binding, inbound, envelope, expected_revision) target = { # 兼容现有 intake/outbox 的通用目标校验;channel_id 用于 REST 出站定位。 "to_user_id": inbound.conv_key if inbound.is_group else inbound.from_user_id, @@ -83,6 +89,9 @@ def stage_discord_inbound( id=new_id("chevt"), tenant_id=binding.tenant_id, binding_id=binding.id, channel="discord", event_id=inbound.event_id, payload_json=envelope, config_revision=expected_revision, target_json=target, status="received", + thread_id=_clean(raw.get("thread_id")), + mention_user_ids=_mention_ids(raw, bot_id), + command=_clean(raw.get("command")), ) db.add(event) try: @@ -99,3 +108,57 @@ def stage_discord_inbound( return StageResult(StageDisposition.STAGED, event_pk=event.id) except SQLAlchemyError: return StageResult(StageDisposition.NACK, error_code="inbox_database_error") + + +def _allowlist_allows(config: dict[str, Any], raw: dict[str, Any], author_id: str) -> bool: + """第六重校验:config_json.allowlist 白名单判定(功能5)。 + + 线程消息按父频道判定(parent_id 优先),角色白名单需 members intent,首版不参与。 + """ + message_ctx = { + "guild_id": str(raw.get("guild_id") or "").strip(), + "channel_id": str(raw.get("parent_id") or raw.get("channel_id") or "").strip(), + "author_id": author_id, + } + return evaluate_allowlist(message_ctx, config.get("allowlist")) + + +def _drop_rejected( + db: Session, + binding: ChannelBinding, + inbound: ChannelInbound, + envelope: dict[str, Any], + expected_revision: int, +) -> StageResult: + """白名单拒绝:静默丢弃 + 落库 rejected 可审计(R6);拒绝幂等去重。""" + logger.warning( + "discord 入站被白名单拒绝 binding=%s event=%s channel=%s author=%s", + binding.id, + inbound.event_id, + str((inbound.raw or {}).get("channel_id") or ""), + inbound.from_user_id, + ) + rejected = ChannelInboundEvent( + id=new_id("chevt"), tenant_id=binding.tenant_id, binding_id=binding.id, + channel="discord", event_id=inbound.event_id, payload_json=envelope, + config_revision=expected_revision, status="rejected", error="allowlist_denied", + ) + db.add(rejected) + try: + db.commit() + except IntegrityError: + db.rollback() + return StageResult(StageDisposition.SECURITY_DROP, error_code="allowlist_denied") + + +def _clean(value: Any) -> str | None: + cleaned = str(value or "").strip() + return cleaned or None + + +def _mention_ids(raw: dict[str, Any], bot_id: str) -> str | None: + mentions = [str(item) for item in (raw.get("mentions") or [])] + mentions = [user_id for user_id in mentions if user_id and user_id != bot_id] + if not mentions: + return None + return json.dumps(mentions, ensure_ascii=False) diff --git a/backend/app/channels/service_intake.py b/backend/app/channels/service_intake.py index a6080e5c6..e34c685b1 100644 --- a/backend/app/channels/service_intake.py +++ b/backend/app/channels/service_intake.py @@ -33,6 +33,7 @@ run_command, ) from app.channels.service_session import find_or_create_channel_session +from app.channels.typing_manager import begin_typing, end_typing from app.config import get_settings from app.db import engine from app.db.models import ( @@ -960,6 +961,8 @@ def process_inbound( attachments=attachments, ) _send_wechat_typing(binding, inbound.from_user_id, inbound.context_token, 1, db_engine=use_engine) + # 周期性 typing:begin 内部做能力门禁,仅 Discord 等声明 TYPING 的渠道生效 + begin_typing(binding, target) try: AgentLoop(db).handle_turn(request) except Exception as exc: @@ -977,6 +980,7 @@ def process_inbound( db.commit() return False finally: + end_typing(binding, target) _send_wechat_typing(binding, inbound.from_user_id, inbound.context_token, 2, db_engine=use_engine) event.status = "done" event.processed_at = utc_now() diff --git a/backend/app/channels/service_outbox.py b/backend/app/channels/service_outbox.py index de4a37c7e..8fd064d06 100644 --- a/backend/app/channels/service_outbox.py +++ b/backend/app/channels/service_outbox.py @@ -1,8 +1,10 @@ from __future__ import annotations +import json import logging import threading from datetime import timedelta +from typing import Any from sqlalchemy import func, or_, update from sqlmodel import Session, select @@ -207,6 +209,14 @@ def stage_channel_delivery(db: Session, chat_session: ChatSession, message: Mess error="delivery_target_missing", ) return + # 富媒体结构化载荷(功能8,§4.8 D8-1):消息 metadata 的 channel_payload + # 键(embeds/files)由生成方写入,此处仅负责序列化落库;缺省保持 None。 + payload = (message.metadata_json or {}).get("channel_payload") + payload_json = ( + json.dumps(payload, ensure_ascii=False) + if isinstance(payload, dict) and payload + else None + ) db.add( ChannelDelivery( tenant_id=chat_session.tenant_id, @@ -216,6 +226,7 @@ def stage_channel_delivery(db: Session, chat_session: ChatSession, message: Mess target_json=target, kind="reply", text=message.content, + payload_json=payload_json, status="pending", next_attempt_at=utc_now(), idempotency_key=message.id, @@ -473,7 +484,16 @@ def _deliver_one_locked(db: Session, delivery: ChannelDelivery) -> None: try: adapter = get_channel_adapter(binding.channel) target = dict(delivery.target_json or {}) - if delivery.kind == "reaction_add": + if delivery.delivery_kind == "voice": + send_voice = getattr(adapter, "send_voice", None) + if not callable(send_voice): + raise RuntimeError("渠道适配器不支持语音投递") + voice_payload = json.loads(delivery.payload_json) if delivery.payload_json else None + audio = dict((voice_payload or {}).get("audio") or {}) + if not audio: + raise RuntimeError("语音投递缺少 audio 载荷") + send_voice(binding, target, audio) + elif delivery.kind == "reaction_add": add_reaction = getattr(adapter, "add_reaction", None) if not callable(add_reaction): raise RuntimeError("渠道适配器不支持 reaction") @@ -506,12 +526,12 @@ def _deliver_one_locked(db: Session, delivery: ChannelDelivery) -> None: reaction_event.updated_at = utc_now() db.add(reaction_event) else: - adapter.send( - binding, - target, - delivery.text, - idempotency_key=delivery.idempotency_key, - ) + send_kwargs: dict[str, Any] = {"idempotency_key": delivery.idempotency_key} + if delivery.payload_json and binding.channel == "discord": + # 仅 DiscordAdapter.send 声明 payload_json 命名参数(功能8富媒体); + # 存量渠道签名无此参数,条件传递保持零影响 + send_kwargs["payload_json"] = delivery.payload_json + adapter.send(binding, target, delivery.text, **send_kwargs) except Exception as exc: delivery.last_error = str(exc)[:500] retryable = bool(getattr(exc, "retryable", True)) diff --git a/backend/app/channels/service_routing.py b/backend/app/channels/service_routing.py index 258e822aa..fcd57573f 100644 --- a/backend/app/channels/service_routing.py +++ b/backend/app/channels/service_routing.py @@ -46,19 +46,20 @@ def parse_command(text: str) -> ChannelCommand | None: return None body = stripped[1:].strip() lowered = body.lower() - if lowered in {"员工", "list"}: + if lowered in {"员工", "list", "employee"}: return ChannelCommand(kind="list") - if lowered in {"当前", "目前"}: + if lowered in {"当前", "目前", "current"}: return ChannelCommand(kind="current") - if lowered in {"帮助", "?", "?"}: + if lowered in {"帮助", "?", "?", "help"}: return ChannelCommand(kind="help") if lowered in {"解绑", "unbind"}: return ChannelCommand(kind="unbind") for prefix in ("绑定", "bind"): if lowered.startswith(prefix): return ChannelCommand(kind="bind", query=body[len(prefix):].strip()) - if lowered.startswith("切换"): - return ChannelCommand(kind="switch", query=body[len("切换"):].strip()) + if lowered.startswith("切换") or lowered.startswith("switch"): + prefix_len = len("切换") if lowered.startswith("切换") else len("switch") + return ChannelCommand(kind="switch", query=body[prefix_len:].strip()) if body and " " not in body and "\n" not in body: # /<名字> 直达 return ChannelCommand(kind="switch", query=body) diff --git a/backend/app/channels/typing_manager.py b/backend/app/channels/typing_manager.py new file mode 100644 index 000000000..6efaa400b --- /dev/null +++ b/backend/app/channels/typing_manager.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import json +import logging +import threading +from typing import Any + +from app.channels.adapters.base import ( + ChannelCapability, + channel_capabilities_of, + get_channel_adapter, +) +from app.db import engine as default_engine +from app.db.models import ChannelBinding + +logger = logging.getLogger(__name__) + +TYPING_INTERVAL_SECONDS = 8.0 + + +def _typing_key(binding: ChannelBinding, target: dict[str, Any]) -> tuple[str, str]: + """同一绑定的同一回复目标对应一个 typing 定时器(按 target 内容区分)。""" + return binding.id, json.dumps(target, ensure_ascii=False, sort_keys=True) + + +def _binding_features_typing(binding: ChannelBinding) -> bool: + """§3.1 features.typing 开关:缺失时默认开启,保持存量行为。""" + features = (binding.config_json or {}).get("features") or {} + return bool(features.get("typing", True)) + + +def _adapter_supports_typing(adapter: object) -> bool: + """typing 能力门禁:适配器必须同时具备 send_typing 方法与 TYPING 能力声明。 + + hasattr 只是协议存在性检查;能力声明(ChannelCapabilityAdapter 协议)进一步 + 限定仅 Discord 等显式声明 TYPING 的渠道启用周期性 typing。微信虽然实现了 + send_typing(一次性 1/2 状态调用),但未声明 TYPING 能力,因此不会被本管理器 + 接管,其现有 intake 行为保持不变。 + """ + if not callable(getattr(adapter, "send_typing", None)): + return False + return ChannelCapability.TYPING in channel_capabilities_of(adapter) + + +class TypingManager: + """周期性 typing 指示器管理:入站处理期间每 TYPING_INTERVAL_SECONDS 触发一次。 + + 单进程内存实现:每个 (binding, target) 一条 daemon 定时器链,处理结束由 + end() 取消。Discord 无「停止 typing」语义,typing 在最后一次触发约 10s 后 + 自动消失,因此 end() 只需停止重复触发,不发送结束状态。 + """ + + def __init__( + self, + interval_seconds: float = TYPING_INTERVAL_SECONDS, + db_engine=None, + ) -> None: + self._interval_seconds = interval_seconds + self._db_engine = db_engine + self._timers: dict[tuple[str, str], threading.Timer] = {} + self._lock = threading.Lock() + + def begin(self, binding: ChannelBinding, target: dict[str, Any]) -> None: + """进入入站处理时启动周期性 typing;能力不满足或无 send_typing 时 no-op。""" + if not _binding_features_typing(binding): + return + try: + adapter = get_channel_adapter(binding.channel) + except ValueError: + logger.debug("typing 跳过:未注册渠道适配器 channel=%s", binding.channel) + return + if not _adapter_supports_typing(adapter): + return + key = _typing_key(binding, target) + with self._lock: + if key in self._timers: + return + timer = threading.Timer( + self._interval_seconds, + self._pulse, + args=(binding.id, target), + ) + timer.daemon = True + self._timers[key] = timer + # 处理开始先立即触发一次,短处理(<8s)也能展示 typing + self._send_pulse(binding, target) + self._timers[key].start() + + def end(self, binding: ChannelBinding, target: dict[str, Any]) -> None: + """处理完成或异常时停止周期性 typing(不发送结束状态,Discord 无此语义)。""" + if not _binding_features_typing(binding): + return + key = _typing_key(binding, target) + with self._lock: + timer = self._timers.pop(key, None) + if timer is None: + return + timer.cancel() + + def _pulse(self, binding_id: str, target: dict[str, Any]) -> None: + """定时器回调:触发一次 typing 并重排下一轮;已取消或绑定删除则不重排。""" + from sqlmodel import Session + + try: + with Session(self._db_engine or default_engine) as session: + binding = session.get(ChannelBinding, binding_id) + if binding is None: + logger.debug("typing 停止:绑定已删除 binding=%s", binding_id) + return + session.expunge(binding) + self._send_pulse(binding, target) + except Exception: + logger.exception("typing 脉冲发送失败 binding=%s", binding_id) + return + key = (binding_id, json.dumps(target, ensure_ascii=False, sort_keys=True)) + with self._lock: + if key not in self._timers: + return + self._timers[key].cancel() + timer = threading.Timer( + self._interval_seconds, + self._pulse, + args=(binding_id, target), + ) + timer.daemon = True + self._timers[key] = timer + timer.start() + + def _send_pulse(self, binding: ChannelBinding, target: dict[str, Any]) -> None: + try: + adapter = get_channel_adapter(binding.channel) + send_typing = getattr(adapter, "send_typing", None) + if not callable(send_typing): + return + send_typing(binding, target, 1) + except Exception: + logger.debug( + "渠道 typing 状态发送失败(忽略) binding=%s status=1", + binding.id, + exc_info=True, + ) + + def active_keys(self) -> list[tuple[str, str]]: + """当前活跃的 (binding_id, target) 键列表(测试/诊断用)。""" + with self._lock: + return list(self._timers) + + +# 模块级单例:intake/outbox 共用同一批定时器 +typing_manager = TypingManager() + + +def begin_typing(binding: ChannelBinding, target: dict[str, Any]) -> None: + typing_manager.begin(binding, target) + + +def end_typing(binding: ChannelBinding, target: dict[str, Any]) -> None: + typing_manager.end(binding, target) diff --git a/backend/tests/test_channel_batch_backfill_api.py b/backend/tests/test_channel_batch_backfill_api.py new file mode 100644 index 000000000..55f626e72 --- /dev/null +++ b/backend/tests/test_channel_batch_backfill_api.py @@ -0,0 +1,687 @@ +"""渠道 API 扩展测试:白名单配置更新、批处理/回填端点(功能3/4/5)。""" + +from datetime import datetime + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +import app.api.channels as channels_api +from app.channels.batch_service import BatchJob, BatchService +from app.db import get_session +from app.db.models import ( + AgentProfile, + ChannelBinding, + ChannelInboundEvent, + ChatSession, + Message, + Tenant, + User, + utc_now, +) +from app.security.auth import create_access_token + + +def _engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _client(engine) -> TestClient: + app = FastAPI() + app.include_router(channels_api.router) + + def override_session(): + with Session(engine) as db: + yield db + + app.dependency_overrides[get_session] = override_session + return TestClient(app) + + +def _seed(engine, *, channel: str = "discord") -> User: + with Session(engine) as db: + db.add(Tenant(id="tenant_a", name="A")) + owner = User( + id="user_owner", + tenant_id="tenant_a", + username="owner", + password_hash="x", + ) + other = User( + id="user_other", + tenant_id="tenant_a", + username="other", + password_hash="x", + ) + db.add(owner) + db.add(other) + db.add( + AgentProfile( + id="agent_a", + tenant_id="tenant_a", + name="Agent A", + metadata_json={"owner_user_id": owner.id}, + ) + ) + binding = ChannelBinding( + id="chan_discord", + tenant_id="tenant_a", + agent_id="agent_a", + channel=channel, + status="active", + config_json={"bot_id": "bot-123", "bot_name": "Bot"}, + created_by_user_id=owner.id, + ) + db.add(binding) + db.commit() + db.refresh(owner) + db.expunge(owner) + return owner + + +def _auth(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(user)}"} + + +# ---------- 功能5:白名单/features 配置更新 ---------- + + +def test_put_binding_updates_allowlist_and_features_with_revision_bump() -> None: + engine = _engine() + owner = _seed(engine) + client = _client(engine) + + response = client.put( + "/api/enterprise/channels/chan_discord?tenant_id=tenant_a", + json={ + "allowlist": { + "mode": "deny_all", + "guild_ids": ["guild_1"], + "channel_ids": ["chan_1", "chan_2"], + "role_ids": [], + "user_ids": ["user_1"], + "deny": ["chan_3"], + }, + "features": {"slash_commands": True, "voice": True, "backfill": False}, + }, + headers=_auth(owner), + ) + assert response.status_code == 200 + with Session(engine) as db: + binding = db.get(ChannelBinding, "chan_discord") + assert binding.config_revision == 1 + allowlist = binding.config_json["allowlist"] + assert allowlist["mode"] == "deny_all" + assert allowlist["guild_ids"] == ["guild_1"] + assert allowlist["channel_ids"] == ["chan_1", "chan_2"] + assert allowlist["user_ids"] == ["user_1"] + assert allowlist["deny"] == ["chan_3"] + features = binding.config_json["features"] + assert features["voice"] is True + assert features["backfill"] is False + # 未声明的功能保持 schema 默认值 + assert features["typing"] is True + + +def test_put_binding_allowlist_only_keeps_agents_untouched() -> None: + engine = _engine() + owner = _seed(engine) + client = _client(engine) + response = client.put( + "/api/enterprise/channels/chan_discord?tenant_id=tenant_a", + json={"allowlist": {"channel_ids": ["chan_1"]}}, + headers=_auth(owner), + ) + assert response.status_code == 200 + assert [(a["agent_id"], a["is_default"]) for a in response.json()["agents"]] == [("agent_a", True)] + with Session(engine) as db: + binding = db.get(ChannelBinding, "chan_discord") + assert binding.config_revision == 1 + assert "features" not in binding.config_json + + +def test_put_binding_allowlist_invalid_id_rejected() -> None: + engine = _engine() + owner = _seed(engine) + client = _client(engine) + response = client.put( + "/api/enterprise/channels/chan_discord?tenant_id=tenant_a", + json={"allowlist": {"channel_ids": [" "]}}, + headers=_auth(owner), + ) + assert response.status_code == 422 + + +def test_put_binding_allowlist_rejects_non_manager() -> None: + engine = _engine() + _seed(engine) + client = _client(engine) + other = User(id="user_other", tenant_id="tenant_a", username="other", password_hash="x") + response = client.put( + "/api/enterprise/channels/chan_discord?tenant_id=tenant_a", + json={"allowlist": {"channel_ids": ["chan_1"]}}, + headers=_auth(other), + ) + assert response.status_code == 403 + + +# ---------- 功能3:批处理端点 ---------- + + +def _fake_batch_service() -> tuple[BatchService, list]: + service = BatchService() + calls: list[dict] = [] + + def fake_submit(binding, tenant_id, target, items, *, thread_id=None, client_batch_id=None, db_engine=None, autostart=True): + calls.append( + { + "binding_id": binding.id, + "tenant_id": tenant_id, + "target": target, + "items": items, + "thread_id": thread_id, + "client_batch_id": client_batch_id, + } + ) + job = BatchJob( + job_id="job_batch_1", + binding_id=binding.id, + tenant_id=tenant_id, + target=target, + items=items, + thread_id=thread_id, + client_batch_id=client_batch_id, + status="done", + progress=len(items), + succeeded=len(items), + ) + with service._lock: + service._batch_jobs["job_batch_1"] = job + return "job_batch_1" + + service.submit_batch = fake_submit # type: ignore[method-assign] + return service, calls + + +def test_post_batch_submits_job_and_returns_job_id(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service, calls = _fake_batch_service() + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.post( + "/api/enterprise/channels/chan_discord/batch?tenant_id=tenant_a&channel_id=chan_100", + json={"items": ["hello", {"content": "rich", "embeds": [{"title": "t"}]}], "thread_id": "thread_9"}, + headers=_auth(owner), + ) + assert response.status_code == 200 + assert response.json() == {"job_id": "job_batch_1", "status": "done"} + assert calls == [ + { + "binding_id": "chan_discord", + "tenant_id": "tenant_a", + "target": { + "to_user_id": "", + "channel_id": "chan_100", + "guild_id": "", + "thread_id": "thread_9", + }, + "items": ["hello", {"content": "rich", "embeds": [{"title": "t"}], "files": []}], + "thread_id": "thread_9", + "client_batch_id": None, + } + ] + + +def test_post_batch_without_channel_id_400(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service, _calls = _fake_batch_service() + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.post( + "/api/enterprise/channels/chan_discord/batch?tenant_id=tenant_a", + json={"items": ["hello"]}, + headers=_auth(owner), + ) + assert response.status_code == 400 + assert "缺少目标频道" in response.json()["detail"] + + +def test_post_batch_empty_items_rejected(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service, _calls = _fake_batch_service() + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.post( + "/api/enterprise/channels/chan_discord/batch?tenant_id=tenant_a&channel_id=chan_100", + json={"items": []}, + headers=_auth(owner), + ) + assert response.status_code == 422 + + +def test_get_batch_job_reports_progress(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service = BatchService() + with service._lock: + service._batch_jobs["job_batch_1"] = BatchJob( + job_id="job_batch_1", + binding_id="chan_discord", + tenant_id="tenant_a", + target={"channel_id": "chan_100"}, + items=["a", "b"], + status="done", + progress=2, + succeeded=2, + ) + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.get( + "/api/enterprise/channels/chan_discord/batch/job_batch_1?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "done" + assert payload["progress"] == 2 + assert payload["total"] == 2 + assert payload["succeeded"] == 2 + assert payload["failed"] == 0 + assert payload["errors"] == [] + + +def test_get_batch_job_wrong_binding_404(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service, _calls = _fake_batch_service() + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.get( + "/api/enterprise/channels/chan_other/batch/job_batch_1?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 404 + + +def test_batch_endpoints_require_authentication() -> None: + engine = _engine() + client = _client(engine) + assert client.post("/api/enterprise/channels/chan_discord/batch", json={"items": ["x"]}).status_code == 401 + assert client.get("/api/enterprise/channels/chan_discord/batch/job_1").status_code == 401 + + +# ---------- 功能4:回填端点 ---------- + + +def test_post_backfill_submits_job(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service = BatchService() + calls: list[dict] = [] + + def fake_submit(binding, *, channel_id, limit=100, after=None, before=None, db_engine=None, autostart=True): + calls.append({"channel_id": channel_id, "limit": limit, "after": after, "before": before}) + from app.channels.batch_service import BackfillJob + + job = BackfillJob( + job_id="bfill_1", + binding_id=binding.id, + channel_id=channel_id, + limit=limit, + after=after, + before=before, + status="running", + ) + with service._lock: + service._backfill_jobs["bfill_1"] = job + return "bfill_1" + + service.submit_backfill = fake_submit # type: ignore[method-assign] + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.post( + "/api/enterprise/channels/chan_discord/backfill?tenant_id=tenant_a", + json={"channel_id": "chan_200", "limit": 50, "before": "msg_100"}, + headers=_auth(owner), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["job_id"] == "bfill_1" + assert payload["status"] == "running" + assert calls == [{"channel_id": "chan_200", "limit": 50, "after": None, "before": "msg_100"}] + + +def test_post_backfill_without_channel_id_400(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service = BatchService() + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.post( + "/api/enterprise/channels/chan_discord/backfill?tenant_id=tenant_a", + json={}, + headers=_auth(owner), + ) + assert response.status_code == 400 + assert "缺少目标频道" in response.json()["detail"] + + +def test_post_backfill_rejects_non_discord_binding(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine, channel="wechat") + service = BatchService() + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.post( + "/api/enterprise/channels/chan_discord/backfill?tenant_id=tenant_a", + json={"channel_id": "chan_200"}, + headers=_auth(owner), + ) + assert response.status_code == 400 + assert "不是 Discord 渠道" in response.json()["detail"] + + +def test_backfill_job_query_and_404(monkeypatch) -> None: + engine = _engine() + owner = _seed(engine) + service = BatchService() + from app.channels.batch_service import BackfillJob + + with service._lock: + service._backfill_jobs["bfill_9"] = BackfillJob( + job_id="bfill_9", + binding_id="chan_discord", + channel_id="chan_200", + status="done", + written=3, + duplicates=1, + ) + monkeypatch.setattr(channels_api, "batch_service", service) + client = _client(engine) + + response = client.get( + "/api/enterprise/channels/chan_discord/backfill/bfill_9?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["written"] == 3 + assert payload["duplicates"] == 1 + + missing = client.get( + "/api/enterprise/channels/chan_discord/backfill/bfill_missing?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert missing.status_code == 404 + + +# ---------- §4.4 回填数据 web 可见性 ---------- + + +def _seed_backfill_conversation(engine, owner: User) -> str: + """回填会话 + 对话消息 + 3 条回填事件(2 条匹配频道、1 条其他频道)。""" + with Session(engine) as db: + db.add( + ChatSession( + id="session_backfill", + tenant_id="tenant_a", + user_id=owner.id, + agent_id="agent_a", + channel="discord", + external_conv_id="discord_group_chan_200", + channel_binding_id="chan_discord", + ) + ) + db.add( + Message( + id="msg_web_1", + tenant_id="tenant_a", + session_id="session_backfill", + role="user", + content="web 对话消息", + created_at=datetime(2026, 8, 10, 10, 0, 0), + ) + ) + events = [ + # 用户历史消息 → role=user + ChannelInboundEvent( + id="chevt_backfill_1", + tenant_id="tenant_a", + binding_id="chan_discord", + channel="discord", + event_id="hist_1", + payload_json={ + "schema_version": 1, + "backfilled": True, + "message": { + "id": "hist_1", + "channel_id": "chan_200", + "guild_id": "guild_1", + "author_id": "user_1", + "content": "历史消息一", + "created_at": "2026-08-09T09:00:00+00:00", + }, + }, + target_json={ + "to_user_id": "user_1", + "channel_id": "chan_200", + "guild_id": "guild_1", + "message_id": "hist_1", + }, + status="backfilled", + created_at=utc_now(), + ), + # bot 历史消息 → role=assistant + ChannelInboundEvent( + id="chevt_backfill_2", + tenant_id="tenant_a", + binding_id="chan_discord", + channel="discord", + event_id="hist_2", + payload_json={ + "schema_version": 1, + "backfilled": True, + "message": { + "id": "hist_2", + "channel_id": "chan_200", + "guild_id": "guild_1", + "author_id": "bot-123", + "content": "bot 历史回复", + "created_at": "2026-08-09T09:05:00+00:00", + }, + }, + target_json={ + "to_user_id": "bot-123", + "channel_id": "chan_200", + "guild_id": "guild_1", + "message_id": "hist_2", + }, + status="backfilled", + created_at=utc_now(), + ), + # 其他频道回填 → 不应出现在本会话 + ChannelInboundEvent( + id="chevt_other", + tenant_id="tenant_a", + binding_id="chan_discord", + channel="discord", + event_id="hist_9", + payload_json={ + "schema_version": 1, + "backfilled": True, + "message": { + "id": "hist_9", + "channel_id": "chan_999", + "guild_id": "guild_9", + "author_id": "user_9", + "content": "其他频道消息", + "created_at": "2026-08-08T09:00:00+00:00", + }, + }, + target_json={ + "to_user_id": "user_9", + "channel_id": "chan_999", + "guild_id": "guild_9", + "message_id": "hist_9", + }, + status="backfilled", + created_at=utc_now(), + ), + ] + for event in events: + db.add(event) + db.commit() + return "session_backfill" + + +def test_list_conversation_messages_merges_backfilled_events() -> None: + """回填历史消息与对话消息统一按时间排序出现在 web 端点;其他频道不混入。""" + engine = _engine() + owner = _seed(engine) + session_id = _seed_backfill_conversation(engine, owner) + + response = _client(engine).get( + f"/api/enterprise/channels/chan_discord/conversations/{session_id}/messages?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + items = response.json() + assert [item["content"] for item in items] == ["历史消息一", "bot 历史回复", "web 对话消息"] + assert [item["role"] for item in items] == ["user", "assistant", "user"] + assert all("其他频道" not in item["content"] for item in items) + + +def test_list_conversation_messages_backfilled_without_timestamp_falls_back() -> None: + """回填消息缺 created_at 时兜底用事件写入时间,不报错。""" + engine = _engine() + owner = _seed(engine) + with Session(engine) as db: + db.add( + ChatSession( + id="session_backfill_2", + tenant_id="tenant_a", + user_id=owner.id, + agent_id="agent_a", + channel="discord", + external_conv_id="discord_group_chan_200", + channel_binding_id="chan_discord", + ) + ) + db.add( + ChannelInboundEvent( + id="chevt_no_ts", + tenant_id="tenant_a", + binding_id="chan_discord", + channel="discord", + event_id="hist_ts", + payload_json={ + "schema_version": 1, + "backfilled": True, + "message": {"id": "hist_ts", "channel_id": "chan_200", "content": "无时间戳"}, + }, + target_json={"to_user_id": "user_1", "channel_id": "chan_200", "message_id": "hist_ts"}, + status="backfilled", + created_at=datetime(2026, 8, 7, 8, 0, 0), + ) + ) + db.commit() + + response = _client(engine).get( + "/api/enterprise/channels/chan_discord/conversations/session_backfill_2/messages?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + items = response.json() + assert items == [ + { + "id": "hist_ts", + "role": "user", + "content": "无时间戳", + "created_at": "2026-08-07T08:00:00", + "attachments": None, + } + ] + + +def test_list_conversation_messages_skips_backfill_for_private_conv() -> None: + """私聊会话(external_conv_id 无 _group_ 标记)不合并回填数据。""" + engine = _engine() + owner = _seed(engine) + with Session(engine) as db: + db.add( + ChatSession( + id="session_dm", + tenant_id="tenant_a", + user_id=owner.id, + agent_id="agent_a", + channel="discord", + external_conv_id="discord_p2p_user_1", + channel_binding_id="chan_discord", + ) + ) + db.add( + ChannelInboundEvent( + id="chevt_dm_1", + tenant_id="tenant_a", + binding_id="chan_discord", + channel="discord", + event_id="dm_hist_1", + payload_json={ + "schema_version": 1, + "backfilled": True, + "message": {"id": "dm_hist_1", "channel_id": "chan_200", "content": "私聊不应出现"}, + }, + target_json={"to_user_id": "user_1", "channel_id": "chan_200", "message_id": "dm_hist_1"}, + status="backfilled", + created_at=utc_now(), + ) + ) + db.commit() + + response = _client(engine).get( + "/api/enterprise/channels/chan_discord/conversations/session_dm/messages?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + assert response.json() == [] + + +# ---------- CHANNEL_META 能力声明 ---------- + + +def test_channel_meta_declares_discord_capabilities() -> None: + engine = _engine() + owner = _seed(engine) + response = _client(engine).get( + "/api/enterprise/channels/meta?tenant_id=tenant_a", + headers=_auth(owner), + ) + assert response.status_code == 200 + discord = next(row for row in response.json() if row["channel"] == "discord") + assert set(discord["capabilities"]) == { + "slash_commands", + "threads", + "batch_send", + "backfill", + "typing", + "voice", + "rich_media", + } diff --git a/backend/tests/test_channel_batch_service.py b/backend/tests/test_channel_batch_service.py new file mode 100644 index 000000000..2a6fd1d25 --- /dev/null +++ b/backend/tests/test_channel_batch_service.py @@ -0,0 +1,414 @@ +"""批处理服务测试:token 桶限流整形、批量作业、回填作业(功能3/4)。""" + +import json +import time + +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel, Session, create_engine, select + +from app.channels.adapters.base import ChannelCapability +from app.channels.batch_service import ( + BACKFILL_DEFAULT_LIMIT, + BatchService, + TokenBucket, + _batch_idempotency_key, +) +from app.db.models import ChannelBinding, ChannelInboundEvent + + +class _BatchAdapter: + """记录 send 调用的假适配器,支持按文本触发失败与富媒体载荷记录。""" + + def __init__(self, fail_texts: set[str] | None = None) -> None: + self.sent: list[tuple[str, str, str | None]] = [] + self.payloads: list[str | None] = [] + self.fail_texts = fail_texts or set() + + def channel_capabilities(self) -> set[ChannelCapability]: + return {ChannelCapability.BATCH_SEND} + + def send( + self, + binding, + target, + text: str, + *, + idempotency_key: str | None = None, + payload_json: str | None = None, + ) -> None: + if text in self.fail_texts: + raise RuntimeError(f"模拟发送失败: {text}") + self.sent.append((binding.id, text, idempotency_key)) + self.payloads.append(payload_json) + + +class _BackfillAdapter: + def __init__(self, messages: list[dict]) -> None: + self.messages = messages + self.calls: list[tuple[str, str | None, str | None, int]] = [] + + def channel_capabilities(self) -> set[ChannelCapability]: + return {ChannelCapability.BACKFILL} + + def fetch_history(self, binding, target: dict, *, before=None, after=None, limit=None): + channel_id = str(target.get("channel_id") or "") + self.calls.append((channel_id, before, after, limit or BACKFILL_DEFAULT_LIMIT)) + return self.messages + + +def _engine(binding_channel: str = "batch_test") -> object: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add( + ChannelBinding( + id="binding_batch", + tenant_id="tenant_demo", + agent_id="agent_1", + channel=binding_channel, + status="active", + config_json={"bot_id": "bot_1"}, + ) + ) + db.commit() + return engine + + +def _binding() -> ChannelBinding: + return ChannelBinding( + id="binding_batch", + tenant_id="tenant_demo", + agent_id="agent_1", + channel="batch_test", + status="active", + ) + + +def _register_adapter(monkeypatch, adapter, channel: str = "batch_test") -> None: + import app.channels.adapters.base as base_module + + monkeypatch.setitem(base_module._adapters, channel, adapter) + + +# ---------- TokenBucket ---------- + + +def test_token_bucket_acquire_exhausts_and_blocks() -> None: + bucket = TokenBucket(capacity=2, refill_period_seconds=60.0) + assert bucket.acquire() is True + assert bucket.acquire() is True + assert bucket.acquire() is False + + +def test_token_bucket_refills_over_time() -> None: + bucket = TokenBucket(capacity=2, refill_period_seconds=0.05, refill_amount=1) + assert bucket.acquire() and bucket.acquire() + assert not bucket.acquire() + time.sleep(0.12) + assert bucket.acquire() is True + + +def test_token_bucket_wait_until_available_blocks_then_succeeds() -> None: + bucket = TokenBucket(capacity=1, refill_period_seconds=0.05, refill_amount=1) + assert bucket.acquire() is True + start = time.monotonic() + assert bucket.wait_until_available(timeout=2.0) is True + assert time.monotonic() - start >= 0.04 + + +def test_token_bucket_wait_timeout_returns_false() -> None: + bucket = TokenBucket(capacity=1, refill_period_seconds=60.0) + assert bucket.acquire() is True + assert bucket.wait_until_available(timeout=0.05) is False + + +def test_bucket_is_per_binding() -> None: + service = BatchService() + assert service.bucket_for("b1") is service.bucket_for("b1") + assert service.bucket_for("b1") is not service.bucket_for("b2") + + +# ---------- 批量作业 ---------- + + +def test_run_batch_sends_all_items_with_idempotency_keys(monkeypatch) -> None: + engine = _engine() + adapter = _BatchAdapter() + _register_adapter(monkeypatch, adapter) + service = BatchService() + binding = _binding() + job_id = service.submit_batch( + binding, "tenant_demo", {"channel_id": "chan_1"}, ["a", "b", "c"], db_engine=engine, autostart=False + ) + job = service.get_batch(job_id) + assert job is not None + service.run_batch(job_id, db_engine=engine) + + assert job.status == "done" + assert job.progress == 3 + assert job.succeeded == 3 + assert job.failed == 0 + assert [(text, key) for _, text, key in adapter.sent] == [ + ("a", _batch_idempotency_key(job_id, 0)), + ("b", _batch_idempotency_key(job_id, 1)), + ("c", _batch_idempotency_key(job_id, 2)), + ] + assert adapter.sent[0][0] == "binding_batch" + service.clear_batches() + + +def test_run_batch_single_failure_does_not_abort(monkeypatch) -> None: + engine = _engine() + adapter = _BatchAdapter(fail_texts={"b"}) + _register_adapter(monkeypatch, adapter) + service = BatchService() + job_id = service.submit_batch( + _binding(), "tenant_demo", {"channel_id": "chan_1"}, ["a", "b", "c"], db_engine=engine, autostart=False + ) + service.run_batch(job_id, db_engine=engine) + + job = service.get_batch(job_id) + assert job.status == "done" + assert job.succeeded == 2 + assert job.failed == 1 + assert len(job.errors) == 1 + assert "index=1" in job.errors[0] + service.clear_batches() + + +def test_run_batch_without_adapter_send_marks_failed(monkeypatch) -> None: + class _NoSendAdapter: + pass + + engine = _engine() + _register_adapter(monkeypatch, _NoSendAdapter()) + service = BatchService() + job_id = service.submit_batch( + _binding(), "tenant_demo", {"channel_id": "chan_1"}, ["a"], db_engine=engine, autostart=False + ) + service.run_batch(job_id, db_engine=engine) + + job = service.get_batch(job_id) + assert job.status == "failed" + assert job.errors == ["adapter_no_send"] + service.clear_batches() + + +def test_run_batch_supports_rich_payload_items(monkeypatch) -> None: + engine = _engine() + adapter = _BatchAdapter() + _register_adapter(monkeypatch, adapter) + service = BatchService() + job_id = service.submit_batch( + _binding(), + "tenant_demo", + {"channel_id": "chan_1"}, + ["plain", {"content": "rich", "embeds": [{"title": "t"}]}], + db_engine=engine, + autostart=False, + ) + service.run_batch(job_id, db_engine=engine) + + job = service.get_batch(job_id) + assert job.succeeded == 2 + # 富媒体项以 payload_json 命名参数传递,不再塞进 target + assert adapter.payloads == [ + None, + json.dumps({"embeds": [{"title": "t"}]}, ensure_ascii=False), + ] + rich_call = adapter.sent[1] + assert rich_call[1] == "rich" + service.clear_batches() + + +def test_client_batch_id_deduplicates(monkeypatch) -> None: + engine = _engine() + adapter = _BatchAdapter() + _register_adapter(monkeypatch, adapter) + service = BatchService() + first = service.submit_batch( + _binding(), + "tenant_demo", + {"channel_id": "chan_1"}, + ["a"], + client_batch_id="cb-1", + db_engine=engine, + autostart=False, + ) + second = service.submit_batch( + _binding(), + "tenant_demo", + {"channel_id": "chan_1"}, + ["a"], + client_batch_id="cb-1", + db_engine=engine, + autostart=False, + ) + assert first == second + service.clear_batches() + + +def test_clear_batches_keeps_running_jobs() -> None: + service = BatchService() + job_id = service.submit_batch( + _binding(), "tenant_demo", {"channel_id": "c"}, ["a"], autostart=False + ) + service.get_batch(job_id).status = "done" + assert service.clear_batches() == 1 + assert service.get_batch(job_id) is None + + +# ---------- 回填作业 ---------- + + +def _backfill_messages() -> list[dict]: + return [ + {"id": "msg_1", "content": "hello", "channel_id": "chan_1", "guild_id": "guild_1", "author": {"id": "user_1"}}, + {"id": "msg_2", "content": "world", "channel_id": "chan_1", "guild_id": "guild_1", "author": {"id": "user_2"}}, + ] + + +def test_run_backfill_writes_backfilled_events_without_agent(monkeypatch) -> None: + engine = _engine() + adapter = _BackfillAdapter(_backfill_messages()) + _register_adapter(monkeypatch, adapter) + service = BatchService() + job_id = service.submit_backfill( + _binding(), channel_id="chan_1", limit=100, db_engine=engine, autostart=False + ) + service.run_backfill(job_id, db_engine=engine) + + job = service.get_backfill(job_id) + assert job.status == "done" + assert job.written == 2 + assert job.duplicates == 0 + assert adapter.calls == [("chan_1", None, None, 100)] + with Session(engine) as db: + events = db.exec(select(ChannelInboundEvent).order_by(ChannelInboundEvent.event_id)).all() + assert len(events) == 2 + for event in events: + # backfilled 状态不在 durable intake 的 received 集合,不触发 agent + assert event.status == "backfilled" + assert event.target_json["message_id"] == event.event_id + assert event.target_json["channel_id"] == "chan_1" + assert event.payload_json["backfilled"] is True + service.clear_backfills() + + +def test_run_backfill_accepts_channel_inbound_objects(monkeypatch) -> None: + """真实 Discord 适配器返回 ChannelInbound(dataclass):须归一为 dict 落库。""" + from app.channels.adapters.base import ChannelInbound + + messages = [ + ChannelInbound( + channel="discord", + event_id="m_1", + from_user_id="user_1", + to_user_id="bot_1", + session_id="chan_1", + group_id="guild_1", + context_token="", + text="历史消息一", + is_group=True, + raw={"channel_id": "chan_1", "guild_id": "guild_1", "created_at": "2026-08-01T00:00:00+00:00"}, + sender_name="Alice", + ), + ChannelInbound( + channel="discord", + event_id="m_2", + from_user_id="user_2", + to_user_id="bot_1", + session_id="chan_1", + group_id="guild_1", + context_token="", + text="历史消息二", + is_group=True, + raw={"channel_id": "chan_1", "guild_id": "guild_1"}, + ), + ] + engine = _engine() + adapter = _BackfillAdapter(messages) # type: ignore[arg-type] + _register_adapter(monkeypatch, adapter) + service = BatchService() + job_id = service.submit_backfill( + _binding(), channel_id="chan_1", db_engine=engine, autostart=False + ) + service.run_backfill(job_id, db_engine=engine) + + job = service.get_backfill(job_id) + assert job.status == "done" + assert job.written == 2 + with Session(engine) as db: + events = db.exec(select(ChannelInboundEvent).order_by(ChannelInboundEvent.event_id)).all() + assert [event.event_id for event in events] == ["m_1", "m_2"] + stored = events[0].payload_json["message"] + assert stored["id"] == "m_1" + assert stored["channel_id"] == "chan_1" + assert stored["guild_id"] == "guild_1" + assert stored["author_id"] == "user_1" + assert stored["created_at"] == "2026-08-01T00:00:00+00:00" + assert events[1].payload_json["message"]["created_at"] == "" + service.clear_backfills() + + +def test_run_backfill_is_idempotent_by_message_id(monkeypatch) -> None: + engine = _engine() + adapter = _BackfillAdapter(_backfill_messages()) + _register_adapter(monkeypatch, adapter) + service = BatchService() + job_id = service.submit_backfill( + _binding(), channel_id="chan_1", db_engine=engine, autostart=False + ) + service.run_backfill(job_id, db_engine=engine) + service.run_backfill(job_id, db_engine=engine) + + job = service.get_backfill(job_id) + assert job.status == "done" + assert job.written == 2 + assert job.duplicates == 2 + with Session(engine) as db: + count = len(db.exec(select(ChannelInboundEvent)).all()) + assert count == 2 + service.clear_backfills() + + +def test_run_backfill_without_fetch_history_marks_failed(monkeypatch) -> None: + class _NoHistoryAdapter: + pass + + engine = _engine() + _register_adapter(monkeypatch, _NoHistoryAdapter()) + service = BatchService() + job_id = service.submit_backfill( + _binding(), channel_id="chan_1", db_engine=engine, autostart=False + ) + service.run_backfill(job_id, db_engine=engine) + + job = service.get_backfill(job_id) + assert job.status == "failed" + assert job.errors == ["adapter_no_fetch_history"] + service.clear_backfills() + + +def test_run_backfill_missing_binding_marks_failed(monkeypatch) -> None: + engine = _engine() + adapter = _BackfillAdapter([]) + _register_adapter(monkeypatch, adapter) + service = BatchService() + job_id = service.submit_backfill( + _binding(), channel_id="chan_1", db_engine=engine, autostart=False + ) + # 提交后删除绑定,模拟绑定已删 + with Session(engine) as db: + binding = db.get(ChannelBinding, "binding_batch") + db.delete(binding) + db.commit() + service.run_backfill(job_id, db_engine=engine) + + job = service.get_backfill(job_id) + assert job.status == "failed" + assert job.errors == ["binding_not_found"] diff --git a/backend/tests/test_channel_outbox.py b/backend/tests/test_channel_outbox.py index 5d263061b..80b1c8207 100644 --- a/backend/tests/test_channel_outbox.py +++ b/backend/tests/test_channel_outbox.py @@ -1,3 +1,4 @@ +import json import threading from datetime import timedelta @@ -34,6 +35,7 @@ def __init__(self, *, fail_times: int = 0): self.fail_times = fail_times self.sent: list[tuple[str, dict, str]] = [] self.dedupe_keys: list[str | None] = [] + self.payloads: list[str | None] = [] def send( self, @@ -42,12 +44,14 @@ def send( text: str, *, idempotency_key: str | None = None, + payload_json: str | None = None, ) -> None: self.dedupe_keys.append(idempotency_key) if self.fail_times > 0: self.fail_times -= 1 raise RuntimeError("模拟发送失败") self.sent.append((binding.id, target, text)) + self.payloads.append(payload_json) class FakeFeishuAdapter(FakeAdapter): @@ -370,6 +374,159 @@ def test_daemon_delivers_pending() -> None: assert adapter.sent == [(binding_id, {"to_user_id": "u1", "context_token": "ctx"}, "回复内容")] +# ---------- P1-1:payload_json 从 stage 到 send 的传递(功能8富媒体) ---------- + + +def _discord_channel_session(binding: ChannelBinding) -> ChatSession: + return ChatSession( + id="session_discord", + tenant_id=binding.tenant_id, + user_id="user_1", + agent_id=binding.agent_id, + channel=binding.channel, + external_conv_id="discord_p2p_duser", + channel_target_json={"channel_id": "channel-1"}, + channel_binding_id=binding.id, + channel_account_key=binding.external_account_key, + ) + + +def test_stage_stores_payload_json_from_message_metadata() -> None: + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + chat_session = _discord_channel_session(binding) + message = _assistant_message(chat_session.id, "msg_rich", content="卡片") + message.metadata_json = {"channel_payload": {"embeds": [{"title": "标题"}]}} + db.add(chat_session) + db.add(message) + db.commit() + + stage_channel_delivery(db, chat_session, message) + db.commit() + + delivery = db.exec(select(ChannelDelivery)).one() + assert delivery.payload_json == json.dumps( + {"embeds": [{"title": "标题"}]}, ensure_ascii=False + ) + + +def test_daemon_passes_payload_json_to_discord_adapter(monkeypatch) -> None: + engine = _test_engine() + adapter = FakeAdapter() + monkeypatch.setitem(adapter_registry._adapters, "discord", adapter) + payload = json.dumps({"embeds": [{"title": "标题"}]}, ensure_ascii=False) + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + delivery = _make_delivery( + db, binding, payload_json=payload, target_json={"channel_id": "channel-1"} + ) + delivery_id = delivery.id + binding_id = binding.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.payloads == [payload] + assert adapter.sent[0][0] == binding_id + + +def test_daemon_does_not_pass_payload_json_to_legacy_channels(monkeypatch) -> None: + class _LegacyAdapter(FakeAdapter): + def send( + self, + binding: ChannelBinding, + target: dict, + text: str, + *, + idempotency_key: str | None = None, + ) -> None: + super().send(binding, target, text, idempotency_key=idempotency_key) + + engine = _test_engine() + adapter = _LegacyAdapter() + register_channel_adapter("fake", adapter) + payload = json.dumps({"embeds": [{"title": "标题"}]}, ensure_ascii=False) + with Session(engine) as db: + binding = _seed_binding(db) + delivery = _make_delivery(db, binding, payload_json=payload) + delivery_id = delivery.id + binding_id = binding.id + + # 存量渠道 send 签名无 payload_json 参数:若 outbox 误传会 TypeError 导致投递失败 + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.sent == [(binding_id, {"to_user_id": "u1", "context_token": "ctx"}, "回复内容")] + + +# ---------- P1-3:voice 投递分派(功能7) ---------- + + +class _VoiceAdapter(FakeAdapter): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.voice_calls: list[tuple[str, dict, dict]] = [] + + def send_voice(self, binding: ChannelBinding, target: dict, audio: dict) -> None: + self.voice_calls.append((binding.id, dict(target), dict(audio))) + + +def test_daemon_dispatches_voice_delivery_to_send_voice(monkeypatch) -> None: + engine = _test_engine() + adapter = _VoiceAdapter() + monkeypatch.setitem(adapter_registry._adapters, "discord", adapter) + payload = json.dumps( + {"audio": {"type": "file", "file_ref": "/tmp/clip.mp3"}}, ensure_ascii=False + ) + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + delivery = _make_delivery( + db, + binding, + payload_json=payload, + delivery_kind="voice", + text="", + target_json={"voice_channel_id": "123"}, + ) + delivery_id = delivery.id + binding_id = binding.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.voice_calls == [ + (binding_id, {"voice_channel_id": "123"}, {"type": "file", "file_ref": "/tmp/clip.mp3"}) + ] + assert adapter.sent == [] + + +def test_daemon_fails_voice_delivery_for_channel_without_send_voice(monkeypatch) -> None: + engine = _test_engine() + adapter = FakeAdapter() + register_channel_adapter("fake", adapter) + settings = get_settings().model_copy(update={"channel_delivery_max_attempts": 1}) + monkeypatch.setattr("app.channels.service_outbox.get_settings", lambda: settings) + payload = json.dumps( + {"audio": {"type": "file", "file_ref": "/tmp/clip.mp3"}}, ensure_ascii=False + ) + with Session(engine) as db: + binding = _seed_binding(db) + delivery = _make_delivery(db, binding, payload_json=payload, delivery_kind="voice", text="") + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + delivery = db.get(ChannelDelivery, delivery_id) + assert delivery.status == "failed" + assert "不支持语音投递" in (delivery.last_error or "") + assert adapter.sent == [] + + def test_daemon_rejects_reply_when_session_account_does_not_match_binding() -> None: engine = _test_engine() adapter = FakeAdapter() diff --git a/backend/tests/test_channel_routing.py b/backend/tests/test_channel_routing.py index e1e34a63a..02c42004f 100644 --- a/backend/tests/test_channel_routing.py +++ b/backend/tests/test_channel_routing.py @@ -168,6 +168,18 @@ def test_parse_command_switch() -> None: assert empty.kind == "switch" and empty.query == "" +def test_parse_command_english_aliases() -> None: + """原生斜杠命令回调序列化成文本后的英文形式兼容(功能1)。""" + assert parse_command("/employee").kind == "list" + assert parse_command("/EMPLOYEE").kind == "list" + assert parse_command("/current").kind == "current" + assert parse_command("/help").kind == "help" + assert parse_command("/switch 财务").kind == "switch" + assert parse_command("/switch 财务").query == "财务" + assert parse_command("/switch").kind == "switch" + assert parse_command("/switch").query == "" + + def test_parse_command_unknown_slash_goes_help() -> None: assert parse_command("/foo bar").kind == "help" assert parse_command("/").kind == "help" diff --git a/backend/tests/test_channel_typing_manager.py b/backend/tests/test_channel_typing_manager.py new file mode 100644 index 000000000..3b41a01dc --- /dev/null +++ b/backend/tests/test_channel_typing_manager.py @@ -0,0 +1,204 @@ +"""typing_manager 周期 typing 指示器测试(功能6)。""" + +import time + +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel, Session, create_engine + +from app.channels.adapters.base import ChannelCapability +from app.channels.typing_manager import TypingManager, begin_typing, end_typing +from app.db.models import ChannelBinding + + +class _TypingAdapter: + """声明 TYPING 能力的假适配器:记录 send_typing 调用。""" + + def __init__(self) -> None: + self.typing_calls: list[tuple[str, dict, int]] = [] + + def channel_capabilities(self) -> set[ChannelCapability]: + return {ChannelCapability.TYPING} + + def send_typing(self, binding, target, status: int) -> None: + self.typing_calls.append((binding.id, dict(target), status)) + + +class _NoTypingAdapter: + """有 send_typing 但未声明 TYPING 能力(模拟微信):不应被 typing_manager 接管。""" + + def channel_capabilities(self) -> set[ChannelCapability]: + return set() + + def send_typing(self, binding, target, status: int) -> None: + raise AssertionError("未声明 TYPING 能力的渠道不应收到周期 typing") + + +class _PlainAdapter: + """完全没有 send_typing 的存量渠道(飞书/钉钉):begin 应 no-op。""" + + def send(self, *args, **kwargs) -> None: + return None + + +def _engine_with_binding(channel: str = "typing_test") -> object: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add( + ChannelBinding( + id="binding_typing", + tenant_id="tenant_demo", + agent_id="agent_1", + channel=channel, + status="active", + ) + ) + db.commit() + return engine + + +def _manager(engine, monkeypatch, adapter, channel: str = "typing_test") -> TypingManager: + import app.channels.adapters.base as base_module + + monkeypatch.setitem(base_module._adapters, channel, adapter) + manager = TypingManager(interval_seconds=0.05, db_engine=engine) + return manager + + +def _binding() -> ChannelBinding: + return ChannelBinding( + id="binding_typing", + tenant_id="tenant_demo", + agent_id="agent_1", + channel="typing_test", + status="active", + ) + + +def test_begin_sends_immediate_pulse_and_registers_timer(monkeypatch) -> None: + engine = _engine_with_binding() + adapter = _TypingAdapter() + manager = _manager(engine, monkeypatch, adapter) + binding = _binding() + + manager.begin(binding, {"channel_id": "chan_1"}) + + # begin 立即触发一次 + 定时器已注册 + assert len(adapter.typing_calls) == 1 + assert adapter.typing_calls[0][0] == "binding_typing" + assert adapter.typing_calls[0][2] == 1 + assert len(manager.active_keys()) == 1 + manager.end(binding, {"channel_id": "chan_1"}) + + +def test_begin_repeats_periodically_until_end(monkeypatch) -> None: + engine = _engine_with_binding() + adapter = _TypingAdapter() + manager = _manager(engine, monkeypatch, adapter) + binding = _binding() + target = {"channel_id": "chan_1"} + + manager.begin(binding, target) + time.sleep(0.16) + manager.end(binding, target) + # 立即 1 次 + 0.05s 周期在 0.16s 内至少再触发 2 次 + assert len(adapter.typing_calls) >= 3 + assert manager.active_keys() == [] + + +def test_end_stops_periodic_typing(monkeypatch) -> None: + engine = _engine_with_binding() + adapter = _TypingAdapter() + manager = _manager(engine, monkeypatch, adapter) + binding = _binding() + target = {"channel_id": "chan_1"} + + manager.begin(binding, target) + manager.end(binding, target) + calls_after_end = len(adapter.typing_calls) + time.sleep(0.12) + assert len(adapter.typing_calls) == calls_after_end + + +def test_end_without_begin_is_noop(monkeypatch) -> None: + manager = _manager(_engine_with_binding(), monkeypatch, _TypingAdapter()) + manager.end(_binding(), {"channel_id": "chan_1"}) + assert manager.active_keys() == [] + + +def test_begin_ignores_adapter_without_typing_capability(monkeypatch) -> None: + """微信模拟:有 send_typing 但无 TYPING 能力声明,begin 必须 no-op。""" + adapter = _NoTypingAdapter() + engine = _engine_with_binding() + manager = _manager(engine, monkeypatch, adapter) + manager.begin(_binding(), {"to_user_id": "u1", "context_token": "tok"}) + assert manager.active_keys() == [] + + +def test_begin_respects_features_typing_disabled(monkeypatch) -> None: + """§3.1 features.typing=false:即使适配器声明 TYPING,begin/end 也全部 no-op。""" + engine = _engine_with_binding() + adapter = _TypingAdapter() + manager = _manager(engine, monkeypatch, adapter) + binding = _binding() + binding.config_json = {"features": {"typing": False}} + + manager.begin(binding, {"channel_id": "chan_1"}) + assert adapter.typing_calls == [] + assert manager.active_keys() == [] + manager.end(binding, {"channel_id": "chan_1"}) + assert manager.active_keys() == [] + + +def test_begin_typing_enabled_by_default_without_features(monkeypatch) -> None: + """无 features 配置的存量绑定:typing 默认开启,行为不变。""" + engine = _engine_with_binding() + adapter = _TypingAdapter() + manager = _manager(engine, monkeypatch, adapter) + binding = _binding() + binding.config_json = {"bot_id": "bot_1"} + + manager.begin(binding, {"channel_id": "chan_1"}) + assert len(adapter.typing_calls) == 1 + manager.end(binding, {"channel_id": "chan_1"}) + assert manager.active_keys() == [] + + +def test_begin_ignores_adapter_without_send_typing(monkeypatch) -> None: + """飞书/钉钉模拟:无 send_typing 方法,begin 必须 no-op。""" + adapter = _PlainAdapter() + engine = _engine_with_binding() + manager = _manager(engine, monkeypatch, adapter) + manager.begin(_binding(), {"channel_id": "chan_1"}) + assert manager.active_keys() == [] + + +def test_begin_is_idempotent_for_same_target(monkeypatch) -> None: + engine = _engine_with_binding() + adapter = _TypingAdapter() + manager = _manager(engine, monkeypatch, adapter) + binding = _binding() + target = {"channel_id": "chan_1"} + + manager.begin(binding, target) + manager.begin(binding, target) + assert len(manager.active_keys()) == 1 + assert len(adapter.typing_calls) == 1 + manager.end(binding, target) + + +def test_convenience_functions_route_to_singleton(monkeypatch) -> None: + """模块级便捷函数复用同一单例(默认 TypingManager 实例)。""" + from app.channels import typing_manager as tm_module + + assert tm_module.typing_manager is tm_module.begin_typing.__globals__["typing_manager"] + begin_typing(_binding(), {"channel_id": "chan_1"}) + try: + # 单例默认 db_engine 可能没有该 binding,仅验证不会抛异常且能停止 + end_typing(_binding(), {"channel_id": "chan_1"}) + finally: + tm_module.typing_manager.end(_binding(), {"channel_id": "chan_1"}) diff --git a/backend/tests/test_discord_features.py b/backend/tests/test_discord_features.py new file mode 100644 index 000000000..985467047 --- /dev/null +++ b/backend/tests/test_discord_features.py @@ -0,0 +1,771 @@ +"""Discord 渠道 8 项功能扩展的适配器侧测试(波 2-A)。 + +覆盖:typing(功能6)、白名单(功能5)、线程(功能2)、回填(功能4)、 +富媒体(功能8)、原生斜杠命令(功能1)、语音能力声明(功能7)。 +""" + +from __future__ import annotations + +import asyncio +import json +import threading +from types import SimpleNamespace + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from app.channels.adapters.base import ( + ChannelCapability, + evaluate_allowlist, +) +from app.channels.adapters.discord import ( + DISCORD_API_BASE, + DiscordAdapter, + DiscordPermanentError, + DiscordStreamManager, + DiscordTransientError, + normalize_discord_message, +) +from app.channels.crypto import encrypt_channel_secret +from app.channels.service_discord_inbox import ( + discord_account_key, + stage_discord_inbound, +) +from app.channels.service_durable_inbox import StageDisposition +from app.db.models import ChannelBinding, ChannelInboundEvent, Tenant + + +def _engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _raw(**overrides): + value = { + "id": "msg-1", + "channel_id": "channel-1", + "guild_id": "guild-1", + "author_id": "user-1", + "author_name": "Alice", + "content": "hello", + "mentions": ["bot-1"], + "bot_user_id": "bot-1", + "is_group": True, + } + value.update(overrides) + return value + + +class _Response: + def __init__(self, status_code=200, payload=None): + self.status_code = status_code + self._payload = {} if payload is None else payload + + def json(self): + return self._payload + + @property + def content(self): + return self._payload if isinstance(self._payload, bytes) else json.dumps(self._payload).encode() + + +class _RoutingClient: + """按 URL 片段路由的假 httpx client;每个队列的最后一项会被重复返回。""" + + def __init__(self, routes): + self.routes = routes + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, json=None, headers=None, files=None, data=None, **_kwargs): + self.calls.append( + {"url": url, "body": json, "headers": headers or {}, "files": files, "data": data} + ) + for fragment, queue in self.routes.items(): + if fragment in url: + return queue.pop(0) if len(queue) > 1 else queue[0] + raise AssertionError(f"未预期的请求地址 {url}") + + def get(self, url, params=None, headers=None, **_kwargs): + self.calls.append({"url": url, "params": params or {}, "headers": headers or {}}) + for fragment, queue in self.routes.items(): + if fragment in url: + return queue.pop(0) if len(queue) > 1 else queue[0] + raise AssertionError(f"未预期的请求地址 {url}") + + def calls_to(self, fragment): + return [call for call in self.calls if fragment in call["url"]] + + +def _binding(**overrides): + values = { + "tenant_id": "tenant-1", + "agent_id": "agent-1", + "channel": "discord", + "status": "active", + "credentials_enc": encrypt_channel_secret("secret"), + "config_json": {"bot_id": "bot-1"}, + "external_account_key": discord_account_key("bot-1"), + "config_revision": 1, + } + values.update(overrides) + return ChannelBinding(**values) + + +def _adapter(client): + return DiscordAdapter(client_factory=lambda: client) + + +# ---------------------------------------------------------------- 功能6 typing + + +def test_send_typing_posts_to_channel(): + client = _RoutingClient({"typing": [_Response(204)]}) + _adapter(client).send_typing(_binding(), {"channel_id": "channel-1"}, 1) + call = client.calls_to("/typing")[0] + assert call["url"] == f"{DISCORD_API_BASE}/channels/channel-1/typing" + assert call["headers"]["Authorization"] == "Bot secret" + + +def test_send_typing_status_two_is_noop(): + client = _RoutingClient({}) + _adapter(client).send_typing(_binding(), {"channel_id": "channel-1"}, 2) + assert client.calls == [] + + +def test_send_typing_thread_channel(): + client = _RoutingClient({"typing": [_Response(204)]}) + _adapter(client).send_typing(_binding(), {"thread_id": "thread-9"}, 1) + call = client.calls_to("/typing")[0] + assert call["url"] == f"{DISCORD_API_BASE}/channels/thread-9/typing" + + +def test_send_typing_failure_is_logged_not_raised(caplog): + client = _RoutingClient({"typing": [_Response(500)]}) + with caplog.at_level("WARNING", logger="app.channels.adapters.discord"): + _adapter(client).send_typing(_binding(), {"channel_id": "channel-1"}, 1) + assert "typing 发送失败" in caplog.text + + +# ---------------------------------------------------------------- 功能5 白名单 + + +@pytest.mark.parametrize( + ("allowlist", "ctx", "expected"), + [ + (None, {}, True), + ({}, {}, True), + ({"mode": "allow_all"}, {}, True), + ({"mode": "weird"}, {}, True), + # deny 命中(纯 id 匹配任意维度) + ({"deny": ["channel-1"]}, {"channel_id": "channel-1"}, False), + ({"deny": ["user-1"]}, {"author_id": "user-1"}, False), + ({"deny": ["guild-1"]}, {"guild_id": "guild-1"}, False), + # deny 维度前缀 + ({"deny": ["channel:channel-1"]}, {"channel_id": "channel-1"}, False), + ({"deny": ["user:user-1"]}, {"author_id": "user-1"}, False), + ({"deny": ["guild:guild-1"]}, {"guild_id": "guild-1"}, False), + ({"deny": ["channel:other"]}, {"channel_id": "channel-1"}, True), + # deny 优先于 allow + ( + {"channel_ids": ["channel-1"], "deny": ["channel:channel-1"]}, + {"channel_id": "channel-1"}, + False, + ), + # deny_all:仅 allow 内放行 + ({"mode": "deny_all"}, {"channel_id": "channel-1"}, False), + ( + {"mode": "deny_all", "channel_ids": ["channel-1"]}, + {"channel_id": "channel-1"}, + True, + ), + ( + {"mode": "deny_all", "user_ids": ["user-1"]}, + {"author_id": "user-1"}, + True, + ), + ( + {"mode": "deny_all", "guild_ids": ["guild-1"]}, + {"guild_id": "guild-1"}, + True, + ), + # allow_all:allow 空=放行,非空=仅列表内 + ({"guild_ids": ["guild-1"]}, {"guild_id": "guild-1"}, True), + ({"guild_ids": ["guild-2"]}, {"guild_id": "guild-1"}, False), + ({"user_ids": ["user-1"]}, {"author_id": "user-1"}, True), + ({"user_ids": ["user-2"]}, {"author_id": "user-1"}, False), + ({"channel_ids": ["channel-1"]}, {"channel_id": "channel-1"}, True), + ({"channel_ids": ["channel-2"]}, {"channel_id": "channel-1"}, False), + # role_ids 首版不参与,不影响判定 + ({"role_ids": ["role-1"]}, {"channel_id": "channel-1"}, True), + ], +) +def test_evaluate_allowlist_matrix(allowlist, ctx, expected): + assert evaluate_allowlist(ctx, allowlist) is expected + + +def test_stage_discord_inbound_allowlist_rejected_is_auditable(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1", config_json={ + "bot_id": "bot-1", + "allowlist": {"mode": "allow_all", "channel_ids": ["channel-2"]}, + })) + db.commit() + inbound = normalize_discord_message(_raw(), account_scope="") + assert inbound is not None + result = stage_discord_inbound( + db_engine=db_engine, binding_id="chan-1", expected_revision=1, + bot_id="bot-1", inbound=inbound, + ) + assert result.disposition is StageDisposition.SECURITY_DROP + assert result.error_code == "allowlist_denied" + with Session(db_engine) as db: + events = db.exec(select(ChannelInboundEvent)).all() + assert len(events) == 1 + assert events[0].status == "rejected" + assert events[0].error == "allowlist_denied" + + +def test_stage_discord_inbound_allowlist_rejected_is_idempotent(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1", config_json={ + "bot_id": "bot-1", + "allowlist": {"deny": ["channel:channel-1"]}, + })) + db.commit() + inbound = normalize_discord_message(_raw(), account_scope="") + assert inbound is not None + for _ in range(2): + result = stage_discord_inbound( + db_engine=db_engine, binding_id="chan-1", expected_revision=1, + bot_id="bot-1", inbound=inbound, + ) + assert result.disposition is StageDisposition.SECURITY_DROP + with Session(db_engine) as db: + events = db.exec(select(ChannelInboundEvent)).all() + assert len(events) == 1 + + +def test_stage_discord_inbound_allowlist_allows_without_config(): + """无 allowlist 配置(存量 binding)一律放行,兼容现状。""" + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1")) + db.commit() + inbound = normalize_discord_message(_raw(), account_scope="") + assert inbound is not None + result = stage_discord_inbound( + db_engine=db_engine, binding_id="chan-1", expected_revision=1, + bot_id="bot-1", inbound=inbound, + ) + assert result.disposition is StageDisposition.STAGED + + +# ---------------------------------------------------------------- 功能2 线程 + + +def test_normalize_discord_thread_session_uses_thread_id(): + inbound = normalize_discord_message( + _raw(channel_id="thread-9", is_thread=True, thread_id="thread-9", parent_id="channel-1") + ) + assert inbound is not None + assert inbound.session_id == "thread-9" + assert inbound.group_id == "guild-1" + + +def test_stage_discord_inbound_persists_thread_and_command_fields(): + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1")) + db.commit() + inbound = normalize_discord_message( + _raw( + channel_id="thread-9", + is_thread=True, + thread_id="thread-9", + parent_id="channel-1", + content="/切换 Alice", + command="/switch", + mentions=["bot-1", "user-9"], + ), + account_scope="", + ) + assert inbound is not None + result = stage_discord_inbound( + db_engine=db_engine, binding_id="chan-1", expected_revision=1, + bot_id="bot-1", inbound=inbound, + ) + assert result.disposition is StageDisposition.STAGED + with Session(db_engine) as db: + event = db.get(ChannelInboundEvent, result.event_pk) + assert event.thread_id == "thread-9" + assert event.command == "/switch" + assert json.loads(event.mention_user_ids) == ["user-9"] + + +def test_stage_discord_inbound_thread_allowlist_uses_parent_channel(): + """线程消息按父频道判定白名单。""" + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1", config_json={ + "bot_id": "bot-1", + "allowlist": {"channel_ids": ["channel-1"]}, + })) + db.commit() + inbound = normalize_discord_message( + _raw(channel_id="thread-9", is_thread=True, thread_id="thread-9", parent_id="channel-1"), + account_scope="", + ) + assert inbound is not None + result = stage_discord_inbound( + db_engine=db_engine, binding_id="chan-1", expected_revision=1, + bot_id="bot-1", inbound=inbound, + ) + assert result.disposition is StageDisposition.STAGED + + +def test_send_prefers_target_thread_id(): + client = _RoutingClient({"messages": [_Response(200)]}) + _adapter(client).send(_binding(), {"channel_id": "channel-1", "thread_id": "thread-9"}, "hi") + call = client.calls[0] + assert call["url"] == f"{DISCORD_API_BASE}/channels/thread-9/messages" + + +# ---------------------------------------------------------------- 功能4 回填 + + +def test_fetch_history_returns_normalized_messages(): + history = [ + {"id": "m2", "channel_id": "channel-1", "guild_id": "guild-1", + "author": {"id": "bot-1", "username": "Bot"}, "content": "bot 说过的话", + "mentions": [], "attachments": []}, + {"id": "m1", "channel_id": "channel-1", "guild_id": "guild-1", + "author": {"id": "user-1", "username": "Alice"}, "content": "普通历史消息(未 @bot)", + "mentions": [], "attachments": []}, + ] + client = _RoutingClient({"messages": [_Response(200, history)]}) + result = _adapter(client).fetch_history(_binding(), {"channel_id": "channel-1"}, limit=100) + assert [item.event_id for item in result] == ["m2", "m1"] + # 回填不跳过 bot 自身消息,且不要求群聊 @bot + assert result[0].from_user_id == "bot-1" + assert result[1].text == "普通历史消息(未 @bot)" + call = client.calls_to("/messages")[0] + assert call["params"]["limit"] == 100 + assert call["headers"]["Authorization"] == "Bot secret" + + +def test_fetch_history_clamps_limit_and_passes_cursors(): + client = _RoutingClient({"messages": [_Response(200, [])]}) + _adapter(client).fetch_history( + _binding(), {"channel_id": "channel-1"}, + before="m-10", after="m-5", limit=500, + ) + call = client.calls_to("/messages")[0] + assert call["params"]["limit"] == 100 + assert call["params"]["before"] == "m-10" + assert call["params"]["after"] == "m-5" + client2 = _RoutingClient({"messages": [_Response(200, [])]}) + _adapter(client2).fetch_history(_binding(), {"channel_id": "channel-1"}, limit=0) + assert client2.calls_to("/messages")[0]["params"]["limit"] == 1 + + +def test_fetch_history_error_classification(): + with pytest.raises(DiscordPermanentError): + _adapter(_RoutingClient({"messages": [_Response(401)]})).fetch_history( + _binding(), {"channel_id": "channel-1"} + ) + with pytest.raises(DiscordTransientError): + _adapter(_RoutingClient({"messages": [_Response(429)]})).fetch_history( + _binding(), {"channel_id": "channel-1"} + ) + + +def test_fetch_history_rejects_missing_channel(): + with pytest.raises(DiscordPermanentError): + _adapter(_RoutingClient({})).fetch_history(_binding(), {}) + + +def test_normalize_include_bot_default_keeps_skipping(): + assert normalize_discord_message(_raw(author_id="bot-1")) is None + assert normalize_discord_message(_raw(author_id="bot-1"), include_bot=True) is not None + + +# ---------------------------------------------------------------- 功能8 富媒体 + + +def test_normalize_extracts_attachments(): + inbound = normalize_discord_message(_raw(attachments=[ + {"id": "att-1", "filename": "pic.png", "content_type": "image/png", + "size": 1024, "url": "https://cdn.discordapp.com/attachments/1"}, + {"id": "att-2", "filename": "doc.pdf", "content_type": "application/pdf", + "size": 2048, "url": "https://cdn.discordapp.com/attachments/2"}, + ])) + assert inbound is not None + assert [att.media_id for att in inbound.attachments] == ["att-1", "att-2"] + assert inbound.attachments[0].kind == "image" + assert inbound.attachments[0].filename == "pic.png" + assert inbound.attachments[0].download_params == {"url": "https://cdn.discordapp.com/attachments/1"} + assert inbound.attachments[1].kind == "file" + + +def test_normalize_skips_broken_attachments(): + inbound = normalize_discord_message(_raw(attachments=[ + {"id": "", "url": "https://x/1"}, + {"filename": "no-url.png"}, + "not-a-dict", + ])) + assert inbound is not None + assert inbound.attachments == [] + + +def test_download_media_fetches_url_bytes(): + client = _RoutingClient({}) + client.routes = {"https://cdn.discordapp.com/attachments/1": [_Response(200, b"\x89PNG")]} + from app.channels.adapters.base import ChannelInboundAttachment + + attachment = ChannelInboundAttachment( + media_id="att-1", kind="image", + download_params={"url": "https://cdn.discordapp.com/attachments/1"}, + ) + data = _adapter(client).download_media(_binding(), attachment) + assert data == b"\x89PNG" + assert client.calls[0]["url"] == "https://cdn.discordapp.com/attachments/1" + assert "Authorization" not in client.calls[0]["headers"] + + +def test_download_media_missing_url(): + from app.channels.adapters.base import ChannelInboundAttachment + + attachment = ChannelInboundAttachment(media_id="att-1", kind="image") + with pytest.raises(DiscordPermanentError): + _adapter(_RoutingClient({})).download_media(_binding(), attachment) + + +def test_send_payload_json_embeds(): + client = _RoutingClient({"messages": [_Response(200)]}) + _adapter(client).send( + _binding(), + {"channel_id": "channel-1"}, + "fallback", + idempotency_key="delivery-1", + payload_json=json.dumps({"content": "卡片", "embeds": [{"title": "标题", "description": "描述"}]}), + ) + assert len(client.calls) == 1 + body = client.calls[0]["body"] + assert body["content"] == "卡片" + assert body["embeds"] == [{"title": "标题", "description": "描述"}] + assert body["nonce"] + assert "fallback" not in body["content"] + + +def test_send_payload_json_embeds_truncated(): + payload = { + "embeds": [ + {"title": "t" * 500, "description": "d" * 5000, + "fields": [{"name": "n" * 500, "value": "v" * 2000}] * 30, + "footer": {"text": "f" * 3000}, "color": "not-int", "unknown": "x"}, + ] + * 12 + } + client = _RoutingClient({"messages": [_Response(200)]}) + _adapter(client).send(_binding(), {"channel_id": "channel-1"}, "", payload_json=json.dumps(payload)) + body = client.calls[0]["body"] + assert len(body["embeds"]) == 10 + embed = body["embeds"][0] + assert len(embed["title"]) == 256 + assert len(embed["description"]) == 4096 + assert len(embed["fields"]) == 25 + assert len(embed["fields"][0]["name"]) == 256 + assert len(embed["fields"][0]["value"]) == 1024 + assert len(embed["footer"]["text"]) == 2048 + assert "color" not in embed + assert "unknown" not in embed + + +def test_send_payload_json_files_uses_multipart(): + payload = { + "content": "带文件", + "files": [{"filename": "a.png", "data": "aGVsbG8=", "content_type": "image/png"}], + } + client = _RoutingClient({"messages": [_Response(200)]}) + _adapter(client).send( + _binding(), {"channel_id": "channel-1"}, "", idempotency_key="delivery-1", + payload_json=json.dumps(payload), + ) + call = client.calls[0] + assert call["files"] == [("files[0]", ("a.png", b"hello", "image/png"))] + assert "Content-Type" not in call["headers"] + inner = json.loads(call["data"]["payload_json"]) + assert inner["content"] == "带文件" + assert inner["nonce"] + assert "files" not in inner + + +def test_send_payload_json_files_oversize_is_permanent(): + payload = { + "files": [{"filename": "big.bin", "data": "x" * (8 * 1024 * 1024 + 1)}], + } + with pytest.raises(DiscordPermanentError): + _adapter(_RoutingClient({})).send( + _binding(), {"channel_id": "channel-1"}, "", payload_json=json.dumps(payload) + ) + + +def test_send_payload_json_invalid_falls_back_to_text(): + client = _RoutingClient({"messages": [_Response(200)]}) + _adapter(client).send( + _binding(), {"channel_id": "channel-1"}, "plain", + payload_json="not-json{{{", + ) + assert client.calls[0]["body"]["content"] == "plain" + + +def test_send_payload_json_empty_embeds_falls_back_to_text(): + client = _RoutingClient({"messages": [_Response(200)]}) + _adapter(client).send( + _binding(), {"channel_id": "channel-1"}, "plain", + payload_json=json.dumps({"content": "no embeds here"}), + ) + assert client.calls[0]["body"]["content"] == "plain" + + +# ---------------------------------------------------------------- 功能1 斜杠命令 + + +def test_command_text_mapping(): + text = DiscordStreamManager._command_text + assert text("employee") == "/员工" + assert text("employee", "Alice") == "/切换 Alice" + assert text("switch") == "/切换" + assert text("switch", "Alice") == "/切换 Alice" + assert text("current") == "/当前" + assert text("help") == "/帮助" + assert text("bind") == "/绑定" + assert text("bind", "CODE") == "/绑定 CODE" + assert text("unknown") == "/帮助" + + +def test_default_client_factory_is_commands_bot_with_slash_commands(): + import discord + from discord.ext import commands + + manager = DiscordStreamManager(db_engine=_engine()) + client = manager._default_client_factory("token", lambda message: None) + assert isinstance(client, commands.Bot) + assert isinstance(client, discord.Client) + names = sorted(command.name for command in client.tree.get_commands()) + assert names == ["bind", "current", "employee", "help", "switch"] + assert callable(getattr(client, "_sync_commands", None)) + # 注册事件后 on_message 仍由显式 handler 覆盖(与存量一致) + assert callable(getattr(client, "on_message", None)) + + +def test_default_client_factory_slash_commands_disabled_skips_command_tree(): + """§3.1 features.slash_commands=false:不注册命令树也不同步,文本处理保留。""" + manager = DiscordStreamManager(db_engine=_engine()) + client = manager._default_client_factory( + "token", lambda message: None, slash_commands=False + ) + assert client.tree.get_commands() == [] + # on_ready 因缺少 _sync_commands 自动跳过命令同步 + assert not callable(getattr(client, "_sync_commands", None)) + assert callable(getattr(client, "on_message", None)) + + +def test_normalize_command_message_passes_without_mention(): + inbound = normalize_discord_message( + _raw(mentions=[], content="/切换 Alice", command="/switch") + ) + assert inbound is not None + assert inbound.text == "/切换 Alice" + # 无 command 字段的群聊未 @bot 消息仍然拒绝 + assert normalize_discord_message(_raw(mentions=[])) is None + + +class _FakeClientWithSync: + """最小 discord client:start 触发 on_ready,带可计数的 _sync_commands。""" + + def __init__(self): + self._handlers = {} + self.sync_calls = 0 + self._closed = threading.Event() + + def event(self, coro): + self._handlers[coro.__name__] = coro + return coro + + async def _sync_commands(self): + self.sync_calls += 1 + + async def start(self, token: str) -> None: + if "on_ready" in self._handlers: + await self._handlers["on_ready"]() + await asyncio.to_thread(self._closed.wait) + + async def close(self) -> None: + self._closed.set() + + +def test_gateway_on_ready_syncs_commands(): + import time + + db_engine = _engine() + with Session(db_engine) as db: + db.add(Tenant(id="tenant-1", name="Tenant")) + db.add(_binding(id="chan-1")) + db.commit() + clients = [] + + def factory(token, on_message): + client = _FakeClientWithSync() + clients.append(client) + return client + + manager = DiscordStreamManager(db_engine=db_engine, client_factory=factory) + manager.ensure_binding("chan-1") + deadline = time.time() + 8.0 + while time.time() < deadline and (not clients or not clients[0].sync_calls): + time.sleep(0.02) + assert clients and clients[0].sync_calls >= 1 + manager.stop_binding("chan-1") + assert manager.wait_binding_stopped("chan-1", timeout_seconds=3.0) + + +# ---------------------------------------------------------------- 功能7 语音 + + +def test_channel_capabilities_default_excludes_voice(): + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + assert adapter.channel_capabilities() == { + ChannelCapability.SLASH_COMMANDS, + ChannelCapability.THREADS, + ChannelCapability.BACKFILL, + ChannelCapability.TYPING, + ChannelCapability.RICH_MEDIA, + } + + +def test_channel_capabilities_voice_flag(monkeypatch): + monkeypatch.setattr("app.channels.adapters.discord.shutil.which", lambda name: "/usr/bin/ffmpeg") + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + assert ChannelCapability.VOICE not in adapter.channel_capabilities(_binding()) + assert ChannelCapability.VOICE in adapter.channel_capabilities( + _binding(config_json={"bot_id": "bot-1", "features": {"voice": True}}) + ) + + +def test_channel_capabilities_voice_requires_ffmpeg(monkeypatch): + monkeypatch.setattr("app.channels.adapters.discord.shutil.which", lambda name: None) + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + assert ChannelCapability.VOICE not in adapter.channel_capabilities( + _binding(config_json={"bot_id": "bot-1", "features": {"voice": True}}) + ) + + +def test_send_voice_missing_ffmpeg_is_permanent(monkeypatch, tmp_path): + monkeypatch.setattr("app.channels.adapters.discord.shutil.which", lambda name: None) + audio_file = tmp_path / "clip.mp3" + audio_file.write_bytes(b"fake") + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + with pytest.raises(DiscordPermanentError) as excinfo: + adapter.send_voice( + _binding(), + {"voice_channel_id": "123"}, + {"type": "file", "file_ref": str(audio_file)}, + ) + assert "ffmpeg" in str(excinfo.value) + + +def test_send_voice_tts_not_configured(monkeypatch, tmp_path): + monkeypatch.setattr("app.channels.adapters.discord.shutil.which", lambda name: "/usr/bin/ffmpeg") + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + with pytest.raises(DiscordPermanentError) as excinfo: + adapter.send_voice(_binding(), {"voice_channel_id": "123"}, {"type": "tts", "text": "hi"}) + assert "TTS" in str(excinfo.value) + + +def test_send_voice_missing_file_is_permanent(monkeypatch): + monkeypatch.setattr("app.channels.adapters.discord.shutil.which", lambda name: "/usr/bin/ffmpeg") + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + with pytest.raises(DiscordPermanentError) as excinfo: + adapter.send_voice( + _binding(), + {"voice_channel_id": "123"}, + {"type": "file", "file_ref": "/nonexistent/clip.mp3"}, + ) + assert "不存在" in str(excinfo.value) + + +def test_send_voice_gateway_not_running(monkeypatch, tmp_path): + monkeypatch.setattr("app.channels.adapters.discord.shutil.which", lambda name: "/usr/bin/ffmpeg") + audio_file = tmp_path / "clip.mp3" + audio_file.write_bytes(b"fake") + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + import app.channels as channels_module + + monkeypatch.setattr( + channels_module, + "get_discord_stream_manager", + lambda: SimpleNamespace(get_loop=lambda bid: None, get_client=lambda bid: None), + ) + with pytest.raises(DiscordTransientError): + adapter.send_voice( + _binding(), + {"voice_channel_id": "123"}, + {"type": "file", "file_ref": str(audio_file)}, + ) + + +def test_play_voice_async_joins_plays_disconnects(tmp_path): + class FakeVoiceClient: + def __init__(self): + self.playing = True + self.played = False + self.disconnected = False + + def play(self, source): + self.played = True + + def is_playing(self): + if self.played and self.playing: + self.playing = False + return True + return False + + async def disconnect(self): + self.disconnected = True + + class FakeChannel: + def __init__(self, voice_client): + self._voice_client = voice_client + + async def connect(self): + return self._voice_client + + voice_client = FakeVoiceClient() + channel = FakeChannel(voice_client) + client = SimpleNamespace(get_channel=lambda cid: channel if cid == 123 else None) + audio_file = tmp_path / "clip.mp3" + audio_file.write_bytes(b"fake") + adapter = DiscordAdapter(client_factory=lambda: _RoutingClient({})) + asyncio.run(adapter._play_voice_async(client, 123, str(audio_file))) + assert voice_client.played is True + assert voice_client.disconnected is True From ebb6fbcf01ae608f318f12e1128c9d89356f8602 Mon Sep 17 00:00:00 2001 From: aurevian-biz Date: Sat, 15 Aug 2026 23:59:15 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20Discord?= =?UTF-8?q?=20=E6=B8=A0=E9=81=93=E5=8A=9F=E8=83=BD=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiscordFeatureConfig: 8 功能开关(features) + 白名单编辑器(mode/ID 列表) + 回填触发按钮 + 批量发送面板(轮询进度) - ChannelMessageAttachments: 会话消息附件卡片渲染(image/pdf/文件名) - ChannelsPage 挂载功能配置 section 与附件渲染分支 - types: ChannelAllowlistConfig/ChannelFeatureFlags 等 6 个新类型, config_json 收紧为 ChannelBindingConfigJson - i18n: 补充 33 条 Discord 功能区词条 本地验证: build 通过, vitest 26 files/97 tests 通过 --- frontend-enterprise/src/i18n/en.json | 38 ++ .../src/pages/ChannelsPage.tsx | 14 + .../channels/ChannelMessageAttachments.tsx | 57 +++ .../channels/DiscordFeatureConfig.test.tsx | 296 ++++++++++++++ .../pages/channels/DiscordFeatureConfig.tsx | 375 ++++++++++++++++++ frontend-enterprise/src/types/index.ts | 61 ++- 6 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 frontend-enterprise/src/pages/channels/ChannelMessageAttachments.tsx create mode 100644 frontend-enterprise/src/pages/channels/DiscordFeatureConfig.test.tsx create mode 100644 frontend-enterprise/src/pages/channels/DiscordFeatureConfig.tsx diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 64bd46a28..97dfcef67 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -2063,6 +2063,44 @@ "输入状态": "Typing Indicator", "凭证已配置": "Credentials configured", "重新配置": "Reconfigure", + "轮换 Token": "Rotate Token", + "Bot Token": "Bot Token", + "凭证获取路径:Discord Developer Portal → Applications → Bot → Token。需开启 Message Content Intent,否则群聊中未提及机器人的消息将无法读取。": "Get credentials: Discord Developer Portal → Applications → Bot → Token. Enable the Message Content Intent, otherwise messages that do not mention the bot in group chats cannot be read.", + "功能配置": "Feature Settings", + "功能开关": "Feature Toggles", + "原生斜杠命令": "Native slash commands", + "线程": "Threads", + "批量投递": "Batch send", + "历史回填": "History backfill", + "输入状态提示": "Typing indicator", + "富媒体(嵌入与附件)": "Rich media (embeds & attachments)", + "语音(需 ffmpeg)": "Voice (requires ffmpeg)", + "开启或关闭该渠道的 Discord 扩展能力;语音能力需要服务器安装 ffmpeg。": "Enable or disable Discord extension capabilities for this channel. Voice requires ffmpeg on the server.", + "权限白名单": "Access Allowlist", + "放行所有消息": "Allow all messages", + "仅允许列表内的消息": "Allow only listed messages", + "允许的服务器 ID": "Allowed guild IDs", + "允许的频道 ID": "Allowed channel IDs", + "允许的用户 ID": "Allowed user IDs", + "拒绝列表(每行一个 ID)": "Deny list (one ID per line)", + "每行一个 ID,留空表示不限制": "One ID per line. Leave empty for no restriction.", + "拒绝列表中的条目优先于允许列表。": "Deny entries take precedence over allow entries.", + "保存功能配置失败": "Failed to save feature settings", + "手动回填": "Backfill History", + "拉取频道历史消息并纳入会话,用于新员工上岗时了解频道上下文。": "Fetch channel history into conversations, so a newly onboarded employee understands the channel context.", + "回填已触发": "Backfill triggered", + "回填触发失败": "Failed to trigger backfill", + "批量发送": "Batch Send", + "批量发送与回填": "Batch Send & Backfill", + "目标频道 ID(可选)": "Target channel ID (optional)", + "留空时使用已保存的批处理/回填目标频道。": "Leave empty to use the saved batch/backfill target channel.", + "批量发送消息(每行一条)": "Batch messages (one per line)", + "每行一条消息,逐条投递到目标频道。": "One message per line, delivered to the target channel.", + "请先输入至少一条消息": "Enter at least one message first.", + "批处理进度:{1} / {2}": "Batch progress: {1} / {2}", + "批处理完成": "Batch complete", + "批处理失败": "Batch failed", + "服务暂不可用": "Service temporarily unavailable", "凭证获取路径:企业微信管理后台 → 智能机器人。": "Get credentials: WeCom admin console → Bot.", "请填写完整凭证": "Please complete all credential fields", "保存凭证失败": "Failed to save credentials", diff --git a/frontend-enterprise/src/pages/ChannelsPage.tsx b/frontend-enterprise/src/pages/ChannelsPage.tsx index 2f643343c..980a58595 100644 --- a/frontend-enterprise/src/pages/ChannelsPage.tsx +++ b/frontend-enterprise/src/pages/ChannelsPage.tsx @@ -46,6 +46,8 @@ import WecomSetup from './channels/WecomSetup'; import FeishuSetup from './channels/FeishuSetup'; import DingTalkSetup from './channels/DingTalkSetup'; import DiscordSetup from './channels/DiscordSetup'; +import DiscordFeatureConfig from './channels/DiscordFeatureConfig'; +import ChannelMessageAttachments from './channels/ChannelMessageAttachments'; import { getChannelPresentation } from './channelPresentation'; import { StatusBadge } from './scheduled-tasks/StatusBadge'; import { formatTime, type BadgeTone } from './scheduled-tasks/shared'; @@ -789,6 +791,17 @@ export default function ChannelsPage({ + {binding.channel === 'discord' && ( + + setBindings((current) => current.map((item) => (item.id === updated.id ? updated : item))) + } + /> + )} +
@@ -978,6 +991,7 @@ export default function ChannelsPage({ {shown.content} +
); })} diff --git a/frontend-enterprise/src/pages/channels/ChannelMessageAttachments.tsx b/frontend-enterprise/src/pages/channels/ChannelMessageAttachments.tsx new file mode 100644 index 000000000..9c211ed0e --- /dev/null +++ b/frontend-enterprise/src/pages/channels/ChannelMessageAttachments.tsx @@ -0,0 +1,57 @@ +import type { ChannelConversationMessageRead } from '../../types'; + +function formatFileSize(size: number | undefined): string { + if (size === undefined || size === null || Number.isNaN(size)) return ''; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function attachmentKindLabel(kind: string | undefined, contentType: string | undefined): string { + const ct = contentType || ''; + if ((kind || '').toLowerCase() === 'image' || ct.startsWith('image/')) return '图片'; + if ((kind || '').toLowerCase() === 'pdf' || ct === 'application/pdf' || ct.includes('pdf')) { + return 'PDF'; + } + return '文件'; +} + +export default function ChannelMessageAttachments({ + message, +}: { + message: ChannelConversationMessageRead; +}) { + const attachments = message.attachments || []; + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment, index) => { + const kindLabel = attachmentKindLabel(attachment.kind, attachment.content_type); + const sizeLabel = formatFileSize(attachment.size); + return ( +
+ + {kindLabel} + + + + {attachment.filename || kindLabel} + + + {kindLabel} + {sizeLabel ? ` · ${sizeLabel}` : ''} + {/* 演进点:后端消息查询端点暂不返回附件访问 URL,这里仅展示文件名与大小; + 待后端在 ChannelConversationAttachmentRead 中补充 url 字段后, + image 类型可改为 缩略图预览(参照 chat 会话 MessageBubble 的附件卡片)。 */} + + +
+ ); + })} +
+ ); +} diff --git a/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.test.tsx b/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.test.tsx new file mode 100644 index 000000000..1b94e95fb --- /dev/null +++ b/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.test.tsx @@ -0,0 +1,296 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + ChannelBindingRead, + ChannelBatchJobRead, + ChannelMetaRead, +} from '../../types'; +import DiscordFeatureConfig from './DiscordFeatureConfig'; + +const { notify } = vi.hoisted(() => ({ notify: { success: vi.fn(), error: vi.fn() } })); + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: (value: string) => value, + locale: 'zh-CN', + setLocale: () => {}, + toggleLocale: () => {}, + }), + I18nProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('@/components/ui/app-toast', () => ({ notify })); + +const putMock = vi.hoisted(() => vi.fn()); +const postMock = vi.hoisted(() => vi.fn()); +const getMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../api/client', () => ({ + api: { + put: (...args: unknown[]) => putMock(...args), + post: (...args: unknown[]) => postMock(...args), + get: (...args: unknown[]) => getMock(...args), + }, + TENANT_ID: 'tenant_demo', +})); + +const discordMeta: ChannelMetaRead = { + channel: 'discord', + name: 'Discord', + setup: 'credentials', + capabilities: [], +}; + +const baseBinding: ChannelBindingRead = { + id: 'chan_discord', + tenant_id: 'tenant_demo', + agent_id: 'agent_a', + channel: 'discord', + status: 'active', + config_revision: 0, + connected: false, + agents: [], + created_at: '2026-08-07T00:00:00Z', + updated_at: '2026-08-07T00:00:00Z', +}; + +const binding = (overrides: Partial = {}): ChannelBindingRead => ({ + ...baseBinding, + ...overrides, +}); + +function renderConfig( + b: ChannelBindingRead, + meta: ChannelMetaRead = discordMeta, + onChanged: () => void = () => {}, +) { + return render(); +} + +beforeEach(() => { + putMock.mockReset(); + postMock.mockReset(); + getMock.mockReset(); + notify.success.mockReset(); + notify.error.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('DiscordFeatureConfig', () => { + it('renders feature toggles defaulting to on except voice, plus allowlist fields', () => { + renderConfig(binding()); + + expect(screen.getByText('功能配置')).toBeTruthy(); + expect(screen.getByText('原生斜杠命令')).toBeTruthy(); + expect(screen.getByText('语音(需 ffmpeg)')).toBeTruthy(); + expect(screen.getByText('权限白名单')).toBeTruthy(); + expect(screen.getByLabelText('允许的服务器 ID')).toBeTruthy(); + expect(screen.getByLabelText('允许的频道 ID')).toBeTruthy(); + expect(screen.getByLabelText('允许的用户 ID')).toBeTruthy(); + expect(screen.getByLabelText('拒绝列表(每行一个 ID)')).toBeTruthy(); + + const switches = screen.getAllByRole('switch'); + expect(switches).toHaveLength(7); + // voice 默认关闭,其余默认开启 + expect(switches[5].getAttribute('data-state')).toBe('unchecked'); + expect(switches[0].getAttribute('data-state')).toBe('checked'); + }); + + it('restricts toggles to meta capabilities when declared', () => { + renderConfig( + binding(), + { ...discordMeta, capabilities: ['slash_commands', 'voice'] }, + ); + + expect(screen.getByText('原生斜杠命令')).toBeTruthy(); + expect(screen.getByText('语音(需 ffmpeg)')).toBeTruthy(); + expect(screen.queryByText('线程')).toBeNull(); + expect(screen.queryByText('富媒体(嵌入与附件)')).toBeNull(); + }); + + it('pre-fills allowlist and feature state from binding config_json', () => { + renderConfig( + binding({ + config_json: { + features: { slash_commands: false, voice: true }, + allowlist: { + mode: 'deny_all', + guild_ids: ['111'], + channel_ids: ['222'], + user_ids: ['333'], + deny: ['444', '555'], + }, + }, + }), + ); + + const switches = screen.getAllByRole('switch'); + expect(switches[0].getAttribute('data-state')).toBe('unchecked'); + expect(switches[5].getAttribute('data-state')).toBe('checked'); + expect((screen.getByLabelText('允许的服务器 ID') as HTMLTextAreaElement).value).toBe('111'); + expect((screen.getByLabelText('允许的频道 ID') as HTMLTextAreaElement).value).toBe('222'); + expect((screen.getByLabelText('允许的用户 ID') as HTMLTextAreaElement).value).toBe('333'); + expect((screen.getByLabelText('拒绝列表(每行一个 ID)') as HTMLTextAreaElement).value).toBe( + '444\n555', + ); + }); + + it('saves features and allowlist through the binding update endpoint', async () => { + putMock.mockResolvedValue(binding()); + renderConfig(binding()); + + fireEvent.click(screen.getAllByRole('switch')[5]); // 打开 voice + fireEvent.change(screen.getByLabelText('允许的服务器 ID'), { + target: { value: 'guild-1\nguild-2' }, + }); + fireEvent.change(screen.getByLabelText('拒绝列表(每行一个 ID)'), { + target: { value: 'deny-channel' }, + }); + fireEvent.click(screen.getByText('保存')); + + await waitFor(() => { + expect(putMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord?tenant_id=tenant_demo', + { + tenant_id: 'tenant_demo', + // batch_send 为前端保留开关,不随保存下发(后端 ChannelFeaturesConfig 无该键) + features: expect.objectContaining({ + slash_commands: true, + voice: true, + backfill: true, + rich_media: true, + }), + allowlist: { + mode: 'allow_all', + guild_ids: ['guild-1', 'guild-2'], + channel_ids: [], + user_ids: [], + deny: ['deny-channel'], + }, + }, + ); + expect((putMock.mock.calls[0][1] as Record).features).not.toHaveProperty( + 'batch_send', + ); + }); + await waitFor(() => expect(notify.success).toHaveBeenCalledWith('已保存')); + }); + + it('switches to strict mode and reports save failure', async () => { + putMock.mockRejectedValue(new Error('无有效更新内容')); + renderConfig(binding()); + + fireEvent.click(screen.getByText('仅允许列表内的消息')); + fireEvent.click(screen.getByText('保存')); + + await waitFor(() => expect(notify.error).toHaveBeenCalledWith('无有效更新内容')); + }); + + it('triggers a manual backfill and reports success', async () => { + postMock.mockResolvedValue({ job_id: 'bf-1', status: 'pending' }); + renderConfig(binding()); + + fireEvent.click(screen.getByText('手动回填')); + + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord/backfill', + { tenant_id: 'tenant_demo' }, + ); + }); + await waitFor(() => expect(notify.success).toHaveBeenCalledWith('回填已触发')); + }); + + it('passes the target channel id to backfill when provided', async () => { + postMock.mockResolvedValue({ job_id: 'bf-1', status: 'pending' }); + renderConfig(binding()); + + fireEvent.change(screen.getByLabelText('目标频道 ID(可选)'), { + target: { value: 'channel-9' }, + }); + fireEvent.click(screen.getByText('手动回填')); + + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord/backfill', + { tenant_id: 'tenant_demo', channel_id: 'channel-9' }, + ); + }); + }); + + it('degrades gracefully when the backfill endpoint is unavailable', async () => { + postMock.mockRejectedValue(new Error('Not Found')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + renderConfig(binding()); + + fireEvent.click(screen.getByText('手动回填')); + + await waitFor(() => expect(notify.error).toHaveBeenCalledWith('Not Found')); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('posts batch items, polls the job and reports progress', async () => { + postMock.mockResolvedValue({ job_id: 'job-1', status: 'pending' } satisfies ChannelBatchJobRead); + getMock.mockResolvedValueOnce({ + job_id: 'job-1', + status: 'running', + progress: 1, + total: 2, + } satisfies ChannelBatchJobRead); + + renderConfig(binding()); + + fireEvent.change(screen.getByLabelText('批量发送消息(每行一条)'), { + target: { value: '第一条\n第二条' }, + }); + fireEvent.click(screen.getByText('发送')); + + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith('/api/enterprise/channels/chan_discord/batch', { + tenant_id: 'tenant_demo', + items: ['第一条', '第二条'], + }); + }); + await waitFor(() => { + expect(getMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord/batch/job-1?tenant_id=tenant_demo', + ); + }); + await waitFor(() => expect(screen.getByText('批处理进度:1 / 2')).toBeTruthy()); + }); + + it('passes the target channel id as a query parameter for batch', async () => { + postMock.mockResolvedValue({ job_id: 'job-2', status: 'done' } satisfies ChannelBatchJobRead); + renderConfig(binding()); + + fireEvent.change(screen.getByLabelText('目标频道 ID(可选)'), { + target: { value: 'channel-7' }, + }); + fireEvent.change(screen.getByLabelText('批量发送消息(每行一条)'), { + target: { value: 'hello' }, + }); + fireEvent.click(screen.getByText('发送')); + + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord/batch?channel_id=channel-7', + { tenant_id: 'tenant_demo', items: ['hello'] }, + ); + }); + }); + + it('refuses an empty batch', () => { + renderConfig(binding()); + + fireEvent.click(screen.getByText('发送')); + + expect(notify.error).toHaveBeenCalledWith('请先输入至少一条消息'); + expect(postMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.tsx b/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.tsx new file mode 100644 index 000000000..be05ad636 --- /dev/null +++ b/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.tsx @@ -0,0 +1,375 @@ +import { useEffect, useRef, useState } from 'react'; +import { notify } from '@/components/ui/app-toast'; + +import { Input, RadioGroup, RadioGroupItem, Switch, Textarea } from '@/components/ui'; +import { Button as UIButton } from '@/components/ui/button'; +import IconActionToggle from '../../assets/icons/action-toggle.svg?react'; +import { api, TENANT_ID } from '../../api/client'; +import type { + ChannelAllowlistConfig, + ChannelBatchJobRead, + ChannelBindingRead, + ChannelFeatureFlags, + ChannelMetaRead, +} from '../../types'; + +const PRIMARY_BUTTON_CLASS = + 'h-8 gap-1 rounded-[10px] bg-[#18181a] px-5 text-[12px] font-normal text-white hover:bg-[#303030]'; +const OUTLINE_BUTTON_CLASS = + 'h-8 gap-1 rounded-[10px] border-[#e3e7f1] px-5 text-[12px] font-normal text-[#464c5e] hover:bg-[#f6f6f6] hover:text-[#18181a]'; + +const DISCORD_FEATURE_FLAGS: Array<{ + key: keyof ChannelFeatureFlags; + label: string; + defaultOn: boolean; +}> = [ + { key: 'slash_commands', label: '原生斜杠命令', defaultOn: true }, + { key: 'threads', label: '线程', defaultOn: true }, + { key: 'batch_send', label: '批量投递', defaultOn: true }, + { key: 'backfill', label: '历史回填', defaultOn: true }, + { key: 'typing', label: '输入状态提示', defaultOn: true }, + // voice 需要服务端安装 ffmpeg,默认关闭 + { key: 'voice', label: '语音(需 ffmpeg)', defaultOn: false }, + { key: 'rich_media', label: '富媒体(嵌入与附件)', defaultOn: true }, +]; + +function defaultFeatureFlags(): ChannelFeatureFlags { + return Object.fromEntries( + DISCORD_FEATURE_FLAGS.map((flag) => [flag.key, flag.defaultOn]), + ) as ChannelFeatureFlags; +} + +// meta.capabilities 为空(旧后端未声明)时回退到默认全集,保证 UI 完整可用 +function visibleFeatureKeys(meta: ChannelMetaRead | undefined): Set { + const capabilities = meta?.capabilities || []; + if (capabilities.length === 0) { + return new Set(DISCORD_FEATURE_FLAGS.map((flag) => flag.key)); + } + return new Set(capabilities); +} + +function parseIdLines(text: string): string[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function IdTextarea({ + label, + value, + onChange, + hint, +}: { + label: string; + value: string; + onChange: (next: string) => void; + hint?: string; +}) { + return ( +