diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index 138fc9a4..1b8d94ff 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 @@ -23,13 +24,23 @@ DingTalkPermanentError, validate_dingtalk_credentials, ) +from app.channels.adapters.discord import ( + DiscordPermanentError, + validate_discord_credentials, +) from app.channels.adapters.feishu import ( FeishuPermanentError, 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, @@ -46,6 +57,7 @@ ChannelQRCodeRead, ChannelQRCodeStatusRead, DingTalkCredentialsRequest, + DiscordCredentialsRequest, FeishuCredentialsRequest, MyIdentityBindingRead, WeComCredentialsRequest, @@ -121,7 +133,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 # 渠道描述:前端接入页据此渲染渠道卡片与凭证表单,新渠道只加条目不动页面骨架 @@ -170,6 +182,24 @@ 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": [ + "slash_commands", + "threads", + "auto_thread", + "batch_send", + "backfill", + "typing", + "voice", + "rich_media", + ], + }, ] @@ -490,7 +520,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="无有效更新内容") if request.agents is not None and binding.team_id: # 团队绑定的接待员工由团队现任 TL 决定,不允许整表替换员工挂载 @@ -542,6 +577,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) @@ -1110,6 +1159,225 @@ 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) + + +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(...), @@ -1345,13 +1613,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, @@ -1371,3 +1639,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/api/chat.py b/backend/app/api/chat.py index fd0226dd..9ad61198 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -19,7 +19,7 @@ from starlette.background import BackgroundTask from app.agents.branching import model_for_agent, visible_published_skills -from app.channels.service_outbox import stage_channel_delivery +from app.channels.service_outbox import stage_channel_delivery, stage_user_message_mirror from app.core import AgentLoop from app.core.cancellation import cancel_chat_turn from app.core.capability_manifest import CapabilityManifestBuilder @@ -652,6 +652,8 @@ def _maybe_handle_scheduled_task_request( created_at=now, ) db.add(user_message) + if request.channel == "web": + stage_user_message_mirror(db, chat_session, user_message, web_origin=True) draft_payload = draft.model_dump(mode="json") db.add( AgentEvent( 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/base.py b/backend/app/channels/adapters/base.py index f190f407..ac64ee15 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/channels/adapters/discord.py b/backend/app/channels/adapters/discord.py new file mode 100644 index 00000000..a8153688 --- /dev/null +++ b/backend/app/channels/adapters/discord.py @@ -0,0 +1,1067 @@ +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, 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 + +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*") + +DISCORD_TYPING_API = f"{DISCORD_API_BASE}/channels/{{channel_id}}/typing" +DISCORD_THREADS_API = f"{DISCORD_API_BASE}/channels/{{channel_id}}/threads" + +# 出站富媒体限制(与 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 发送失败的基类,默认可重试。""" + + retryable = True + + +class DiscordPermanentError(DiscordSendError): + """凭证失效/权限不足等重试无意义的错误。""" + + retryable = False + + +class DiscordTransientError(DiscordSendError): + """网络抖动/限流/服务端错误等可重试错误。""" + + retryable = True + + +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 + 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 + # 忽略机器人自己发的消息;回填需要看到 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 require_mention and not command 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() + # 线程消息的会话锚点=线程自身 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 频道稳定关联。 + 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(), + 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 {}) + 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, + payload_json: str | None = None, + ) -> None: + """出站投递:纯文本分片发送;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) + headers = { + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + } + rich = self._parse_rich_payload(payload_json) + try: + with self._client_factory() as client: + 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 create_thread(self, binding: ChannelBinding, target: dict[str, Any], name: str) -> str: + """在 target 指定频道创建公开线程(GUILD_PUBLIC_THREAD),返回线程 ID。 + + 供 outbox 自动建线程链路调用;name 裁剪到 Discord 100 字符上限, + 错误分类复用 _raise_for_status(401/403/4xx 永久,429/5xx 可重试)。 + """ + 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}"} + try: + with self._client_factory() as client: + response = client.post( + DISCORD_THREADS_API.format(channel_id=channel_id), + json={"name": str(name)[:100], "type": 11}, + headers=headers, + ) + self._raise_for_status(response, "创建线程") + thread_id = str(response.json().get("id") or "").strip() + except DiscordSendError: + raise + except (httpx.HTTPError, ValueError) as exc: + raise DiscordTransientError(str(exc)) from exc + if not thread_id: + raise DiscordPermanentError("Discord 创建线程未返回线程 ID") + return thread_id + + 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 + + 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._loops: dict[str, Any] = {} + self._clients: dict[str, Any] = {} + 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, + ) + 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, + # 仅默认工厂支持开关参数;自定义工厂签名保持 (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: + 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, *, slash_commands: bool = True): + import discord + from discord.ext import commands + + intents = discord.Intents.default() + intents.message_content = True + # 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) + + _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 + + @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 + + 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) + + register_event = getattr(client, "event", 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) + 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: + self._clients.pop(binding_id, None) + 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/batch_service.py b/backend/app/channels/batch_service.py new file mode 100644 index 00000000..fe3b278d --- /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 new file mode 100644 index 00000000..797dffee --- /dev/null +++ b/backend/app/channels/discord_runtime.py @@ -0,0 +1,87 @@ +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 [] + 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 "", + "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, + } + 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 6a0c6bfc..9d65f674 100644 --- a/backend/app/channels/schema.py +++ b/backend/app/channels/schema.py @@ -1,13 +1,74 @@ 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, Team, 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 + # 自动建线程默认关闭,保存存量行为(未开启时不在新会话自动建线程) + auto_thread: bool = False + 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 # 与 team_id 互斥:挂员工集(现状)或绑一个团队,二选一 @@ -32,6 +93,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): @@ -62,6 +127,7 @@ class ChannelBindingRead(BaseModel): created_by_name: Optional[str] = None agents: list[ChannelBindingAgentRead] = [] auto_route: bool = True + config_json: Optional[dict] = None created_at: str updated_at: str @@ -110,6 +176,11 @@ class DingTalkCredentialsRequest(BaseModel): client_secret: str +class DiscordCredentialsRequest(BaseModel): + tenant_id: str + bot_token: str + + class ChannelCredentialFieldRead(BaseModel): key: str label: str @@ -133,6 +204,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 +276,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 @@ -259,6 +400,7 @@ def channel_binding_read(db: Session, binding: ChannelBinding) -> ChannelBinding created_by_name=channel_binding_creator_name(db, binding), agents=channel_binding_agents_read(db, binding), auto_route=(binding.config_json or {}).get("auto_route") is not False, + config_json=binding.config_json, created_at=binding.created_at.isoformat(), updated_at=binding.updated_at.isoformat(), ) @@ -272,6 +414,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 new file mode 100644 index 00000000..bd11b766 --- /dev/null +++ b/backend/app/channels/service_discord_inbox.py @@ -0,0 +1,169 @@ +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, 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 + + +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 {} + 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, + "channel_id": str(raw.get("channel_id") or "").strip(), + "guild_id": str(raw.get("guild_id") or "").strip(), + "message_id": inbound.event_id, + } + # 线程消息记录 thread_id,供 outbox 自动建线程时识别已在线程内的会话; + # 仅非空时写入,避免 "thread_id": None 噪音污染普通频道目标。 + thread_id = _clean(raw.get("thread_id")) + if thread_id: + target["thread_id"] = thread_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", + thread_id=_clean(raw.get("thread_id")), + mention_user_ids=_mention_ids(raw, bot_id), + command=_clean(raw.get("command")), + ) + 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") + + +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_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 42da848e..59a78d72 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 ( @@ -173,7 +174,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( @@ -453,6 +454,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")) @@ -1024,6 +1027,8 @@ def process_inbound( interaction_mode=interaction_mode, ) _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: response = AgentLoop(db).handle_turn(request) except Exception as exc: @@ -1041,6 +1046,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() @@ -1087,7 +1093,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(), ) @@ -1183,7 +1189,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) @@ -1281,6 +1287,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") @@ -1330,7 +1347,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), @@ -1372,11 +1389,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 d6e5ef01..750f10b9 100644 --- a/backend/app/channels/service_outbox.py +++ b/backend/app/channels/service_outbox.py @@ -1,8 +1,13 @@ from __future__ import annotations +import base64 +import json import logging +import mimetypes import threading from datetime import timedelta +from pathlib import PosixPath +from typing import Any from sqlalchemy import func, or_, update from sqlmodel import Session, select @@ -21,6 +26,7 @@ utc_now, ) from app.channels.adapters.base import channel_reaction_token +from app.channels.adapters.discord import DiscordPermanentError, DiscordTransientError from app.channels.service_durable_inbox import reaction_target from app.channels.service_identity import external_account_scope from app.session.origin import PILOTDECK_GROUP_CHAT_CHANNEL @@ -37,6 +43,73 @@ _delivery_stop = threading.Event() _FEISHU_DEDUP_RECOVERY_SECONDS = 55 * 60 _NON_DELIVERY_CHANNELS = {"public_api", PILOTDECK_GROUP_CHAT_CHANNEL} +# harness 产物桥接为 discord 附件载荷的上限:与 DiscordAdapter._MAX_ATTACHMENT_BYTES 对齐 +_DISCORD_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024 +_MAX_HARNESS_ARTIFACT_FILES = 10 + + +def _channel_payload_from_harness_artifacts( + db: Session, + chat_session: ChatSession, + message: Message, +) -> dict[str, list[dict[str, str]]] | None: + """把 agent 运行生成的 harness 产物桥接为 discord 的 channel_payload.files 载荷。 + + 生成侧补齐(功能8,§4.8 D8-1):生成方未写 channel_payload 时,把 + assistant_metadata.harness_artifacts 中的 workspace_file 转成 base64 附件, + 使 Discord 富媒体路径(embeds/files)真正生效。任一文件超限或读取失败都 + 静默跳过——纯降级,绝不让渠道投递登记因产物问题失败。 + """ + artifacts = (message.metadata_json or {}).get("harness_artifacts") + if not isinstance(artifacts, list) or not artifacts: + return None + files: list[dict[str, str]] = [] + for entry in artifacts[:_MAX_HARNESS_ARTIFACT_FILES]: + if not isinstance(entry, dict) or entry.get("type") != "workspace_file": + continue + raw_path = str(entry.get("path") or "").strip() + task_frame_id = str(entry.get("task_frame_id") or "").strip() + size = entry.get("size") + if ( + not raw_path + or not task_frame_id + or not isinstance(size, int) + or size < 0 + ): + continue + if size > _DISCORD_MAX_ATTACHMENT_BYTES: + continue + # app.core.harness_session_cleanup 会经 app.core 触发本模块导入, + # 桥接是低频路径,函数内延迟导入避免模块级循环依赖。 + from app.core.harness_session_cleanup import harness_task_workspace_path + from app.harness.artifacts import HarnessArtifactAccessError, open_harness_artifact + + try: + workspace_root = harness_task_workspace_path( + tenant_id=chat_session.tenant_id, + session_id=chat_session.id, + task_frame_id=task_frame_id, + db=db, + ) + opened = open_harness_artifact(workspace_root, raw_path) + data = b"".join(opened.iter_bytes()) + except (HarnessArtifactAccessError, OSError): + logger.warning( + "harness 产物桥接跳过:文件不可读 path=%s frame=%s session=%s", + raw_path, + task_frame_id, + chat_session.id, + ) + continue + content_type = mimetypes.guess_type(raw_path)[0] or "application/octet-stream" + files.append( + { + "filename": PosixPath(raw_path).name, + "data": base64.b64encode(data).decode("ascii"), + "content_type": content_type, + } + ) + return {"files": files} if files else None def _stage_failed_delivery( @@ -128,6 +201,82 @@ def _find_active_binding_for_agent(db: Session, chat_session: ChatSession) -> Ch return None +def _valid_delivery_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")) + + +def stage_user_message_mirror( + db: Session, + chat_session: ChatSession, + message: Message, + *, + web_origin: bool, +) -> None: + """把 Web 端提问镜像登记为渠道 outbox 投递(随主事务提交,不单独 commit)。 + + 与 stage_channel_delivery 的区别: + - 仅 web_origin=True(Web 来源提问)时登记,渠道自身入站一律跳过,避免回声; + - 仅在会话已完整锚定渠道(binding + account_key + target)时登记,纯 Web + 会话无锚定自然跳过; + - 任何校验失败都静默跳过并只记日志,绝不让镜像影响 Web 主流程; + - 投递目标直接取会话锚定(channel_target_json),不走 feishu 事件上下文 + 推导(Web 提问没有对应 ChannelInboundEvent)。 + 投递端无需改动:kind=user_mirror 走 _deliver_one_locked 的通用发送分支, + 且不会被误判为最终回复(is_final 只认 reply/error_notice)。 + """ + if not web_origin: + return + try: + channel = str(getattr(chat_session, "channel", None) or "").strip() + if not channel or channel in _NON_DELIVERY_CHANNELS: + return + text = str(message.content or "").strip() + if not text: + return + binding_id = str(getattr(chat_session, "channel_binding_id", None) or "").strip() + account_key = str(getattr(chat_session, "channel_account_key", None) or "").strip() + target = dict(chat_session.channel_target_json or {}) + if not binding_id or not account_key or not target: + return + binding = db.get(ChannelBinding, binding_id) + if ( + not binding + or binding.status != "active" + or binding.tenant_id != chat_session.tenant_id + or binding.channel != channel + or account_key != binding.external_account_key + ): + return + if not _valid_delivery_target(channel, target): + return + existing = db.exec( + select(ChannelDelivery).where(ChannelDelivery.idempotency_key == message.id) + ).first() + if existing: + return + db.add( + ChannelDelivery( + tenant_id=chat_session.tenant_id, + binding_id=binding.id, + session_id=chat_session.id, + message_id=message.id, + target_json=target, + kind="user_mirror", + text=text, + status="pending", + next_attempt_at=utc_now(), + idempotency_key=message.id, + ) + ) + except Exception: + logger.exception("用户消息镜像登记失败 session=%s", getattr(chat_session, "id", None)) + + def stage_channel_delivery(db: Session, chat_session: ChatSession, message: Message) -> None: """把 assistant 回复登记为渠道 outbox 投递(随主事务提交,不单独 commit)。 @@ -191,11 +340,7 @@ def stage_channel_delivery(db: Session, chat_session: ChatSession, message: Mess ).first() if existing: return - if binding.channel == "feishu": - valid_target = bool(target.get("message_id") or target.get("receive_id")) - else: - valid_target = bool(target.get("to_user_id") and target.get("context_token")) - if not valid_target: + if not _valid_delivery_target(binding.channel, target): _stage_failed_delivery( db, chat_session, @@ -205,6 +350,16 @@ def stage_channel_delivery(db: Session, chat_session: ChatSession, message: Mess error="delivery_target_missing", ) return + # 富媒体结构化载荷(功能8,§4.8 D8-1):优先取生成方写入的 channel_payload + # 键(embeds/files);discord 且无显式载荷时,把 harness 产物桥接为 files。 + payload = (message.metadata_json or {}).get("channel_payload") + if not (isinstance(payload, dict) and payload) and binding.channel == "discord": + payload = _channel_payload_from_harness_artifacts(db, chat_session, message) + 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, @@ -214,6 +369,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, @@ -375,6 +531,53 @@ def _event_has_delivered_response( return False +def _should_auto_thread( + binding: ChannelBinding, + delivery: ChannelDelivery, + target: dict, +) -> bool: + """判定是否应为该投递自动创建 discord 公开线程(D2-4)。 + + 仅 discord 群聊(reply + guild_id)且特性显式开启时触发;已在线程内的会话 + (target 带 thread_id)、语音投递、以及建线程永久失败过的会话(blocked 标记) + 一律跳过,默认关闭保持存量行为。 + """ + if binding.channel != "discord": + return False + if delivery.kind != "reply": + return False + if delivery.delivery_kind == "voice": + return False + if target.get("thread_id"): + return False + if not target.get("guild_id"): + return False + if target.get("auto_thread_blocked"): + return False + features = (binding.config_json or {}).get("features") or {} + return features.get("auto_thread") is True + + +def _thread_name_for(db: Session, delivery: ChannelDelivery) -> str: + """解析自动建线程的线程名:metadata.thread_name > 会话首条用户消息 > 默认值。""" + message = db.get(Message, delivery.message_id) if delivery.message_id else None + if message: + thread_name = str((message.metadata_json or {}).get("thread_name") or "").strip() + if thread_name: + return thread_name + first_user = db.exec( + select(Message) + .where(Message.session_id == delivery.session_id, Message.role == "user") + .order_by(Message.created_at, Message.id) + .limit(1) + ).first() + if first_user: + cleaned = " ".join(str(first_user.content or "").split()) + if cleaned: + return cleaned[:40] + return "对话" + + def _deliver_one(db: Session, delivery: ChannelDelivery) -> None: from app.channels import binding_lifecycle_lock @@ -471,7 +674,52 @@ 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 _should_auto_thread(binding, delivery, target): + # D2-4:建线程是外部副作用,成功立即提交 thread_id 到 target/session; + # 后续 send 失败重试经 refresh 读回 target_json,幂等跳过再建线程。 + create_thread = getattr(adapter, "create_thread", None) + if not callable(create_thread): + logger.warning( + "discord 适配器缺少 create_thread,自动建线程跳过 binding=%s", + binding.id, + ) + else: + try: + thread_id = create_thread(binding, target, _thread_name_for(db, delivery)) + except DiscordPermanentError: + logger.warning( + "discord 自动建线程永久失败,降级发主频道 binding=%s delivery=%s", + binding.id, + delivery.id, + ) + blocked = dict(chat_session.channel_target_json or {}) + blocked["auto_thread_blocked"] = True + chat_session.channel_target_json = blocked + # blocked 同步写回 delivery:本投递后续重试经 refresh 读回 + # target_json 短路 _should_auto_thread,避免每次重试重复建线程 + delivery.target_json = dict(blocked) + db.add(delivery) + db.add(chat_session) + db.commit() + except DiscordTransientError: + raise + else: + target["thread_id"] = thread_id + delivery.target_json = dict(target) + chat_session.channel_target_json = dict(target) + db.add(delivery) + db.add(chat_session) + db.commit() + 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") @@ -504,12 +752,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)) @@ -786,6 +1034,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/app/channels/service_routing.py b/backend/app/channels/service_routing.py index 258e822a..fcd57573 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 00000000..6efaa400 --- /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/app/core/harness_v2_engine.py b/backend/app/core/harness_v2_engine.py index 95ee3093..2a6d6933 100644 --- a/backend/app/core/harness_v2_engine.py +++ b/backend/app/core/harness_v2_engine.py @@ -2,6 +2,7 @@ from copy import deepcopy import hashlib +import logging import time from typing import Any @@ -67,6 +68,8 @@ TurnPlan, ) +logger = logging.getLogger(__name__) + class HarnessV2Engine: """Outer planner + durable TaskFrame scheduler + isolated Harness runs.""" @@ -126,6 +129,8 @@ def run(self, request: ChatTurnRequest) -> ChatTurnResponse: self.turn_record, user_message.id, ) + if request.channel == "web": + self._maybe_stage_user_message_mirror(session, user_message) bind_turn = getattr(self.events, "bind_turn", None) if callable(bind_turn): bind_turn(user_message.id, request.client_turn_id) @@ -523,6 +528,28 @@ def run(self, request: ChatTurnRequest) -> ChatTurnResponse: self.turn_store.complete(self.turn_record, response) return response + def _maybe_stage_user_message_mirror( + self, + session: ChatSession, + message: Message, + ) -> None: + """Web 端提问镜像到会话锚定的渠道(如 Discord),保持跨端上下文连续。 + + 仅由 run() 在 request.channel == "web" 时调用;延迟导入避免与 + channels 模块的潜在依赖环。镜像登记失败绝不影响 Web 主流程。 + """ + try: + from app.channels.service_outbox import stage_user_message_mirror + + stage_user_message_mirror( + self.db, + session, + message, + web_origin=True, + ) + except Exception: + logger.exception("用户消息镜像登记失败 session=%s", session.id) + def close(self) -> None: session_id = str(self._session_lock_id or "") try: diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 99afd2f3..c1b3f04e 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")} @@ -1080,6 +1082,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 25ba7ee7..515c74e0 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -910,6 +910,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) # 创建/接管该事件的进程启动代次;当前代次仍在运行时禁止按墙钟误接管。 @@ -933,6 +939,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/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_api.py b/backend/tests/test_channel_api.py index b477c160..8f4c8199 100644 --- a/backend/tests/test_channel_api.py +++ b/backend/tests/test_channel_api.py @@ -6,7 +6,7 @@ import app.api.channels as channels_api from app.channels.crypto import decrypt_channel_secret, encrypt_channel_secret -from app.channels.schema import channel_binding_read +from app.channels.schema import ChannelFeaturesConfig, channel_binding_read from app.db import get_session from app.db.models import ( AgentProfile, @@ -1173,6 +1173,71 @@ def test_put_binding_empty_update_400() -> None: assert response.status_code == 400 +# ---------- 能力开关 features(auto_thread) ---------- + + +def test_channel_features_config_auto_thread_default_false() -> None: + # 自动建线程开关默认关闭,保存存量行为(未配置时后端不自动建线程) + assert ChannelFeaturesConfig().auto_thread is False + + +def test_discord_channel_meta_declares_auto_thread_capability() -> None: + discord_meta = next( + meta for meta in channels_api.CHANNEL_META if meta["channel"] == "discord" + ) + assert "auto_thread" in discord_meta["capabilities"] + + +def test_put_binding_features_auto_thread_persists() -> None: + engine = _test_engine() + users = _seed_users(engine) + binding_id = _seed_binding(engine) + client = _make_client(engine) + + response = client.put( + f"/api/enterprise/channels/{binding_id}?tenant_id=tenant_demo", + json={"features": {"auto_thread": True}}, + headers=_auth(users["owner"]), + ) + assert response.status_code == 200 + with Session(engine) as db: + binding = db.get(ChannelBinding, binding_id) + assert binding.config_json["features"]["auto_thread"] is True + # 结构化段变更 → config_revision 自增 + assert binding.config_revision == 1 + + +def test_binding_read_returns_config_json_features() -> None: + """PUT 响应须回传 config_json.features,否则前端回显开关会落回默认值。 + + 回归:auto_thread 保存成功后 UI 显示关闭(数据库已 true 但响应缺失 + config_json 字段,前端 useEffect 回显读到 undefined→默认 false)。 + """ + engine = _test_engine() + users = _seed_users(engine) + binding_id = _seed_binding(engine) + client = _make_client(engine) + + response = client.put( + f"/api/enterprise/channels/{binding_id}?tenant_id=tenant_demo", + json={"features": {"auto_thread": True}}, + headers=_auth(users["owner"]), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["config_json"]["features"]["auto_thread"] is True + + # 列表接口同样回传 config_json,保证刷新页面后开关状态保持 + listing = client.get( + "/api/enterprise/channels", + params={"tenant_id": "tenant_demo"}, + headers=_auth(users["owner"]), + ) + assert listing.status_code == 200 + row = next(item for item in listing.json() if item["id"] == binding_id) + assert row["config_json"]["features"]["auto_thread"] is True + + # ---------- 分页 ---------- 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 00000000..70da2df4 --- /dev/null +++ b/backend/tests/test_channel_batch_backfill_api.py @@ -0,0 +1,688 @@ +"""渠道 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", + "auto_thread", + "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 00000000..2a6fd1d2 --- /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_capabilities.py b/backend/tests/test_channel_capabilities.py new file mode 100644 index 00000000..80073084 --- /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 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_harness_payload.py b/backend/tests/test_channel_harness_payload.py new file mode 100644 index 00000000..ba3c04bb --- /dev/null +++ b/backend/tests/test_channel_harness_payload.py @@ -0,0 +1,310 @@ +"""harness 产物桥接为 discord channel_payload 的测试(功能8富媒体 D8 生成侧补齐)。 + +契约要点: +- assistant_metadata.harness_artifacts 条目格式: + {"type": "workspace_file", "task_frame_id", "path", "sha256", "size"} + (app.harness.artifacts.publish_harness_artifacts 产出) +- 桥接仅在 binding.channel == "discord" 且无显式 channel_payload 时生效; +- 产物转为 {"files": [{"filename", "data"(base64), "content_type"}]} 载荷, + 单文件超 8MiB / 读取失败时静默跳过(降级,不抛异常)。 +""" + +import base64 +import json +from typing import Any + +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from app.channels.service_outbox import stage_channel_delivery +from app.core.harness_session_cleanup import harness_task_workspace_path +from app.db.models import ( + ChannelBinding, + ChannelDelivery, + ChatSession, + Message, + Tenant, + UIConfig, +) + +# 与 DiscordAdapter._MAX_ATTACHMENT_BYTES 对齐 +_DISCORD_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024 + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _seed_base(db: Session, tmp_path) -> None: + """种 tenant + 指向 tmp_path 的 harness 存储根(UIConfig)。""" + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add( + UIConfig( + tenant_id="tenant_demo", + sandbox_enabled=False, + harness_storage_path=str(tmp_path), + ) + ) + db.commit() + + +def _seed_binding(db: Session, *, channel: str) -> ChannelBinding: + binding = ChannelBinding( + tenant_id="tenant_demo", + agent_id="agent_1", + channel=channel, + status="active", + external_account_key=f"{channel}:account", + ) + db.add(binding) + db.commit() + return binding + + +def _discord_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 _wechat_session(binding: ChannelBinding) -> ChatSession: + return ChatSession( + id="session_wechat", + tenant_id=binding.tenant_id, + user_id="user_1", + agent_id=binding.agent_id, + channel=binding.channel, + external_conv_id="wechat_p2p_u1", + channel_target_json={"to_user_id": "u1", "context_token": "ctx"}, + channel_binding_id=binding.id, + channel_account_key=binding.external_account_key, + ) + + +def _write_harness_file( + db: Session, + chat_session: ChatSession, + task_frame_id: str, + relative_path: str, + content: bytes, +) -> None: + """按 harness_task_workspace_path 的根布局写入工作区文件。""" + root = harness_task_workspace_path( + tenant_id=chat_session.tenant_id, + session_id=chat_session.id, + task_frame_id=task_frame_id, + db=db, + ) + root.mkdir(parents=True, exist_ok=True) + (root / relative_path).write_bytes(content) + + +def _artifact(task_frame_id: str, relative_path: str, size: int) -> dict[str, Any]: + return { + "type": "workspace_file", + "task_frame_id": task_frame_id, + "path": relative_path, + "sha256": "x" * 64, + "size": size, + } + + +def _assistant_message( + chat_session: ChatSession, + *, + message_id: str, + content: str = "已生成报告", + metadata_json: dict[str, Any] | None = None, +) -> Message: + return Message( + id=message_id, + tenant_id=chat_session.tenant_id, + session_id=chat_session.id, + role="assistant", + content=content, + metadata_json=metadata_json or {}, + ) + + +def test_discord_harness_artifacts_become_files_payload(tmp_path) -> None: + engine = _test_engine() + with Session(engine) as db: + _seed_base(db, tmp_path) + binding = _seed_binding(db, channel="discord") + chat_session = _discord_session(binding) + original = "**任务报告**\n\n- 完成调研\n- 输出结论\n".encode("utf-8") + _write_harness_file(db, chat_session, "task_frame_1", "report.md", original) + message = _assistant_message( + chat_session, + message_id="msg_harness", + metadata_json={"harness_artifacts": [_artifact("task_frame_1", "report.md", len(original))]}, + ) + 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 is not None + payload = json.loads(delivery.payload_json) + files = payload.get("files") + assert isinstance(files, list) and len(files) == 1 + assert files[0]["filename"] == "report.md" + assert base64.b64decode(files[0]["data"]) == original + assert files[0]["content_type"] + + +def test_non_discord_channel_does_not_bridge_harness_artifacts(tmp_path) -> None: + engine = _test_engine() + with Session(engine) as db: + _seed_base(db, tmp_path) + binding = _seed_binding(db, channel="wechat") + chat_session = _wechat_session(binding) + _write_harness_file(db, chat_session, "task_frame_1", "report.md", b"data") + message = _assistant_message( + chat_session, + message_id="msg_harness", + metadata_json={"harness_artifacts": [_artifact("task_frame_1", "report.md", 4)]}, + ) + db.add(chat_session) + db.add(message) + db.commit() + + stage_channel_delivery(db, chat_session, message) + db.commit() + + delivery = db.exec(select(ChannelDelivery)).one() + # 微信等存量渠道不做 harness 桥接,保持纯文本行为 + assert delivery.payload_json is None + + +def test_no_harness_artifacts_keeps_payload_json_none(tmp_path) -> None: + engine = _test_engine() + with Session(engine) as db: + _seed_base(db, tmp_path) + binding = _seed_binding(db, channel="discord") + chat_session = _discord_session(binding) + message = _assistant_message(chat_session, message_id="msg_plain", metadata_json={}) + 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 is None + + +def test_oversized_harness_artifact_is_skipped(tmp_path) -> None: + engine = _test_engine() + with Session(engine) as db: + _seed_base(db, tmp_path) + binding = _seed_binding(db, channel="discord") + chat_session = _discord_session(binding) + original = b"small report" + _write_harness_file(db, chat_session, "task_frame_1", "report.md", original) + # 声明超 8MiB 的条目即使文件存在也跳过(Discord 附件上限) + oversized = _artifact("task_frame_1", "big.bin", _DISCORD_MAX_ATTACHMENT_BYTES + 1) + message = _assistant_message( + chat_session, + message_id="msg_harness", + metadata_json={ + "harness_artifacts": [ + oversized, + _artifact("task_frame_1", "report.md", len(original)), + ] + }, + ) + 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 is not None + payload = json.loads(delivery.payload_json) + assert [file["filename"] for file in payload["files"]] == ["report.md"] + + +def test_missing_harness_file_is_skipped_without_raising(tmp_path) -> None: + engine = _test_engine() + with Session(engine) as db: + _seed_base(db, tmp_path) + binding = _seed_binding(db, channel="discord") + chat_session = _discord_session(binding) + original = b"survivor" + _write_harness_file(db, chat_session, "task_frame_1", "report.md", original) + message = _assistant_message( + chat_session, + message_id="msg_harness", + metadata_json={ + "harness_artifacts": [ + _artifact("task_frame_1", "missing.md", 10), + "garbage-entry", # 非 dict 条目直接忽略 + _artifact("other_frame", "report.md", len(original)), # 工作区不存在 + _artifact("task_frame_1", "report.md", len(original)), + ] + }, + ) + 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.status == "pending" + assert delivery.payload_json is not None + payload = json.loads(delivery.payload_json) + assert [file["filename"] for file in payload["files"]] == ["report.md"] + + +def test_explicit_channel_payload_takes_priority_over_harness_bridge(tmp_path) -> None: + engine = _test_engine() + with Session(engine) as db: + _seed_base(db, tmp_path) + binding = _seed_binding(db, channel="discord") + chat_session = _discord_session(binding) + _write_harness_file(db, chat_session, "task_frame_1", "report.md", b"data") + explicit = {"embeds": [{"title": "显式卡片"}]} + message = _assistant_message( + chat_session, + message_id="msg_harness", + metadata_json={ + "channel_payload": explicit, + "harness_artifacts": [_artifact("task_frame_1", "report.md", 4)], + }, + ) + db.add(chat_session) + db.add(message) + db.commit() + + stage_channel_delivery(db, chat_session, message) + db.commit() + + delivery = db.exec(select(ChannelDelivery)).one() + # 显式 channel_payload 优先,harness 桥接不覆盖 + assert delivery.payload_json == json.dumps(explicit, ensure_ascii=False) + assert delivery.payload_json is not None + assert "files" not in json.loads(delivery.payload_json) \ No newline at end of file diff --git a/backend/tests/test_channel_outbox.py b/backend/tests/test_channel_outbox.py index 33fa5a2b..434b2f8b 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 @@ -7,12 +8,14 @@ from app.channels.adapters import base as adapter_registry from app.channels.adapters.base import register_channel_adapter +from app.channels.adapters.discord import DiscordPermanentError, DiscordTransientError from app.channels.service_durable_inbox import reaction_target from app.channels.service_outbox import ( cleanup_channel_reactions_before_binding_delete, run_delivery_daemon, run_reaction_delivery_daemon, stage_channel_delivery, + stage_user_message_mirror, ) from app.config import get_settings from app.channels.crypto import encrypt_channel_secret @@ -34,6 +37,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 +46,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 +376,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() @@ -1294,6 +1453,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 重置陈旧阈值 ---------- @@ -1478,3 +1675,745 @@ 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" + + +# ---------- user_mirror:Web 提问镜像到锚定渠道 ---------- + + +def _user_message(session_id: str, message_id: str, content: str = "Web 提问") -> Message: + return Message( + id=message_id, + tenant_id="tenant_demo", + session_id=session_id, + role="user", + content=content, + ) + + +def test_web_user_message_mirror_staged() -> None: + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + chat_session = _discord_channel_session(binding) + message = _user_message(chat_session.id, "msg_web_q") + db.add(chat_session) + db.add(message) + db.commit() + + stage_user_message_mirror(db, chat_session, message, web_origin=True) + db.commit() + + deliveries = db.exec(select(ChannelDelivery)).all() + assert len(deliveries) == 1 + delivery = deliveries[0] + assert delivery.binding_id == binding.id + assert delivery.session_id == chat_session.id + assert delivery.message_id == "msg_web_q" + assert delivery.idempotency_key == "msg_web_q" + assert delivery.kind == "user_mirror" + assert delivery.status == "pending" + assert delivery.text == "Web 提问" + assert delivery.target_json == {"channel_id": "channel-1"} + + +def test_user_mirror_skipped_when_not_web_origin() -> None: + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + chat_session = _discord_channel_session(binding) + message = _user_message(chat_session.id, "msg_chan_q") + db.add(chat_session) + db.add(message) + db.commit() + + # 渠道自身入站(Discord 提问)不镜像,避免回声 + stage_user_message_mirror(db, chat_session, message, web_origin=False) + db.commit() + + assert db.exec(select(ChannelDelivery)).all() == [] + + +def test_user_mirror_skipped_without_channel_anchor() -> None: + engine = _test_engine() + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + web_session = ChatSession(id="session_web", tenant_id="tenant_demo", agent_id="agent_1") + message = _user_message(web_session.id, "msg_web_plain") + db.add(web_session) + db.add(message) + db.commit() + + stage_user_message_mirror(db, web_session, message, web_origin=True) + db.commit() + + assert db.exec(select(ChannelDelivery)).all() == [] + + +def test_user_mirror_idempotent_per_message() -> None: + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + chat_session = _discord_channel_session(binding) + message = _user_message(chat_session.id, "msg_web_q2") + db.add(chat_session) + db.add(message) + db.commit() + + stage_user_message_mirror(db, chat_session, message, web_origin=True) + stage_user_message_mirror(db, chat_session, message, web_origin=True) + db.commit() + + assert len(db.exec(select(ChannelDelivery)).all()) == 1 + + +def test_user_mirror_skipped_when_binding_inactive() -> None: + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord", status="inactive") + chat_session = _discord_channel_session(binding) + message = _user_message(chat_session.id, "msg_web_q3") + db.add(chat_session) + db.add(message) + db.commit() + + stage_user_message_mirror(db, chat_session, message, web_origin=True) + db.commit() + + assert db.exec(select(ChannelDelivery)).all() == [] + + +def test_user_mirror_skipped_for_empty_content() -> None: + engine = _test_engine() + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + chat_session = _discord_channel_session(binding) + message = _user_message(chat_session.id, "msg_web_q4", content=" ") + db.add(chat_session) + db.add(message) + db.commit() + + stage_user_message_mirror(db, chat_session, message, web_origin=True) + db.commit() + + assert db.exec(select(ChannelDelivery)).all() == [] + + +def test_user_mirror_delivered_via_daemon() -> None: + engine = _test_engine() + adapter = FakeAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + binding_id = binding.id + chat_session = _discord_channel_session(binding) + message = _user_message(chat_session.id, "msg_web_q5", "Web 端提问镜像") + db.add(chat_session) + db.add(message) + db.commit() + + stage_user_message_mirror(db, chat_session, message, web_origin=True) + db.commit() + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + delivery = db.exec(select(ChannelDelivery)).one() + assert delivery.status == "delivered" + assert delivery.attempts == 1 + assert adapter.sent == [ + (binding_id, {"channel_id": "channel-1"}, "Web 端提问镜像") + ] + + +# ---------- D2-4:discord 自动建线程(群聊回复投递) ---------- + + +class _ThreadAdapter(FakeAdapter): + """带 create_thread 的 discord 测试适配器:记录建线程调用,可注入永久/瞬态失败。""" + + def __init__(self, *, thread_fail: str | None = None, **kwargs): + super().__init__(**kwargs) + self.thread_calls: list[tuple[str, dict, str]] = [] + self.thread_fail = thread_fail + + def create_thread(self, binding: ChannelBinding, target: dict, name: str) -> str: + self.thread_calls.append((binding.id, dict(target), name)) + if self.thread_fail == "permanent": + raise DiscordPermanentError("模拟建线程永久失败") + if self.thread_fail == "transient": + raise DiscordTransientError("模拟建线程限流") + return "thread-9" + + +class _ThreadVoiceAdapter(_ThreadAdapter): + 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 _thread_binding(db: Session) -> ChannelBinding: + """discord 绑定并开启 auto_thread 特性(群聊场景)。""" + binding = _seed_binding(db, channel="discord") + binding.config_json = {"features": {"auto_thread": True}} + db.add(binding) + db.commit() + return binding + + +def _thread_delivery_target() -> dict: + return {"channel_id": "channel-1", "guild_id": "guild-1"} + + +def test_daemon_creates_thread_then_sends_into_thread() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + 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 == "delivered" + assert delivery.target_json["thread_id"] == "thread-9" + chat_session = db.get(ChatSession, "session_chan") + assert chat_session.channel_target_json["thread_id"] == "thread-9" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "对话") + ] + assert adapter.sent[0][1]["thread_id"] == "thread-9" + + +def test_daemon_skips_auto_thread_when_feature_disabled() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _seed_binding(db, channel="discord") + binding_id = binding.id + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [] + assert adapter.sent == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "回复内容") + ] + + +def test_daemon_skips_auto_thread_when_target_already_has_thread_id() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + delivery = _make_delivery( + db, + binding, + target_json={ + "channel_id": "channel-1", + "guild_id": "guild-1", + "thread_id": "thread-5", + }, + ) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [] + assert adapter.sent[0][1]["thread_id"] == "thread-5" + + +def test_daemon_skips_auto_thread_for_dm_session() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery(db, binding, target_json={"channel_id": "channel-1"}) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [] + assert adapter.sent == [(binding_id, {"channel_id": "channel-1"}, "回复内容")] + + +def test_daemon_skips_auto_thread_for_voice_delivery() -> None: + engine = _test_engine() + adapter = _ThreadVoiceAdapter() + register_channel_adapter("discord", adapter) + payload = json.dumps( + {"audio": {"type": "file", "file_ref": "/tmp/clip.mp3"}}, ensure_ascii=False + ) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery( + db, + binding, + payload_json=payload, + delivery_kind="voice", + text="", + target_json=_thread_delivery_target(), + ) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [] + assert adapter.voice_calls == [ + ( + binding_id, + {"channel_id": "channel-1", "guild_id": "guild-1"}, + {"type": "file", "file_ref": "/tmp/clip.mp3"}, + ) + ] + + +def test_daemon_skips_auto_thread_for_non_reply_kind() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery( + db, binding, kind="error_notice", target_json=_thread_delivery_target() + ) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [] + assert adapter.sent == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "回复内容") + ] + + +def test_daemon_skips_auto_thread_for_non_discord_channel() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("fake", adapter) + with Session(engine) as db: + binding = _seed_binding(db) + binding.config_json = {"features": {"auto_thread": True}} + db.add(binding) + db.commit() + binding_id = binding.id + delivery = _make_delivery(db, binding) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [] + assert adapter.sent == [(binding_id, {"to_user_id": "u1", "context_token": "ctx"}, "回复内容")] + + +def test_daemon_thread_name_prefers_metadata_thread_name() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + message = _assistant_message("session_chan", "msg_chan") + message.metadata_json = {"thread_name": "预算讨论"} + db.add(message) + db.commit() + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "预算讨论") + ] + + +def test_daemon_thread_name_falls_back_to_first_user_message() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + db.add(_user_message("session_chan", "msg_chan", "帮我算一下本季度预算规划")) + db.commit() + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "帮我算一下本季度预算规划") + ] + + +def test_daemon_thread_name_defaults_when_no_messages() -> None: + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "对话") + ] + + +def test_daemon_thread_name_falls_back_when_metadata_missing() -> None: + """message 存在但 metadata_json 为 None:回退到首条 user 消息。""" + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + db.add(_assistant_message("session_chan", "msg_chan")) + db.add(_user_message("session_chan", "user_1", "帮我算一下本季度预算规划")) + db.commit() + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "帮我算一下本季度预算规划") + ] + + +def test_daemon_thread_name_falls_back_when_metadata_blank() -> None: + """metadata.thread_name 为空白:strip 后为空,回退到首条 user 消息。""" + engine = _test_engine() + adapter = _ThreadAdapter() + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + assistant = _assistant_message("session_chan", "msg_chan") + assistant.metadata_json = {"thread_name": " "} + db.add(assistant) + db.add(_user_message("session_chan", "user_1", "帮我算一下本季度预算规划")) + db.commit() + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + delivery_id = delivery.id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery_id).status == "delivered" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "帮我算一下本季度预算规划") + ] + + +def test_daemon_create_thread_permanent_error_degrades_to_channel() -> None: + engine = _test_engine() + adapter = _ThreadAdapter(thread_fail="permanent") + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + chat_session = _discord_channel_session(binding) + chat_session.channel_target_json = _thread_delivery_target() + msg1 = _assistant_message(chat_session.id, "msg_thr_1") + db.add(chat_session) + db.add(msg1) + db.commit() + stage_channel_delivery(db, chat_session, msg1) + db.commit() + delivery1_id = db.exec( + select(ChannelDelivery).where(ChannelDelivery.message_id == "msg_thr_1") + ).one().id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery1_id).status == "delivered" + chat_session = db.get(ChatSession, "session_discord") + assert chat_session.channel_target_json["auto_thread_blocked"] is True + # 同会话第二次投递:blocked 标志应让建线程被再次跳过,继续发主频道 + msg2 = _assistant_message(chat_session.id, "msg_thr_2") + db.add(msg2) + db.commit() + stage_channel_delivery(db, chat_session, msg2) + db.commit() + delivery2_id = db.exec( + select(ChannelDelivery).where(ChannelDelivery.message_id == "msg_thr_2") + ).one().id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + assert db.get(ChannelDelivery, delivery2_id).status == "delivered" + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "对话") + ] + assert len(adapter.sent) == 2 + assert adapter.sent[0][1].get("thread_id") is None + assert adapter.sent[1][1].get("thread_id") is None + + +def test_daemon_create_thread_transient_error_retries() -> None: + engine = _test_engine() + adapter = _ThreadAdapter(thread_fail="transient") + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + 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 == "pending" + assert delivery.attempts == 1 + assert "模拟建线程限流" in (delivery.last_error or "") + assert delivery.next_attempt_at > utc_now() + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "对话") + ] + assert adapter.sent == [] + + +def test_daemon_send_failure_after_thread_creation_retry_skips_create_thread() -> None: + engine = _test_engine() + adapter = _ThreadAdapter(fail_times=1) + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + delivery = _make_delivery(db, binding, target_json=_thread_delivery_target()) + 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 == "pending" + assert delivery.target_json["thread_id"] == "thread-9" + delivery.next_attempt_at = utc_now() - timedelta(seconds=1) + db.add(delivery) + db.commit() + + 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 == 2 + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "对话") + ] + assert adapter.sent == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1", "thread_id": "thread-9"}, "回复内容") + ] + + +def test_daemon_permanent_degrade_then_send_failure_retry_skips_create_thread() -> None: + """降级(建线程永久失败)后 send 也失败:重试须经 delivery.target_json 的 + auto_thread_blocked 短路,不再重复建线程(回归 P1 缺陷)。""" + engine = _test_engine() + adapter = _ThreadAdapter(thread_fail="permanent", fail_times=1) + register_channel_adapter("discord", adapter) + with Session(engine) as db: + binding = _thread_binding(db) + binding_id = binding.id + chat_session = _discord_channel_session(binding) + chat_session.channel_target_json = _thread_delivery_target() + msg1 = _assistant_message(chat_session.id, "msg_thr_fail_1") + db.add(chat_session) + db.add(msg1) + db.commit() + stage_channel_delivery(db, chat_session, msg1) + db.commit() + delivery1_id = db.exec( + select(ChannelDelivery).where(ChannelDelivery.message_id == "msg_thr_fail_1") + ).one().id + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + delivery = db.get(ChannelDelivery, delivery1_id) + assert delivery.status == "pending" + # P1:降级分支须把 blocked 同步写进 delivery.target_json,供本投递重试短路 + assert delivery.target_json.get("auto_thread_blocked") is True + delivery.next_attempt_at = utc_now() - timedelta(seconds=1) + db.add(delivery) + db.commit() + + run_delivery_daemon(once=True, db_engine=engine) + + with Session(engine) as db: + delivery = db.get(ChannelDelivery, delivery1_id) + assert delivery.status == "delivered" + assert delivery.attempts == 2 + # 重试时不得再次建线程:仅第一次调用(永久失败那次)计入 + assert adapter.thread_calls == [ + (binding_id, {"channel_id": "channel-1", "guild_id": "guild-1"}, "对话") + ] + assert adapter.sent == [ + ( + binding_id, + { + "channel_id": "channel-1", + "guild_id": "guild-1", + "auto_thread_blocked": True, + }, + "回复内容", + ) + ] diff --git a/backend/tests/test_channel_routing.py b/backend/tests/test_channel_routing.py index e1e34a63..02c42004 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 00000000..3b41a01d --- /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_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/tests/test_discord_features.py b/backend/tests/test_discord_features.py new file mode 100644 index 00000000..01c00e6c --- /dev/null +++ b/backend/tests/test_discord_features.py @@ -0,0 +1,893 @@ +"""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 httpx +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_stage_discord_inbound_target_includes_thread_id(): + """线程消息 target 记录 thread_id,供出站定位线程端点(自动建线程前置)。""" + 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"), + 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.target_json.get("thread_id") == "thread-9" + + +def test_stage_discord_inbound_plain_channel_target_has_no_thread_id_key(): + """普通频道消息不写 thread_id 键,保持门禁不变量。""" + 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 + with Session(db_engine) as db: + event = db.get(ChannelInboundEvent, result.event_pk) + assert "thread_id" not in event.target_json + + +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" + + +# ---------------------------------------------------------------- 自动建线程 + + +class _BrokenClient: + """post 直接抛 httpx.HTTPError 的假 client,模拟网络故障。""" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, **_kwargs): + raise httpx.ConnectError("network down") + + +def test_create_thread_posts_to_threads_endpoint(): + client = _RoutingClient({"threads": [_Response(200, {"id": "thread-9"})]}) + result = _adapter(client).create_thread(_binding(), {"channel_id": "channel-1"}, "新线程") + call = client.calls_to("/threads")[0] + assert call["url"] == f"{DISCORD_API_BASE}/channels/channel-1/threads" + assert call["body"]["name"] == "新线程" + assert call["body"]["type"] == 11 + assert call["headers"]["Authorization"] == "Bot secret" + assert result == "thread-9" + + +def test_create_thread_returns_created_id(): + client = _RoutingClient({"threads": [_Response(200, {"id": "thread-9"})]}) + assert _adapter(client).create_thread(_binding(), {"channel_id": "channel-1"}, "t") == "thread-9" + + +def test_create_thread_truncates_name_to_100_chars(): + client = _RoutingClient({"threads": [_Response(200, {"id": "thread-9"})]}) + _adapter(client).create_thread(_binding(), {"channel_id": "channel-1"}, "n" * 150) + assert len(client.calls_to("/threads")[0]["body"]["name"]) == 100 + + +def test_create_thread_missing_channel_is_permanent(): + with pytest.raises(DiscordPermanentError) as excinfo: + _adapter(_RoutingClient({})).create_thread(_binding(), {}, "t") + assert excinfo.value.retryable is False + + +def test_create_thread_permission_denied_is_permanent(): + with pytest.raises(DiscordPermanentError): + _adapter(_RoutingClient({"threads": [_Response(403)]})).create_thread( + _binding(), {"channel_id": "channel-1"}, "t" + ) + + +def test_create_thread_rate_limited_is_transient(): + with pytest.raises(DiscordTransientError) as excinfo: + _adapter(_RoutingClient({"threads": [_Response(429)]})).create_thread( + _binding(), {"channel_id": "channel-1"}, "t" + ) + assert excinfo.value.retryable is True + + +def test_create_thread_server_error_is_transient(): + with pytest.raises(DiscordTransientError): + _adapter(_RoutingClient({"threads": [_Response(500)]})).create_thread( + _binding(), {"channel_id": "channel-1"}, "t" + ) + + +def test_create_thread_http_error_is_transient(): + with pytest.raises(DiscordTransientError) as excinfo: + _adapter(_BrokenClient()).create_thread(_binding(), {"channel_id": "channel-1"}, "t") + assert excinfo.value.retryable is True + + +def test_create_thread_missing_id_is_permanent(): + with pytest.raises(DiscordPermanentError) as excinfo: + _adapter(_RoutingClient({"threads": [_Response(200, {})]})).create_thread( + _binding(), {"channel_id": "channel-1"}, "t" + ) + assert excinfo.value.retryable is False + + +# ---------------------------------------------------------------- 功能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 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/i18n/en.json b/frontend-enterprise/src/i18n/en.json index ecc15686..997c8c1c 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -2070,6 +2070,45 @@ "输入状态": "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", + "自动创建线程": "Auto-create 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 2c651fdc..ccb68519 100644 --- a/frontend-enterprise/src/pages/ChannelsPage.tsx +++ b/frontend-enterprise/src/pages/ChannelsPage.tsx @@ -47,6 +47,9 @@ 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 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'; @@ -793,6 +796,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' ? ( + {binding.channel === 'discord' && ( + + setBindings((current) => current.map((item) => (item.id === updated.id ? updated : item))) + } + /> + )} +
@@ -1017,6 +1039,7 @@ export default function ChannelsPage({ {shown.content} +
); })} diff --git a/frontend-enterprise/src/pages/channelPresentation.ts b/frontend-enterprise/src/pages/channelPresentation.ts index 7352ffd3..3a9c5d08 100644 --- a/frontend-enterprise/src/pages/channelPresentation.ts +++ b/frontend-enterprise/src/pages/channelPresentation.ts @@ -35,6 +35,13 @@ const BUILT_IN_CHANNELS: Record = { 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/ChannelMessageAttachments.tsx b/frontend-enterprise/src/pages/channels/ChannelMessageAttachments.tsx new file mode 100644 index 00000000..9c211ed0 --- /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 00000000..027bf562 --- /dev/null +++ b/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.test.tsx @@ -0,0 +1,329 @@ +// @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(8); + // voice 默认关闭,其余默认开启 + expect(switches[6].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[6].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('pre-fills auto_thread from config_json', () => { + renderConfig( + binding({ + config_json: { + features: { auto_thread: true }, + }, + }), + ); + + expect(screen.getByText('自动创建线程')).toBeTruthy(); + // auto_thread 位于 threads 之后(index 2),配置为 true 时开关勾选 + expect(screen.getAllByRole('switch')[2].getAttribute('data-state')).toBe('checked'); + }); + + it('saves features and allowlist through the binding update endpoint', async () => { + putMock.mockResolvedValue(binding()); + renderConfig(binding()); + + fireEvent.click(screen.getAllByRole('switch')[6]); // 打开 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('saves auto_thread through the binding update endpoint', async () => { + putMock.mockResolvedValue(binding()); + renderConfig(binding()); + + fireEvent.click(screen.getAllByRole('switch')[2]); // 打开自动创建线程 + fireEvent.click(screen.getByText('保存')); + + await waitFor(() => { + expect(putMock).toHaveBeenCalledWith( + '/api/enterprise/channels/chan_discord?tenant_id=tenant_demo', + { + tenant_id: 'tenant_demo', + features: expect.objectContaining({ auto_thread: true }), + allowlist: expect.anything(), + }, + ); + }); + }); + + 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 00000000..c865fcaf --- /dev/null +++ b/frontend-enterprise/src/pages/channels/DiscordFeatureConfig.tsx @@ -0,0 +1,377 @@ +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: 'auto_thread', label: '自动创建线程', defaultOn: false }, + { 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 ( +