diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index 24b64a7d..9f8b5b15 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, @@ -45,6 +49,7 @@ ChannelQRCodeRead, ChannelQRCodeStatusRead, DingTalkCredentialsRequest, + DiscordCredentialsRequest, FeishuCredentialsRequest, MyIdentityBindingRead, WeComCredentialsRequest, @@ -119,7 +124,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 # 渠道描述:前端接入页据此渲染渠道卡片与凭证表单,新渠道只加条目不动页面骨架 @@ -168,6 +173,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": [], + }, ] @@ -1084,6 +1098,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/__init__.py b/backend/app/channels/__init__.py index ee2da256..74e2c0b8 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/adapters/discord.py b/backend/app/channels/adapters/discord.py new file mode 100644 index 00000000..80be7831 --- /dev/null +++ b/backend/app/channels/adapters/discord.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +import hashlib +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 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 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})" + ) + 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 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) + + 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: + 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) + + register_event = getattr(client, "event", None) + if callable(register_event): + async def _on_ready() -> None: + await mark_connected(True) + + _on_ready.__name__ = "on_ready" + register_event(_on_ready) + else: + await mark_connected(True) + + 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: + 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: + 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() + 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 00000000..8be3cd07 --- /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/schema.py b/backend/app/channels/schema.py index 2856015d..d3db9d57 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/app/channels/service_discord_inbox.py b/backend/app/channels/service_discord_inbox.py new file mode 100644 index 00000000..6e4fcfbd --- /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") diff --git a/backend/app/channels/service_identity.py b/backend/app/channels/service_identity.py index 53b703fa..6608240e 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 12c7c85b..1669fc22 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")) @@ -982,7 +984,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(), ) @@ -1078,7 +1080,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) @@ -1176,6 +1178,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") @@ -1225,7 +1238,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), @@ -1267,11 +1280,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 diff --git a/backend/app/channels/service_outbox.py b/backend/app/channels/service_outbox.py index fe7f6941..0beb45bb 100644 --- a/backend/app/channels/service_outbox.py +++ b/backend/app/channels/service_outbox.py @@ -192,6 +192,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: @@ -785,6 +787,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/pyproject.toml b/backend/pyproject.toml index 2ab556b0..c79e77ce 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] diff --git a/backend/tests/test_channel_discord.py b/backend/tests/test_channel_discord.py new file mode 100644 index 00000000..90b23a60 --- /dev/null +++ b/backend/tests/test_channel_discord.py @@ -0,0 +1,487 @@ +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 + +from app.channels.adapters.discord import ( + DISCORD_API_BASE, + DiscordAdapter, + DiscordPermanentError, + DiscordStreamManager, + 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" + assert call["body"].get("nonce") # idempotency_key 映射为 nonce + + +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_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"})]} + ) + 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 + + +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): + # 与真实 discord.py Client.event() 语义一致:setattr(self, coro.__name__, coro) + self._handlers[coro.__name__] = 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) + + +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 + + +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 diff --git a/backend/tests/test_channel_outbox.py b/backend/tests/test_channel_outbox.py index 389eaa43..8b3d7cfb 100644 --- a/backend/tests/test_channel_outbox.py +++ b/backend/tests/test_channel_outbox.py @@ -1273,6 +1273,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 重置陈旧阈值 ---------- @@ -1457,3 +1495,121 @@ 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" + + +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" diff --git a/backend/tests/test_discord_api.py b/backend/tests/test_discord_api.py new file mode 100644 index 00000000..3e6d9b87 --- /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 diff --git a/backend/uv.lock b/backend/uv.lock index fed03e88..f9863cd2 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" }, diff --git a/frontend-enterprise/src/pages/ChannelsPage.tsx b/frontend-enterprise/src/pages/ChannelsPage.tsx index 13249d1c..2f643343 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 00000000..5bcdfc16 --- /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 00000000..6bbe7c86 --- /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}>保存 +
+
+ ); +}