From a48f7ebf2aefafaed5d53ce89eb99d7e81f7e776 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 21 May 2026 10:35:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E9=81=93=E5=9F=BA=E7=A1=80=E6=A8=A1=E5=9D=97=E4=B8=8EUI=20sche?= =?UTF-8?q?ma=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交新增了通道模块的完整基础实现: 1. 搭建了channel包的顶层导出结构,整合所有核心子模块接口 2. 实现了错误处理工具类与重试退避逻辑 3. 新增UI表单/页面/字段的schema定义与自动生成工具 4. 完成上下文管理模块,支持会话上下文、可见性过滤与运行时状态注册 5. 实现通道能力配置类,支持流式传输、功能开关等多维度能力定义 --- backend/package/yuxi/channel/__init__.py | 69 + backend/package/yuxi/channel/capabilities.py | 437 ++++ backend/package/yuxi/channel/context.py | 215 ++ backend/package/yuxi/channel/errors.py | 85 + backend/package/yuxi/channel/protocols.py | 2332 ++++++++++++++++++ backend/package/yuxi/channel/ui/__init__.py | 1 + backend/package/yuxi/channel/ui/schema.py | 162 ++ 7 files changed, 3301 insertions(+) create mode 100644 backend/package/yuxi/channel/__init__.py create mode 100644 backend/package/yuxi/channel/capabilities.py create mode 100644 backend/package/yuxi/channel/context.py create mode 100644 backend/package/yuxi/channel/errors.py create mode 100644 backend/package/yuxi/channel/protocols.py create mode 100644 backend/package/yuxi/channel/ui/__init__.py create mode 100644 backend/package/yuxi/channel/ui/schema.py diff --git a/backend/package/yuxi/channel/__init__.py b/backend/package/yuxi/channel/__init__.py new file mode 100644 index 00000000..a3bd051f --- /dev/null +++ b/backend/package/yuxi/channel/__init__.py @@ -0,0 +1,69 @@ +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.common import AttachmentCache, CachedAttachment +from yuxi.channel.context import ( + ChannelContext, + ChannelRuntimeContexts, + ContextVisibilityMode, + ConversationBinding, + SupplementalContext, + task_scoped_contexts, +) +from yuxi.channel.events import ChannelEventBus, EventTopic +from yuxi.channel.runtime.manager import ( + ChannelGateway, + ChannelSnapshot, + ChannelState, + gateway, +) +from yuxi.channel.plugins.registry import ChannelPluginRegistry +from yuxi.channel.runtime.trace import StartupTrace +from yuxi.channel.streaming.draft_stream import ( + DraftStreamLoop, + FinalizableDraftStreamControls, + get_tool_status_label, +) +from yuxi.channel.runtime.trace_ctx import ( + TraceIdFilter, + generate_trace_id, + get_trace_id, + install_trace_filter, + set_trace_id, + trace_context, +) + +__all__ = [ + # common + "AttachmentCache", + "CachedAttachment", + # core + "ChannelCapabilities", + "ChannelContext", + "ChannelEventBus", + "ChannelGateway", + "ChannelPluginRegistry", + "ChannelRuntimeContexts", + "ChannelSnapshot", + "ChannelState", + "ContextVisibilityMode", + "ConversationBinding", + "EventTopic", + # hooks + "HookCallback", + "HookEvent", + "HookRegistration", + "LifecycleHookRegistry", + "StartupTrace", + "DraftStreamLoop", + "FinalizableDraftStreamControls", + "get_tool_status_label", + "SupplementalContext", + "gateway", + "task_scoped_contexts", + # trace + "TraceIdFilter", + "generate_trace_id", + "get_trace_id", + "install_trace_filter", + "set_trace_id", + "trace_context", +] diff --git a/backend/package/yuxi/channel/capabilities.py b/backend/package/yuxi/channel/capabilities.py new file mode 100644 index 00000000..98ae7096 --- /dev/null +++ b/backend/package/yuxi/channel/capabilities.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import warnings +from dataclasses import MISSING, dataclass, field, fields +from typing import ClassVar + +from yuxi.channel.config.defaults import ( + BLOCK_STREAMING_BREAK, + BLOCK_STREAMING_CHUNK_BREAK_PREFERENCE, + BLOCK_STREAMING_CHUNK_MAX_CHARS, + BLOCK_STREAMING_CHUNK_MIN_CHARS, + BLOCK_STREAMING_COALESCE_IDLE_MS, + BLOCK_STREAMING_COALESCE_MAX_CHARS, + BLOCK_STREAMING_COALESCE_MIN_CHARS, + SSE_MAX_REASONING_CHARS, + STREAMING_PREVIEW_MIN_INITIAL_CHARS, + STREAMING_PREVIEW_THROTTLE_MS, +) + +# 哨兵对象,用于标记字段尚未被显式设置 +_UNSET = object() + +# 支持的流式传输模式 +STREAMING_MODES: tuple[str, ...] = ("card_kit", "block", "c2c_stream_api", "raw") +# 块级流式传输的分割断点类型 +BLOCK_STREAMING_BREAKS: tuple[str, ...] = ("text_end", "paragraph", "sentence", "token") + + +@dataclass +class ChannelCapabilities: + """定义通道(Channel)支持的各种能力配置。""" + + # 聊天类型,默认支持私聊和群聊 + chat_types: list[str] = field(default_factory=lambda: ["direct", "group"]) + # 消息类型,默认仅支持文本 + message_types: list[str] = field(default_factory=lambda: ["text"]) + # 交互方式列表 + interactions: list[str] = field(default_factory=list) + + # 以下为各类功能开关,默认使用 _UNSET 表示未显式设置 + reactions: bool = field(default=_UNSET) # 是否支持消息表情反应 + typing_indicator: bool = field(default=_UNSET) # 是否支持输入状态指示 + threads: bool = field(default=_UNSET) # 是否支持话题线程 + edit: bool = field(default=_UNSET) # 是否支持编辑消息 + unsend: bool = field(default=_UNSET) # 是否支持撤回消息 + reply: bool = field(default=_UNSET) # 是否支持回复消息 + media: bool = field(default=_UNSET) # 是否支持发送媒体 + effects: bool = field(default=_UNSET) # 是否支持消息特效 + native_commands: bool = field(default=_UNSET) # 是否支持原生命令 + polls: bool = field(default=_UNSET) # 是否支持投票 + pins: bool = field(default=_UNSET) # 是否支持置顶消息 + group_management: bool = field(default=_UNSET) # 是否支持群组管理 + adaptive_cards: bool = field(default=_UNSET) # 是否支持自适应卡片 + tasks: bool = field(default=_UNSET) # 是否支持任务 + events: bool = field(default=_UNSET) # 是否支持事件 + notes: bool = field(default=_UNSET) # 是否支持笔记 + task_modules: bool = field(default=_UNSET) # 是否支持任务模块 + messaging_extensions: bool = field(default=_UNSET) # 是否支持消息扩展 + incoming_webhooks: bool = field(default=_UNSET) # 是否支持传入的 Webhook + bot_state: bool = field(default=_UNSET) # 是否支持机器人状态 + + # 流式传输相关配置 + streaming: bool = field(default=_UNSET) # 是否启用流式传输 + streaming_mode: str | None = None # 流式传输模式 + preview_stream_throttle_ms: int = field(default=_UNSET) # 预览流节流间隔(毫秒) + preview_min_initial_chars: int = field(default=_UNSET) # 预览最小初始字符数 + + # 块级流式传输配置 + block_streaming: bool = field(default=_UNSET) # 是否启用块级流式传输 + block_streaming_break: str = field(default=_UNSET) # 块级流式分割断点 + block_streaming_chunk_min_chars: int = field(default=_UNSET) # 块最小字符数 + block_streaming_chunk_max_chars: int = field(default=_UNSET) # 块最大字符数 + block_streaming_chunk_break_preference: str = field(default=_UNSET) # 块分割偏好 + block_streaming_coalesce_min_chars: int | None = None # 合并块最小字符数 + block_streaming_coalesce_max_chars: int | None = None # 合并块最大字符数 + block_streaming_coalesce_idle_ms: int = field(default=_UNSET) # 合并空闲等待时间(毫秒) + + # SSE 与轮询配置 + sse_support: bool = field(default=_UNSET) # 是否支持 SSE + sse_max_reasoning_chars: int = field(default=_UNSET) # SSE 最大推理字符数 + polling_fallback: bool = field(default=_UNSET) # 是否启用轮询降级 + + # TTS(文本转语音)配置 + tts: dict | None = None + + # 类变量:已警告的冻结字段集合 + _WARNED_FROZEN_FIELDS: ClassVar[frozenset[str]] = frozenset() + + def __post_init__(self) -> None: + """初始化后处理:记录原始值并对未设置的字段应用默认值。""" + self._raw_values = dict(self.__dict__) + # 布尔功能开关默认值均为 False + if self.reactions is _UNSET: + self.reactions = False + if self.typing_indicator is _UNSET: + self.typing_indicator = False + if self.threads is _UNSET: + self.threads = False + if self.edit is _UNSET: + self.edit = False + if self.unsend is _UNSET: + self.unsend = False + if self.reply is _UNSET: + self.reply = False + if self.media is _UNSET: + self.media = False + if self.effects is _UNSET: + self.effects = False + if self.native_commands is _UNSET: + self.native_commands = False + if self.polls is _UNSET: + self.polls = False + if self.pins is _UNSET: + self.pins = False + if self.group_management is _UNSET: + self.group_management = False + if self.adaptive_cards is _UNSET: + self.adaptive_cards = False + if self.tasks is _UNSET: + self.tasks = False + if self.events is _UNSET: + self.events = False + if self.notes is _UNSET: + self.notes = False + if self.task_modules is _UNSET: + self.task_modules = False + if self.messaging_extensions is _UNSET: + self.messaging_extensions = False + if self.incoming_webhooks is _UNSET: + self.incoming_webhooks = False + if self.bot_state is _UNSET: + self.bot_state = False + if self.streaming is _UNSET: + self.streaming = False + if self.block_streaming is _UNSET: + self.block_streaming = False + if self.sse_support is _UNSET: + self.sse_support = False + if self.polling_fallback is _UNSET: + self.polling_fallback = False + + # 数值类型字段默认值(来源:config/defaults.py) + if self.preview_stream_throttle_ms is _UNSET: + self.preview_stream_throttle_ms = STREAMING_PREVIEW_THROTTLE_MS + if self.preview_min_initial_chars is _UNSET: + self.preview_min_initial_chars = STREAMING_PREVIEW_MIN_INITIAL_CHARS + + if self.block_streaming_break is _UNSET: + self.block_streaming_break = BLOCK_STREAMING_BREAK + if self.block_streaming_chunk_min_chars is _UNSET: + self.block_streaming_chunk_min_chars = BLOCK_STREAMING_CHUNK_MIN_CHARS + if self.block_streaming_chunk_max_chars is _UNSET: + self.block_streaming_chunk_max_chars = BLOCK_STREAMING_CHUNK_MAX_CHARS + if self.block_streaming_chunk_break_preference is _UNSET: + self.block_streaming_chunk_break_preference = BLOCK_STREAMING_CHUNK_BREAK_PREFERENCE + if self.block_streaming_coalesce_idle_ms is _UNSET: + self.block_streaming_coalesce_idle_ms = BLOCK_STREAMING_COALESCE_IDLE_MS + if self.sse_max_reasoning_chars is _UNSET: + self.sse_max_reasoning_chars = SSE_MAX_REASONING_CHARS + + # 校验流式传输模式是否合法 + if self.streaming_mode is not None and self.streaming_mode not in STREAMING_MODES: + warnings.warn( + f"Unknown streaming_mode: {self.streaming_mode!r}. Known modes: {STREAMING_MODES}", + stacklevel=3, + ) + + # 校验块级流式分割断点是否合法 + if self.block_streaming_break not in BLOCK_STREAMING_BREAKS: + warnings.warn( + f"Unknown block_streaming_break: {self.block_streaming_break!r}. " + f"Known breaks: {BLOCK_STREAMING_BREAKS}", + stacklevel=3, + ) + + # 校验块字符数范围合理性 + if self.block_streaming_chunk_min_chars > self.block_streaming_chunk_max_chars: + raise ValueError( + f"block_streaming_chunk_min_chars ({self.block_streaming_chunk_min_chars}) " + f"must be <= block_streaming_chunk_max_chars ({self.block_streaming_chunk_max_chars})" + ) + + # 块级流式传输仅在流式传输开启时生效 + if self.block_streaming and not self.streaming: + warnings.warn( + "block_streaming=True but streaming=False, block_streaming has no effect", + stacklevel=3, + ) + + # 校验合并块字符数范围合理性 + if self.block_streaming_coalesce_min_chars is not None and self.block_streaming_coalesce_max_chars is not None: + if self.block_streaming_coalesce_min_chars > self.block_streaming_coalesce_max_chars: + raise ValueError( + f"block_streaming_coalesce_min_chars ({self.block_streaming_coalesce_min_chars}) " + f"must be <= block_streaming_coalesce_max_chars ({self.block_streaming_coalesce_max_chars})" + ) + + def to_dict(self) -> dict: + """将能力配置转换为字典格式。""" + return { + "chat_types": list(self.chat_types), + "message_types": list(self.message_types), + "interactions": list(self.interactions), + "reactions": self.reactions, + "typing_indicator": self.typing_indicator, + "threads": self.threads, + "edit": self.edit, + "unsend": self.unsend, + "reply": self.reply, + "media": self.media, + "effects": self.effects, + "native_commands": self.native_commands, + "polls": self.polls, + "pins": self.pins, + "group_management": self.group_management, + "adaptive_cards": self.adaptive_cards, + "tasks": self.tasks, + "events": self.events, + "notes": self.notes, + "task_modules": self.task_modules, + "messaging_extensions": self.messaging_extensions, + "incoming_webhooks": self.incoming_webhooks, + "bot_state": self.bot_state, + "streaming": self.streaming, + "streaming_mode": self.streaming_mode, + "preview_stream_throttle_ms": self.preview_stream_throttle_ms, + "preview_min_initial_chars": self.preview_min_initial_chars, + "block_streaming": self.block_streaming, + "block_streaming_break": self.block_streaming_break, + "block_streaming_chunk_min_chars": self.block_streaming_chunk_min_chars, + "block_streaming_chunk_max_chars": self.block_streaming_chunk_max_chars, + "block_streaming_chunk_break_preference": self.block_streaming_chunk_break_preference, + "block_streaming_coalesce_min_chars": self.block_streaming_coalesce_min_chars, + "block_streaming_coalesce_max_chars": self.block_streaming_coalesce_max_chars, + "block_streaming_coalesce_idle_ms": self.block_streaming_coalesce_idle_ms, + "sse_support": self.sse_support, + "sse_max_reasoning_chars": self.sse_max_reasoning_chars, + "polling_fallback": self.polling_fallback, + "tts": dict(self.tts) if self.tts is not None else None, + } + + def override_with(self, overrides: ChannelCapabilities | dict) -> ChannelCapabilities: + """使用另一组能力配置覆盖当前配置,返回新的 ChannelCapabilities 实例。""" + if isinstance(overrides, dict): + override_dict: dict | None = overrides + overrides = ChannelCapabilities(**overrides) + else: + override_dict = None + + if not isinstance(overrides, ChannelCapabilities): + raise TypeError(f"Expected ChannelCapabilities or dict, got {type(overrides).__name__}") + + def _merge_list(base: list[str], override: list[str]) -> list[str]: + """合并两个列表并去重,保持原有顺序。""" + return list(dict.fromkeys(base + override)) + + def _should_override(name: str) -> bool: + """判断指定字段是否需要被覆盖。""" + if override_dict is not None: + return name in override_dict + raw = getattr(overrides, "_raw_values", None) + if raw is None: + return True + raw_value = raw.get(name) + if raw_value is _UNSET: + return False + field_default = _FIELD_DEFAULTS.get(name) + if field_default is not _UNSET: + return raw_value != field_default + return True + + return ChannelCapabilities( + chat_types=_merge_list(self.chat_types, overrides.chat_types), + message_types=_merge_list(self.message_types, overrides.message_types), + interactions=_merge_list(self.interactions, overrides.interactions), + reactions=overrides.reactions if _should_override("reactions") else self.reactions, + typing_indicator=overrides.typing_indicator if _should_override("typing_indicator") else self.typing_indicator, + threads=overrides.threads if _should_override("threads") else self.threads, + edit=overrides.edit if _should_override("edit") else self.edit, + unsend=overrides.unsend if _should_override("unsend") else self.unsend, + reply=overrides.reply if _should_override("reply") else self.reply, + media=overrides.media if _should_override("media") else self.media, + effects=overrides.effects if _should_override("effects") else self.effects, + native_commands=overrides.native_commands if _should_override("native_commands") else self.native_commands, + polls=overrides.polls if _should_override("polls") else self.polls, + pins=overrides.pins if _should_override("pins") else self.pins, + group_management=overrides.group_management if _should_override("group_management") else self.group_management, + adaptive_cards=overrides.adaptive_cards if _should_override("adaptive_cards") else self.adaptive_cards, + tasks=overrides.tasks if _should_override("tasks") else self.tasks, + events=overrides.events if _should_override("events") else self.events, + notes=overrides.notes if _should_override("notes") else self.notes, + task_modules=overrides.task_modules if _should_override("task_modules") else self.task_modules, + messaging_extensions=overrides.messaging_extensions if _should_override("messaging_extensions") else self.messaging_extensions, + incoming_webhooks=overrides.incoming_webhooks if _should_override("incoming_webhooks") else self.incoming_webhooks, + bot_state=overrides.bot_state if _should_override("bot_state") else self.bot_state, + streaming=overrides.streaming if _should_override("streaming") else self.streaming, + streaming_mode=overrides.streaming_mode if _should_override("streaming_mode") else self.streaming_mode, + preview_stream_throttle_ms=overrides.preview_stream_throttle_ms if _should_override("preview_stream_throttle_ms") else self.preview_stream_throttle_ms, + preview_min_initial_chars=overrides.preview_min_initial_chars if _should_override("preview_min_initial_chars") else self.preview_min_initial_chars, + block_streaming=overrides.block_streaming if _should_override("block_streaming") else self.block_streaming, + block_streaming_break=overrides.block_streaming_break if _should_override("block_streaming_break") else self.block_streaming_break, + block_streaming_chunk_min_chars=overrides.block_streaming_chunk_min_chars if _should_override("block_streaming_chunk_min_chars") else self.block_streaming_chunk_min_chars, + block_streaming_chunk_max_chars=overrides.block_streaming_chunk_max_chars if _should_override("block_streaming_chunk_max_chars") else self.block_streaming_chunk_max_chars, + block_streaming_chunk_break_preference=overrides.block_streaming_chunk_break_preference if _should_override("block_streaming_chunk_break_preference") else self.block_streaming_chunk_break_preference, + block_streaming_coalesce_min_chars=overrides.block_streaming_coalesce_min_chars if _should_override("block_streaming_coalesce_min_chars") else self.block_streaming_coalesce_min_chars, + block_streaming_coalesce_max_chars=overrides.block_streaming_coalesce_max_chars if _should_override("block_streaming_coalesce_max_chars") else self.block_streaming_coalesce_max_chars, + block_streaming_coalesce_idle_ms=overrides.block_streaming_coalesce_idle_ms if _should_override("block_streaming_coalesce_idle_ms") else self.block_streaming_coalesce_idle_ms, + sse_support=overrides.sse_support if _should_override("sse_support") else self.sse_support, + sse_max_reasoning_chars=overrides.sse_max_reasoning_chars if _should_override("sse_max_reasoning_chars") else self.sse_max_reasoning_chars, + polling_fallback=overrides.polling_fallback if _should_override("polling_fallback") else self.polling_fallback, + tts=overrides.tts if _should_override("tts") else self.tts, + ) + + def supports(self, feature: str) -> bool: + """检查是否支持指定功能。""" + return bool(getattr(self, feature, False)) + + def get_action_registry(self, plugin: object) -> MessageActionRegistry: + """根据当前能力配置生成消息动作注册表。""" + from yuxi.channel.sdk.actions import MessageAction, MessageActionRegistry + + registry = MessageActionRegistry() + + base_actions = {MessageAction.SEND, MessageAction.SEND_MEDIA} + if self.reply: + base_actions.add(MessageAction.REPLY) + + if self.edit: + base_actions.add(MessageAction.EDIT) + if self.unsend: + base_actions.add(MessageAction.UNSEND) + + if self.reactions: + base_actions.add(MessageAction.REACT) + base_actions.add(MessageAction.REACT_REMOVE) + base_actions.add(MessageAction.REACTIONS) + + if self.pins: + base_actions.add(MessageAction.PIN) + base_actions.add(MessageAction.UNPIN) + + if self.polls: + base_actions.add(MessageAction.POLL) + base_actions.add(MessageAction.POLL_VOTE) + + if self.group_management: + base_actions |= { + MessageAction.RENAME_GROUP, + MessageAction.ADD_PARTICIPANT, + MessageAction.REMOVE_PARTICIPANT, + MessageAction.MEMBER_INFO, + MessageAction.PERMISSIONS, + } + + if self.threads: + base_actions |= { + MessageAction.THREAD_CREATE, + MessageAction.THREAD_LIST, + MessageAction.THREAD_REPLY, + MessageAction.TOPIC_CREATE, + MessageAction.TOPIC_EDIT, + } + + for action in base_actions: + registry.register(action, lambda **kwargs: {"success": True}) + + return registry + + def derive_from_adapter(self, adapter) -> ChannelCapabilities: + """从适配器推导能力配置。""" + actions = {a.value for a in adapter.list_actions()} + return ChannelCapabilities( + chat_types=self.chat_types, + message_types=self.message_types, + reactions="react" in actions or "react_remove" in actions, + edit="edit" in actions, + unsend="unsend" in actions, + polls="poll" in actions or "poll_vote" in actions, + pins="pin" in actions or "unpin" in actions, + group_management=( + "add_participant" in actions + or "remove_participant" in actions + or "rename_group" in actions + ), + threads="thread_reply" in actions or "thread_create" in actions or "thread_list" in actions, + media="send_media" in actions, + reply="reply" in actions, + ) + + def get_effective_streaming_mode(self) -> str: + """获取实际生效的流式传输模式。""" + if not self.streaming: + return "none" + if self.streaming_mode: + return self.streaming_mode + return "preview" + + def get_chunk_limit(self) -> int: + """获取分块大小限制。""" + return 4096 + + def get_effective_block_config(self) -> dict: + """获取实际生效的块级流式传输配置。""" + text_limit = self.get_chunk_limit() + + chunk_min = max(1, self.block_streaming_chunk_min_chars) + chunk_max = max(chunk_min, min(self.block_streaming_chunk_max_chars, text_limit)) + + coalesce_min = self.block_streaming_coalesce_min_chars or chunk_min + coalesce_max = self.block_streaming_coalesce_max_chars or chunk_max + coalesce_min = min(coalesce_min, coalesce_max) + coalesce_max = min(coalesce_max, chunk_max) + + coalesce_idle_ms = max(0, min(self.block_streaming_coalesce_idle_ms, 5000)) + + return { + "chunk": { + "min_chars": chunk_min, + "max_chars": chunk_max, + "break_preference": self.block_streaming_chunk_break_preference, + }, + "coalesce": { + "min_chars": coalesce_min, + "max_chars": coalesce_max, + "idle_ms": coalesce_idle_ms, + }, + "text_limit": text_limit, + } + + +# 构建字段默认值映射表,用于 override_with 中的覆盖判断 +_FIELD_DEFAULTS: dict[str, object] = {} +for _f in fields(ChannelCapabilities): + if _f.default is not MISSING: + _FIELD_DEFAULTS[_f.name] = _f.default + elif _f.default_factory is not MISSING: + _FIELD_DEFAULTS[_f.name] = _f.default_factory() diff --git a/backend/package/yuxi/channel/context.py b/backend/package/yuxi/channel/context.py new file mode 100644 index 00000000..d5c25468 --- /dev/null +++ b/backend/package/yuxi/channel/context.py @@ -0,0 +1,215 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from enum import StrEnum +from collections.abc import Callable +from typing import Any + + +@dataclass +class ChannelContext: + """单个通道请求/会话的上下文对象,承载一次交互所需的全部运行时信息。""" + + channel_type: str # 通道类型标识,例如 "feishu"、"wechat" + account_id: str # 该通道下的账户唯一标识 + cancel_event: asyncio.Event = field(default_factory=asyncio.Event) # 用于取消当前任务的异步事件 + config: dict = field(default_factory=dict) # 通道级配置字典 + queue: asyncio.Queue[Any] | None = None # 可选的消息队列,用于异步流式回传 + logger: logging.Logger | None = None # 专用日志器,__post_init__ 中自动初始化 + request_id: str | None = None # 可选的链路追踪请求 ID + + def __post_init__(self): + # 若调用方未传入 logger,则按 "yuxi.channel.{channel_type}.{account_id}" 的命名规范自动创建 + if self.logger is None: + self.logger = logging.getLogger(f"yuxi.channel.{self.channel_type}.{self.account_id}") + + +@dataclass(slots=True) +class ConversationBinding: + """会话绑定关系,描述一条消息所属的对话层级结构。""" + + channel: str # 通道标识 + account_id: str # 账户标识 + conversation_id: str # 当前会话/对话 ID + parent_conversation_id: str | None = None # 父会话 ID,用于支持嵌套会话场景 + thread_id: str | None = None # 线程 ID,用于支持话题(thread)级别的细分 + + +class ContextVisibilityMode(StrEnum): + """补充上下文可见性控制模式,决定哪些附加信息可以进入大模型上下文。""" + + ALL = "all" # 全部放行,不做过滤 + ALLOWLIST = "allowlist" # 仅允许发送者在白名单中的内容 + ALLOWLIST_QUOTE = "allowlist_quote" # 白名单逻辑,但引用(quote)类型始终放行 + NONE = "none" # 全部拒绝(通常仅保留系统级提示) + + +def _evaluate_supplemental_visibility( + mode: ContextVisibilityMode, + kind: str, + sender_allowed: bool, +) -> bool: + """根据可见性模式判断某类补充上下文是否应被保留。 + + 参数: + mode: 当前采用的可见性模式。 + kind: 上下文类型,如 "quote"、"forwarded"、"thread"。 + sender_allowed: 发送者是否已在白名单中。 + """ + if mode == ContextVisibilityMode.ALL: + return True + if sender_allowed: + return True + if mode == ContextVisibilityMode.ALLOWLIST_QUOTE and kind == "quote": + return True + return False + + +@dataclass(slots=True) +class SupplementalContext: + """补充上下文,包含引用、转发、话题线程等可能来自外部的不受信信息。 + + 在注入大模型前,应通过 filter_by_visibility() 按业务策略进行过滤, + 防止不可信内容污染系统提示或诱导模型产生错误回复。 + """ + + quote: dict | None = None # 引用消息上下文 + forwarded: dict | None = None # 转发消息上下文 + thread: dict | None = None # 话题/线程上下文 + untrusted_context: list[dict] | None = None # 其他未受信上下文列表 + group_system_prompt: str | None = None # 群组级别的系统提示(通常不受可见性过滤影响) + + def filter_by_visibility(self, mode: ContextVisibilityMode) -> "SupplementalContext": + """按指定可见性模式过滤当前实例,返回过滤后的新 SupplementalContext。 + + untrusted_context 与 group_system_prompt 不参与过滤,始终保留。 + """ + if mode == ContextVisibilityMode.ALL: + return self + + quote = self.quote + forwarded = self.forwarded + thread = self.thread + + if not _evaluate_supplemental_visibility(mode, "quote", bool(quote and quote.get("sender_allowed"))): + quote = None + if not _evaluate_supplemental_visibility( + mode, "forwarded", bool(forwarded and forwarded.get("sender_allowed")) + ): + forwarded = None + if not _evaluate_supplemental_visibility(mode, "thread", bool(thread and thread.get("sender_allowed"))): + thread = None + + return SupplementalContext( + quote=quote, + forwarded=forwarded, + thread=thread, + untrusted_context=self.untrusted_context, + group_system_prompt=self.group_system_prompt, + ) + + def is_empty(self) -> bool: + """判断当前补充上下文是否不包含任何有效信息。""" + return not any((self.quote, self.forwarded, self.thread, self.untrusted_context, self.group_system_prompt)) + + +class ChannelRuntimeContexts: + """跨生命周期状态注册表, keyed by (channel_id, account_id, capability). + + 借鉴 openclaw 的 ChannelRuntimeContextRegistry 模式, + 允许插件和模块在不产生紧耦合的情况下动态共享运行时状态。 + + Usage:: + + registry = ChannelRuntimeContexts() + dispose = registry.register("feishu", "account1", "gateway_state", {"connected": True}) + state = registry.get("feishu", "account1", "gateway_state") + dispose() + """ + + def __init__(self): + # 内部存储结构: key -> (token, context) + # token 用于在并发 dispose 时做安全校验,防止误删后续重新注册的同名条目 + self._store: dict[str, tuple[int, Any]] = {} + self._counter = 0 + + @staticmethod + def _make_key(channel_id: str, account_id: str, capability: str) -> str: + """使用 \0 拼接三元组,避免不同字段组合产生 key 冲突。""" + return f"{channel_id}\0{account_id}\0{capability}" + + def register( + self, + channel_id: str, + account_id: str, + capability: str, + context: Any, + ) -> Callable[[], None]: + """注册一个运行时上下文,返回 dispose 闭包用于手动注销。 + + 注意: + - 同一 (channel_id, account_id, capability) 重复注册将覆盖旧值。 + - dispose 是幂等的,多次调用无副作用。 + """ + key = self._make_key(channel_id, account_id, capability) + self._counter += 1 + token = self._counter + self._store[key] = (token, context) + disposed = False + + def dispose(): + nonlocal disposed + if disposed: + return + disposed = True + entry = self._store.get(key) + # 仅当条目存在且 token 匹配时才删除,防止并发覆盖后误删新条目 + if entry is None or entry[0] != token: + return + del self._store[key] + + return dispose + + def get(self, channel_id: str, account_id: str, capability: str) -> Any | None: + """获取指定三元组对应的运行时上下文,若不存在则返回 None。""" + entry = self._store.get(self._make_key(channel_id, account_id, capability)) + return entry[1] if entry is not None else None + + def dispose_all(self): + """清空全部注册状态并重置计数器,通常用于服务关闭或测试清理。""" + self._store.clear() + self._counter = 0 + + +@asynccontextmanager +async def task_scoped_contexts(registry: ChannelRuntimeContexts): + """异步上下文管理器, 在退出时自动 dispose 作用域内所有注册。 + + Usage:: + + registry = ChannelRuntimeContexts() + async with task_scoped_contexts(registry) as scoped: + scoped.register("feishu", "main", "temp", data) + # 退出时自动清理 + """ + leases: list[Callable[[], None]] = [] + + class _Scoped: + """作用域内暴露的注册代理,自动收集所有 dispose 句柄以便退出时统一清理。""" + + def register(self, channel_id: str, account_id: str, capability: str, context: Any): + dispose = registry.register(channel_id, account_id, capability, context) + leases.append(dispose) + return dispose + + try: + yield _Scoped() + finally: + # 按后进先出顺序清理,尽量保证依赖关系正确释放 + for dispose in reversed(leases): + try: + dispose() + except Exception: + # 清理异常不应阻断后续释放流程 + pass diff --git a/backend/package/yuxi/channel/errors.py b/backend/package/yuxi/channel/errors.py new file mode 100644 index 00000000..e5592445 --- /dev/null +++ b/backend/package/yuxi/channel/errors.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging +from typing import Any + +from yuxi.channel.protocols import ClassifiedError, ErrorSeverity, ErrorHandlingProtocol + +logger = logging.getLogger(__name__) + + +def capture_error( + logger_instance: logging.Logger, + error: BaseException, + *, + message: str = "", + context: dict[str, Any] | None = None, + level: str = "error", +) -> ClassifiedError: + if level == "warning": + logger_instance.warning( + "%s: %s | context=%s", + message or "Operation failed", + error, + context or {}, + exc_info=True, + ) + else: + logger_instance.exception(message or "Operation failed") + + return ClassifiedError( + severity=_infer_severity(error), + original_error=error, + error_message=str(error), + ) + + +def classify_error(error: BaseException) -> ClassifiedError: + return ClassifiedError( + severity=_infer_severity(error), + original_error=error, + error_message=str(error), + ) + + +def is_retryable(error: BaseException) -> bool: + return _infer_severity(error) == ErrorSeverity.RETRYABLE + + +def should_backoff(error: BaseException, attempt: int) -> int: + if not is_retryable(error): + return 0 + base_ms = 1000 + max_ms = 30000 + delay_ms = min(base_ms * (2 ** (attempt - 1)), max_ms) + return delay_ms + + +def _infer_severity(error: BaseException) -> ErrorSeverity: + import asyncio + import builtins + + if isinstance(error, asyncio.TimeoutError): + return ErrorSeverity.NETWORK + if isinstance(error, builtins.TimeoutError): + return ErrorSeverity.NETWORK + if isinstance(error, ConnectionError): + return ErrorSeverity.NETWORK + if isinstance(error, PermissionError): + return ErrorSeverity.FORBIDDEN + if isinstance(error, (ValueError, TypeError, KeyError, IndexError, AttributeError)): + return ErrorSeverity.FATAL + if isinstance(error, asyncio.CancelledError): + return ErrorSeverity.FATAL + return ErrorSeverity.RETRYABLE + + +class DefaultErrorHandler(ErrorHandlingProtocol): + def classify_error(self, error: BaseException) -> ClassifiedError: + return classify_error(error) + + def is_retryable(self, error: BaseException) -> bool: + return is_retryable(error) + + def should_backoff(self, error: BaseException, attempt: int) -> int: + return should_backoff(error, attempt) \ No newline at end of file diff --git a/backend/package/yuxi/channel/protocols.py b/backend/package/yuxi/channel/protocols.py new file mode 100644 index 00000000..19272a9d --- /dev/null +++ b/backend/package/yuxi/channel/protocols.py @@ -0,0 +1,2332 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable + +from yuxi.channel.context import ChannelContext + +if TYPE_CHECKING: + from yuxi.channel.capabilities import ChannelCapabilities + from yuxi.channel.doctor.models import ( + ConnectivityCheckResult, + CredentialCheckResult, + DiagnosisResult, + DiagnosisWarning, + LegacyConfigRule, + PermissionCheckResult, + RepairResult, + RepairStep, + ) + + +_SUPPORTED_SCHEMA_TYPES: Final[frozenset[str]] = frozenset( + {"string", "number", "integer", "boolean", "object", "array", "null"} +) + +# ── Base ───────────────────────────────────────────────── + + +@runtime_checkable +class MetaProtocol(Protocol): + id: str + name: str + order: int + + +@runtime_checkable +class CapabilitiesProtocol(Protocol): + capabilities: ChannelCapabilities + + +# ── Config ─────────────────────────────────────────────── + + +@runtime_checkable +class ConfigProtocol(Protocol): + def list_account_ids(self, config: dict) -> list[str]: ... + async def resolve_account(self, account_id: str) -> dict: ... + def is_configured(self, account: dict) -> bool: ... + + def inspect_account(self, config: dict, account_id: str | None = None) -> dict: ... + def default_account_id(self, config: dict) -> str: ... + def set_account_enabled(self, config: dict, account_id: str, enabled: bool) -> dict: ... + def delete_account(self, config: dict, account_id: str) -> dict: ... + def is_enabled(self, account: dict, config: dict) -> bool: ... + def disabled_reason(self, account: dict, config: dict) -> str: ... + def unconfigured_reason(self, account: dict, config: dict) -> str: ... + def describe_account(self, account: dict, config: dict) -> dict: ... + def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str | int] | None: ... + def format_allow_from(self, config: dict, account_id: str | None, allow_from: list[str | int]) -> list[str]: ... + def has_configured_state(self, config: dict) -> bool: ... + def has_persisted_auth_state(self, config: dict) -> bool: ... + def resolve_default_to(self, config: dict, account_id: str | None = None) -> str | None: ... + + +# ── Gateway ────────────────────────────────────────────── + + +@runtime_checkable +class GatewayProtocol(Protocol): + async def start(self, ctx: ChannelContext) -> Any: ... + async def stop(self, ctx: ChannelContext) -> None: ... + + def resolve_gateway_auth_bypass_paths(self, config: dict) -> list[str]: ... + + async def login_with_qr_start( + self, account_id: str | None = None, *, force: bool = False, timeout_ms: int | None = None + ) -> dict: + """返回 {"qr_data_url": str, "message": str, "connected": bool}""" + ... + + async def login_with_qr_wait( + self, account_id: str | None = None, *, timeout_ms: int | None = None, current_qr_data_url: str | None = None + ) -> dict: + """返回 {"connected": bool, "message": str, "qr_data_url": str | None}""" + ... + + async def logout_account(self, ctx: ChannelContext) -> dict: + """返回 {"cleared": bool, "logged_out": bool | None}""" + ... + + +# ── Outbound ───────────────────────────────────────────── + + +class OutboundDeliveryMode(StrEnum): + DIRECT = "direct" + GATEWAY = "gateway" + HYBRID = "hybrid" + + +@dataclass +class OutboundPresentationCapabilities: + supported: bool = False + buttons: bool = False + selects: bool = False + context: bool = False + divider: bool = False + + +@dataclass +class OutboundDeliveryCapabilities: + pin: bool = False + durable_final_text: bool = False + durable_final_media: bool = False + durable_final_payload: bool = False + durable_final_silent: bool = False + durable_final_reply_to: bool = False + durable_final_thread: bool = False + durable_final_native_quote: bool = False + durable_final_message_sending_hooks: bool = False + durable_final_batch: bool = False + durable_final_reconcile_unknown_send: bool = False + durable_final_after_send_success: bool = False + durable_final_after_commit: bool = False + + +@runtime_checkable +class OutboundProtocol(Protocol): + delivery_mode: OutboundDeliveryMode + + def chunker(self, text: str, limit: int, ctx: Any | None = None) -> list[str]: ... + + chunker_mode: str | None + text_chunk_limit: int | None + + async def send_text( + self, + target_id: str, + content: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: ... + async def send_media( + self, + target_id: str, + media_url: str, + media_type: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: ... + async def send_payload(self, ctx: Any) -> Any: ... + async def send_poll(self, ctx: Any) -> Any: ... + + async def edit_message( + self, + target_id: str, + message_id: str, + content: str, + *, + thread_id: str | None = None, + account_id: str | None = None, + ) -> str | None: ... + async def send_card( + self, + target_id: str, + card_content: dict, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> str | None: ... + async def edit_card( + self, + target_id: str, + message_id: str, + card_content: dict, + *, + thread_id: str | None = None, + account_id: str | None = None, + ) -> str | None: ... + + def sanitize_text(self, text: str, payload: Any) -> str: ... + def should_skip_plain_text_sanitization(self, payload: Any) -> bool: ... + + poll_max_options: int | None + supports_poll_duration_seconds: bool + supports_anonymous_polls: bool + extract_markdown_images: bool + + def normalize_payload(self, payload: Any, config: dict, account_id: str | None = None) -> Any | None: ... + def resolve_effective_text_chunk_limit( + self, config: dict, account_id: str | None = None, fallback_limit: int | None = None + ) -> int | None: ... + + presentation_capabilities: OutboundPresentationCapabilities | None + delivery_capabilities: OutboundDeliveryCapabilities | None + + async def render_presentation(self, payload: Any, presentation: Any, ctx: Any) -> Any | None: ... + async def pin_delivered_message(self, config: dict, target_ref: Any, message_id: str, pin: Any) -> None: ... + + async def before_deliver_payload( + self, config: dict, target_ref: Any, payload: Any, hint: Any | None = None + ) -> None: ... + async def after_deliver_payload(self, config: dict, target_ref: Any, payload: Any, results: list) -> None: ... + + def resolve_target( + self, + to: str | None = None, + *, + config: dict | None = None, + allow_from: list[str] | None = None, + account_id: str | None = None, + mode: str | None = None, + ) -> tuple[bool, str]: + """返回 (ok, to) 或 (False, error_message)""" + ... + + def should_treat_delivered_text_as_visible(self, kind: str, text: str | None = None) -> bool: ... + + +# ── Status ─────────────────────────────────────────────── + + +@dataclass +class ChannelAccountSnapshot: + account_id: str + name: str | None = None + enabled: bool | None = None + configured: bool | None = None + status_state: str | None = None + linked: bool | None = None + running: bool | None = None + connected: bool | None = None + restart_pending: bool | None = None + reconnect_attempts: int | None = None + last_connected_at: float | None = None + last_disconnect: dict | None = None + last_message_at: float | None = None + last_event_at: float | None = None + last_transport_activity_at: float | None = None + last_error: str | None = None + health_state: str | None = None + last_start_at: float | None = None + last_stop_at: float | None = None + last_inbound_at: float | None = None + last_outbound_at: float | None = None + busy: bool | None = None + active_runs: int | None = None + last_run_activity_at: float | None = None + mode: str | None = None + dm_policy: str | None = None + allow_from: list[str] | None = None + probe: Any | None = None + last_probe_at: float | None = None + audit: Any | None = None + + +@dataclass +class ChannelStatusIssue: + channel: str + account_id: str + kind: str + message: str + fix: str | None = None + + +@runtime_checkable +class StatusProtocol(Protocol): + default_runtime: ChannelAccountSnapshot | None + + async def probe(self, account: dict | None = None) -> bool: ... + def build_summary(self, snapshot: Any) -> dict: ... + + def build_channel_summary( + self, + account: dict, + config: dict, + default_account_id: str, + snapshot: ChannelAccountSnapshot, + ) -> dict: ... + def format_capabilities_probe(self, probe: Any) -> list[dict]: ... + async def audit_account( + self, account: dict, timeout_ms: int, config: dict, probe: Any | None = None + ) -> Any: ... + async def build_capabilities_diagnostics( + self, + account: dict, + timeout_ms: int, + config: dict, + probe: Any | None = None, + audit: Any | None = None, + target: str | None = None, + ) -> dict | None: ... + def build_account_snapshot( + self, + account: dict, + config: dict, + runtime: ChannelAccountSnapshot | None = None, + probe: Any | None = None, + audit: Any | None = None, + ) -> ChannelAccountSnapshot: ... + def log_self_id( + self, account: dict, config: dict, runtime: Any, include_channel_prefix: bool = False + ) -> None: ... + def resolve_account_state( + self, + account: dict, + config: dict, + configured: bool, + enabled: bool, + ) -> str: ... + def collect_status_issues(self, accounts: list[ChannelAccountSnapshot]) -> list[ChannelStatusIssue]: ... + + +def build_standard_summary( + snapshot: ChannelAccountSnapshot, + channel_id: str, +) -> dict: + return { + "channel": channel_id, + "account_id": snapshot.account_id, + "name": snapshot.name, + "enabled": snapshot.enabled, + "configured": snapshot.configured, + "running": snapshot.running, + "connected": snapshot.connected, + "health_state": snapshot.health_state, + "last_message_at": snapshot.last_message_at, + "last_event_at": snapshot.last_event_at, + "active_runs": snapshot.active_runs, + "busy": snapshot.busy, + "dm_policy": snapshot.dm_policy, + "allow_from": snapshot.allow_from, + "mode": snapshot.mode, + "last_error": snapshot.last_error, + } + + +# ── Security ───────────────────────────────────────────── + + +@runtime_checkable +class SecurityProtocol(Protocol): + async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: ... + def resolve_dm_policy(self) -> dict: ... + + def apply_config_fixes(self, config: dict) -> dict: ... + def collect_warnings( + self, config: dict, account_id: str | None = None, account: dict | None = None + ) -> list[str]: ... + def collect_audit_findings( + self, + config: dict, + account_id: str | None = None, + account: dict | None = None, + source_config: dict | None = None, + ordered_account_ids: list[str] | None = None, + has_explicit_account_path: bool = False, + ) -> list[dict]: ... + + +# ── Pairing ────────────────────────────────────────────── + + +@runtime_checkable +class PairingProtocol(Protocol): + id_label: str + + async def generate_code(self, peer_id: str) -> str: ... + async def verify_code(self, peer_id: str, code: str) -> bool: ... + + def normalize_allow_entry(self, entry: str) -> str: ... + async def notify_approval(self, config: dict, peer_id: str, account_id: str | None = None) -> None: ... + + +# ── Groups ─────────────────────────────────────────────── + + +@dataclass +class ChannelGroupContext: + config: dict + group_id: str | None = None + group_channel: str | None = None + group_space: str | None = None + account_id: str | None = None + sender_id: str | None = None + sender_name: str | None = None + sender_username: str | None = None + sender_e164: str | None = None + + +@runtime_checkable +class GroupsProtocol(Protocol): + async def list_groups(self) -> list[dict]: ... + + def resolve_require_mention(self, ctx: ChannelGroupContext) -> bool | None: ... + def resolve_group_intro_hint(self, ctx: ChannelGroupContext) -> str | None: ... + def resolve_tool_policy(self, ctx: ChannelGroupContext) -> dict | None: ... + + +# ── Mentions ───────────────────────────────────────────── + + +@runtime_checkable +class MentionsProtocol(Protocol): + def extract_mentions(self, raw_message: dict) -> list[str]: ... + + def strip_mentions( + self, text: str, ctx: object, config: dict | None = None, agent_id: str | None = None + ) -> str: ... + def strip_regexes(self, ctx: object, config: dict | None = None, agent_id: str | None = None) -> list: ... + def strip_patterns(self, ctx: object, config: dict | None = None, agent_id: str | None = None) -> list[str]: ... + + +# ── Streaming ──────────────────────────────────────────── + + +class SseEventType(StrEnum): + REASONING = "reasoning" + DELTA = "delta" + TOOL_START = "tool_start" + TOOL_END = "tool_end" + MESSAGE_END = "message_end" + AGENT_END = "agent_end" + PHASE = "phase" + IMAGE = "image" + FILE = "file" + VIDEO = "video" + DONE = "done" + ERROR = "error" + + +@runtime_checkable +class StreamingEventProtocol(Protocol): + def build_streaming_events(self, agent_output: dict) -> list[dict]: ... + + @property + def supported_event_types(self) -> list[str]: ... + + +@runtime_checkable +class StreamingProtocol(Protocol): + streaming_mode: str + preview_stream_throttle_ms: int + preview_min_initial_chars: int + block_streaming_enabled: bool + block_streaming_break: str + block_streaming_chunk_min_chars: int + block_streaming_chunk_max_chars: int + block_streaming_chunk_break_preference: str + block_streaming_coalesce_defaults: dict | None + + def create_draft_stream_session(self, target_id: str) -> object: ... + def create_block_chunker(self) -> object: ... + + +# ── Health ─────────────────────────────────────────────── + + +@runtime_checkable +class HealthProtocol(Protocol): + async def health_check(self) -> bool: ... + + +# ── Message ────────────────────────────────────────────── + + +@runtime_checkable +class MessageProtocol(Protocol): + async def send_message(self, message: dict) -> None: ... + + +# ── Routing ────────────────────────────────────────────── + + +@runtime_checkable +class RoutingProtocol(Protocol): + pass + + +# ── Startup ────────────────────────────────────────────── + + +@runtime_checkable +class StartupProtocol(Protocol): + async def on_startup(self, config: dict) -> None: ... + + +# ── Transport ──────────────────────────────────────────── + + +@runtime_checkable +class TransportProtocol(Protocol): + async def connect(self) -> None: ... + async def disconnect(self) -> None: ... + + +# ── Lifecycle ──────────────────────────────────────────── + + +@runtime_checkable +class LifecycleProtocol(Protocol): + async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None: ... + async def on_account_removed(self, account_id: str) -> None: ... + async def run_startup_maintenance(self, cfg: dict) -> None: ... + async def on_retire(self) -> None: ... + + def detect_legacy_state_migrations( + self, + config: dict, + state_dir: str, + oauth_dir: str, + ) -> list[dict]: ... + + +# ── Secrets ────────────────────────────────────────────── + + +@runtime_checkable +class SecretsProtocol(Protocol): + """密钥注册表与运行时配置注入适配器""" + + secret_target_registry_entries: list | None + unsupported_secret_ref_surface_patterns: list[str] | None + + def collect_unsupported_secret_ref_config_candidates(self, raw: object) -> list[dict]: ... + def collect_runtime_config_assignments( + self, + config: dict, + defaults: object | None, + context: object, + ) -> None: ... + + +# ── Approval ───────────────────────────────────────────── + + +class ApprovalAction(StrEnum): + APPROVE_EXEC = "approve_exec" + APPROVE_READ = "approve_read" + + +class ApprovalDecision(StrEnum): + APPROVED = "approved" + DENIED = "denied" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +@dataclass +class ApprovalRequest: + id: str + channel_type: str + account_id: str + initiator_peer_id: str + action: ApprovalAction + description: str + context: dict = field(default_factory=dict) + created_at: float = field(default_factory=time.monotonic) + expires_at: float | None = None + + +@dataclass +class ApprovalResult: + request_id: str + decision: ApprovalDecision + decided_by: str | None = None + decided_at: float = field(default_factory=time.monotonic) + reason: str | None = None + + +@dataclass +class ApprovalForwardTarget: + channel: str + to: str + account_id: str | None = None + thread_id: str | int | None = None + source: str | None = None + + +class ApprovalKind(StrEnum): + EXEC = "exec" + PLUGIN = "plugin" + + +@runtime_checkable +class ApprovalProtocol(Protocol): + async def check_approval_required(self, config: dict, action: ApprovalAction, initiator_peer_id: str) -> bool: ... + async def create_approval_request( + self, config: dict, action: ApprovalAction, initiator_peer_id: str, description: str, context: dict + ) -> ApprovalRequest: ... + async def send_approval_notification( + self, config: dict, request: ApprovalRequest, approver_peer_ids: list[str] + ) -> bool: ... + async def check_approval_status(self, config: dict, request_id: str) -> ApprovalResult | None: ... + def get_approver_ids(self, config: dict) -> list[str]: ... + + # ── Capability 扩展 ── + def authorize_actor_action( + self, + config: dict, + account_id: str | None, + sender_id: str | None, + action: str, + approval_kind: str, + ) -> dict: + """返回 {"authorized": bool, "reason": str | None}""" + ... + + def get_action_availability_state( + self, + config: dict, + account_id: str | None, + action: str, + approval_kind: str | None = None, + ) -> dict: + """返回 {"kind": "enabled"|"disabled"|"unsupported"}""" + ... + + def get_exec_initiating_surface_state( + self, + config: dict, + account_id: str | None, + action: str, + ) -> dict: ... + + def resolve_approve_command_behavior( + self, + config: dict, + account_id: str | None, + sender_id: str | None, + approval_kind: ApprovalKind, + ) -> dict | None: ... + + # ── Delivery ── + def has_configured_dm_route(self, config: dict) -> bool: ... + def should_suppress_forwarding_fallback( + self, + config: dict, + approval_kind: str, + target: ApprovalForwardTarget, + request: object, + ) -> bool: ... + + # ── Render ── + def build_exec_pending_payload( + self, + config: dict, + request: object, + target: ApprovalForwardTarget, + now_ms: int, + ) -> object | None: ... + def build_exec_resolved_payload( + self, + config: dict, + resolved: object, + target: ApprovalForwardTarget, + ) -> object | None: ... + + # ── Describe ── + def describe_exec_approval_setup( + self, + channel: str, + channel_label: str, + account_id: str | None = None, + ) -> str | None: ... + + +# ── Commands ───────────────────────────────────────────── + + +@dataclass +class ChannelCommand: + name: str + aliases: list[str] = field(default_factory=list) + description: str = "" + usage: str = "" + requires_admin: bool = False + handler: str = "" + + +@runtime_checkable +class CommandsProtocol(Protocol): + def get_commands(self) -> list[ChannelCommand]: ... + async def handle_command(self, config: dict, command_name: str, args: list[str], msg: Any, ctx: Any) -> str: ... + + enforce_owner_for_commands: bool + skip_when_config_empty: bool + native_commands_auto_enabled: bool + native_skills_auto_enabled: bool + + def resolve_native_command_name(self, command_key: str, default_name: str) -> str | None: ... + def build_commands_list_channel_data( + self, + current_page: int, + total_pages: int, + agent_id: str | None = None, + ) -> object | None: ... + def build_models_menu_channel_data(self, providers: list[dict]) -> object | None: ... + def build_models_provider_channel_data(self, providers: list[dict]) -> object | None: ... + def build_models_add_provider_channel_data(self, providers: list[dict]) -> object | None: ... + def build_models_list_channel_data( + self, + provider: str, + models: list[str], + current_model: str | None = None, + current_page: int = 0, + total_pages: int = 0, + page_size: int | None = None, + model_names: dict | None = None, + ) -> object | None: ... + def build_model_browse_channel_data(self) -> object | None: ... + + +# ── Resolver ───────────────────────────────────────────── + + +class ResolveKind(StrEnum): + USER = "user" + GROUP = "group" + + +@dataclass +class ResolveTarget: + name: str + resolved_id: str | None = None + kind: str = "peer" + confidence: float = 0.0 + candidates: list[dict] = field(default_factory=list) + + +@runtime_checkable +class ResolverProtocol(Protocol): + async def resolve_targets( + self, + config: dict, + inputs: list[str], + kind: ResolveKind, + account_id: str | None = None, + ) -> list[ResolveTarget]: ... + + async def resolve_peer(self, config: dict, name_or_alias: str) -> ResolveTarget: ... + async def resolve_group(self, config: dict, name_or_alias: str) -> ResolveTarget: ... + + +# ── Doctor ─────────────────────────────────────────────── + + +@dataclass +class DoctorConfigMutation: + config: dict + changes: list[str] = field(default_factory=list) + warnings: list[str] | None = None + + +@runtime_checkable +class DoctorProtocol(Protocol): + """配置诊断与自动修复适配器""" + + async def check_credentials(self, config: dict) -> CredentialCheckResult: + """验证凭证有效性(AppKey/Secret 是否可获取 token)""" + ... + + async def check_permissions(self, config: dict) -> PermissionCheckResult: + """验证 API 权限(是否有收发消息权限)""" + ... + + async def check_connectivity(self, config: dict) -> ConnectivityCheckResult: + """连接测试(WebSocket/HTTP 端点可达性)""" + ... + + def normalize_compatibility_config(self, config: dict) -> dict: + """旧版配置格式兼容性迁移""" + ... + + def collect_allowlist_warnings(self, allowlist_config: dict) -> list[DiagnosisWarning]: + """白名单配置安全告警""" + ... + + def generate_repair_plan(self, diagnosis: DiagnosisResult) -> list[RepairStep]: + """根据诊断结果生成修复计划""" + ... + + async def execute_repair(self, step: RepairStep, config: dict) -> RepairResult: + """执行单个修复步骤""" + ... + + def legacy_config_rules(self) -> list[LegacyConfigRule]: + """声明旧版配置规则(供自动迁移使用)""" + ... + + @property + def dm_allow_from_mode(self) -> str: + """DM 白名单模式""" + ... + + @property + def group_model(self) -> str: + """群组权限模型""" + ... + + group_allow_from_fallback_to_allow_from: bool + warn_on_empty_group_sender_allowlist: bool + + def collect_preview_warnings(self, config: dict, doctor_fix_command: str) -> list[str]: ... + def collect_mutable_allowlist_warnings(self, config: dict) -> list[str]: ... + def repair_config(self, config: dict, doctor_fix_command: str) -> DoctorConfigMutation: ... + def run_config_sequence(self, config: dict, should_repair: bool) -> dict: ... + def clean_stale_config(self, config: dict) -> DoctorConfigMutation: ... + def collect_empty_allowlist_extra_warnings(self, ctx: dict) -> list[str]: ... + def should_skip_default_empty_group_allowlist_warning(self, ctx: dict) -> bool: ... + + +# ── Directory ──────────────────────────────────────────── + + +@dataclass +class DirectoryPeer: + """目录中的对等体(用户/机器人)""" + + id: str + display_name: str + kind: str = "user" + avatar_url: str | None = None + mention_name: str | None = None + extra: dict = field(default_factory=dict) + + +@dataclass +class DirectoryGroup: + """目录中的群组/频道""" + + id: str + display_name: str + kind: str = "group" + member_count: int = 0 + extra: dict = field(default_factory=dict) + + +@dataclass +class DirectoryEntry: + """统一目录条目(对应 OpenClaw ChannelDirectoryEntry)""" + + kind: str + id: str + name: str | None = None + handle: str | None = None + avatar_url: str | None = None + rank: int | None = None + raw: object | None = None + + +@dataclass +class DirectorySearchParams: + """目录搜索参数""" + + query: str + kind: str = "user" + limit: int = 20 + offset: int = 0 + include_bots: bool = False + + +@dataclass +class DirectorySearchResult: + """目录搜索结果""" + + items: list[DirectoryPeer | DirectoryGroup] + total: int + has_more: bool + offset: int = 0 + + +@runtime_checkable +class DirectoryProtocol(Protocol): + """用户/群组/频道目录查询适配器""" + + async def self(self, config: dict, account_id: str | None = None) -> DirectoryEntry | None: + """获取 bot 自身的目录条目""" + ... + + async def list_peers(self, config: dict) -> list[DirectoryPeer]: + """列出可用用户/联系人(静态,从配置解析)""" + ... + + async def list_groups(self, config: dict) -> list[DirectoryGroup]: + """列出可用群组/频道(静态,从配置解析)""" + ... + + async def list_peers_live( + self, + config: dict, + account_id: str | None = None, + query: str | None = None, + limit: int | None = None, + ) -> list[DirectoryEntry]: + """在线 API 查询用户/联系人""" + ... + + async def list_groups_live( + self, + config: dict, + account_id: str | None = None, + query: str | None = None, + limit: int | None = None, + ) -> list[DirectoryEntry]: + """在线 API 查询群组/频道""" + ... + + async def list_group_members( + self, + config: dict, + group_id: str, + account_id: str | None = None, + limit: int | None = None, + ) -> list[DirectoryEntry]: + """列出群组成员""" + ... + + async def search_peers(self, config: dict, params: DirectorySearchParams) -> DirectorySearchResult: + """搜索用户/联系人(在线,渠道 API 查询)""" + ... + + async def search_groups(self, config: dict, params: DirectorySearchParams) -> DirectorySearchResult: + """搜索群组/频道(在线,渠道 API 查询)""" + ... + + async def get_peer(self, config: dict, peer_id: str) -> DirectoryPeer | None: + """根据 ID 获取单个用户详情""" + ... + + async def get_group(self, config: dict, group_id: str) -> DirectoryGroup | None: + """根据 ID 获取单个群组详情""" + ... + + +# ── Heartbeat ──────────────────────────────────────────── + + +@runtime_checkable +class HeartbeatProtocol(Protocol): + """心跳保活 + typing 指示器适配器""" + + @property + def ping_interval_ms(self) -> int: + """心跳间隔(毫秒),默认 30000""" + ... + + async def check_ready( + self, + config: dict, + account_id: str | None = None, + deps: object | None = None, + ) -> dict: + """返回 {"ok": bool, "reason": str}""" + ... + + async def on_ping(self, account: dict) -> bool: + """执行心跳 ping,返回是否正常""" + ... + + async def send_typing( + self, + config: dict, + to: str, + account_id: str | None = None, + thread_id: str | int | None = None, + deps: object | None = None, + ) -> None: ... + + async def clear_typing( + self, + config: dict, + to: str, + account_id: str | None = None, + thread_id: str | int | None = None, + deps: object | None = None, + ) -> None: ... + + async def start_typing(self, target_id: str, account: dict) -> object: + """开始 typing 指示器,返回可用于 stop 的句柄""" + ... + + async def stop_typing(self, handle: object) -> None: + """停止 typing 指示器""" + ... + + async def typing_indicator( + self, + target_id: str, + account: dict, + _stop_event, + ) -> None: + """完整 typing 生命周期:start → keepalive → stop""" + ... + + +# ── Agent Prompt ───────────────────────────────────────── + + +@dataclass +class AgentPromptContext: + channel_type: str + account_id: str + peer_id: str | None = None + peer_name: str | None = None + group_id: str | None = None + group_name: str | None = None + thread_id: str | None = None + + +@runtime_checkable +class AgentPromptProtocol(Protocol): + """渠道特有的 Agent 提示信息注入 + + 不同渠道有不同的消息格式和交互约束,通过此协议向 LLM 注入 + 渠道特定的系统提示,让 Agent 知道当前在哪个渠道、有哪些能力可用。 + """ + + def build_system_prompt(self, context: AgentPromptContext) -> str | None: + """构建渠道特定的系统提示(注入到 Agent system prompt)""" + ... + + def build_context_note(self, context: AgentPromptContext) -> str: + """构建当前消息的上下文提示(注入到 user message 前缀)""" + ... + + @property + def channel_format_instructions(self) -> str | None: + """渠道消息格式说明(如支持 Markdown、卡片格式、字数限制)""" + ... + + def message_tool_hints(self, config: dict, account_id: str | None = None) -> list[str]: ... + def message_tool_capabilities(self, config: dict, account_id: str | None = None) -> list[str] | None: ... + def inbound_formatting_hints(self, account_id: str | None = None) -> dict | None: ... + def reaction_guidance(self, config: dict, account_id: str | None = None) -> dict | None: ... + + +# ── Threading ──────────────────────────────────────────── + + +@dataclass +class ThreadInfo: + thread_id: str + parent_message_id: str | None = None + title: str | None = None + is_threaded: bool = True + + +@dataclass +class ReplyTransport: + mode: str = "inline" + thread_id: str | None = None + target_id: str | None = None + + +@runtime_checkable +class ThreadingProtocol(Protocol): + """线程/话题解析适配器""" + + def extract_thread_id(self, msg: object) -> str | None: + """从原始消息提取线程/话题 ID""" + ... + + def resolve_reply_transport(self, msg: object, thread_id: str | None) -> ReplyTransport: + """决定回复是以线程内回复还是广播方式""" + ... + + def get_thread_info(self, thread_id: str) -> ThreadInfo | None: + """获取线程详情""" + ... + + def resolve_reply_to_mode( + self, + config: dict, + account_id: str | None = None, + chat_type: str | None = None, + ) -> str: + """返回 "off"|"first"|"all"|"batched" """ + ... + + allow_explicit_reply_tags_when_off: bool + + def build_tool_context( + self, + config: dict, + account_id: str | None, + context: object, + has_replied_ref: object | None = None, + ) -> object | None: ... + + def resolve_auto_thread_id( + self, + config: dict, + account_id: str | None, + to: str, + tool_context: object | None = None, + reply_to_id: str | None = None, + ) -> str | None: ... + + def resolve_focused_binding( + self, + config: dict, + account_id: str | None, + context: object, + ) -> object | None: ... + + +# ── Messaging ──────────────────────────────────────────── + + +@dataclass +class SessionResolution: + """会话归属解析结果""" + + kind: str + conversation_id: str + label: str | None = None + thread_id: str | None = None + explicit_target: str | None = None + + +@dataclass +class BoundReplyPayload: + """渠道感知的回复载荷""" + + target_id: str + content: str + reply_to_id: str | None = None + thread_id: str | None = None + routing_hint: dict | None = None + metadata: dict | None = None + + +@runtime_checkable +class MessagingProtocol(Protocol): + """会话感知路由适配器 + + 渠道通过此协议自定义消息归属、会话路由、回复载荷构造。 + 不实现此协议的渠道使用默认逻辑(DM/群二分 + 纯文本回复)。 + """ + + def resolve_session(self, msg: object) -> SessionResolution: + """确定消息归属哪个会话""" + ... + + def parse_explicit_target(self, content: str) -> str | None: + """解析 @Bot 后缀的显式路由目标""" + ... + + def build_reply_payload( + self, + target_id: str, + content: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + session_key: str | None = None, + agent_config_id: str | None = None, + **kwargs, + ) -> BoundReplyPayload: + """构建渠道感知的回复载荷""" + ... + + def resolve_focused_binding(self, msg: object, session: SessionResolution, current_binding: object) -> dict | None: + """会话内临时路由重定向(如话题内切换 Agent)""" + ... + + @property + def supported_explicit_targets(self) -> list[str]: + """支持的显式路由目标关键词""" + ... + + def build_attachment_refs(self, attachments: list[dict]) -> str: + """将附件列表转换为文本引用 + + 输入: [{"file_path": "/tmp/img.png", "file_type": "image", "file_name": "img.png"}] + 输出: "\\n[图片: /tmp/img.png]\\n[文件: /tmp/doc.pdf]" + """ + ... + + # ── 扩展方法 ── + target_prefixes: list[str] | None + + def normalize_target(self, raw: str) -> str | None: ... + + default_markdown_table_mode: str | None + + def normalize_explicit_session_key(self, session_key: str, ctx: object) -> str | None: ... + def derive_legacy_session_chat_type(self, session_key: str) -> str | None: ... + def is_legacy_group_session_key(self, key: str) -> bool: ... + def canonicalize_legacy_session_key(self, key: str, agent_id: str) -> str | None: ... + def resolve_legacy_group_session_key(self, ctx: object) -> dict | None: ... + + def resolve_inbound_attachment_roots(self, config: dict, account_id: str | None = None) -> list[str]: ... + def resolve_remote_inbound_attachment_roots(self, config: dict, account_id: str | None = None) -> list[str]: ... + + def resolve_inbound_conversation( + self, + from_: str | None = None, + to: str | None = None, + conversation_id: str | None = None, + thread_id: str | int | None = None, + is_group: bool = False, + ) -> dict | None: ... + + def resolve_delivery_target( + self, + conversation_id: str, + parent_conversation_id: str | None = None, + ) -> dict | None: ... + + def resolve_session_conversation(self, kind: str, raw_id: str) -> dict | None: ... + def resolve_session_target( + self, + kind: str, + id: str, + thread_id: str | None = None, + ) -> str | None: ... + + def parse_explicit_target_full(self, raw: str) -> dict | None: ... + def infer_target_chat_type(self, to: str) -> str | None: ... + + preserve_heartbeat_thread_id_for_group_route: bool + + def build_cross_context_presentation( + self, origin_label: str, message: str, config: dict, account_id: str | None = None + ) -> object: ... + def transform_reply_payload( + self, payload: object, config: dict, account_id: str | None = None + ) -> object | None: ... + def enable_interactive_replies(self, config: dict, account_id: str | None = None) -> bool: ... + def has_structured_reply_payload(self, payload: object) -> bool: ... + + def target_resolver_looks_like_id(self, raw: str, normalized: str | None = None) -> bool: ... + async def target_resolver_resolve_target( + self, + config: dict, + input_: str, + normalized: str, + account_id: str | None = None, + preferred_kind: str | None = None, + ) -> dict | None: ... + + def format_target_display(self, target: str, display: str | None = None, kind: str | None = None) -> str: ... + + async def resolve_outbound_session_route( + self, + config: dict, + agent_id: str, + target: str, + account_id: str | None = None, + current_session_key: str | None = None, + resolved_target: dict | None = None, + reply_to_id: str | None = None, + thread_id: str | int | None = None, + ) -> object | None: ... + + +# ── Hooks ──────────────────────────────────────────────── + + +@runtime_checkable +class MessageSendingHook(Protocol): + """入站消息发送前钩子 + + 渠道插件可实现在回复发送前拦截/修改消息内容。 + """ + + async def on_message_sending(self, msg: object, content: str) -> str | None: + """回复发送前钩子,返回 None 表示阻止发送,返回 str 表示替换内容""" + ... + + +@runtime_checkable +class InboundMessageHook(Protocol): + """入站消息接收钩子 + + 渠道插件可在消息被调度前进行预处理(过滤/转换)。 + """ + + async def on_message_received(self, msg: object) -> object | None: + """消息接收后、调度前钩子,返回 None 表示丢弃消息""" + ... + + +@runtime_checkable +class SendLifecycleHook(Protocol): + """发送生命周期钩子 + + 渠道插件可监听发送各阶段事件。 + """ + + async def before_send_attempt(self, target_id: str, content: str) -> None: + """发送前钩子""" + ... + + async def after_send_success(self, target_id: str, receipt: object) -> None: + """发送成功后钩子""" + ... + + async def after_send_failure(self, target_id: str, error: str) -> None: + """发送失败后钩子""" + ... + + async def after_commit(self, receipt: object) -> None: + """提交确认后钩子""" + ... + + +# ── Config Schema ──────────────────────────────────────── + + +@runtime_checkable +class ConfigSchemaProtocol(Protocol): + """渠道配置 JSON Schema 定义 + + 用于前端表单生成和配置校验。每个渠道声明自己的配置结构。 + """ + + def config_schema(self) -> dict: + """返回 JSON Schema (draft-07) 描述的渠道配置结构""" + ... + + +# ── Defaults ───────────────────────────────────────────── + + +@runtime_checkable +class DefaultsProtocol(Protocol): + """渠道默认值适配器 + + 提供渠道级别的默认配置,如 debounce 时长等。 + """ + + @property + def debounce_ms(self) -> int: + """消息去重 debounce 毫秒数,默认 5000""" + ... + + +# ── Reload ─────────────────────────────────────────────── + + +@runtime_checkable +class ReloadProtocol(Protocol): + """配置热重载适配器 + + 渠道声明哪些配置前缀变更时需要触发重新加载。 + """ + + @property + def config_prefixes(self) -> list[str]: + """触发重载的配置键前缀列表""" + ... + + +# ── Setup ──────────────────────────────────────────────── + + +@runtime_checkable +class SetupProtocol(Protocol): + """渠道初始化/安装适配器 + + 渠道首次启用时执行的初始化流程。 + """ + + async def setup(self, config: dict) -> bool: + """执行渠道初始化,返回是否成功""" + ... + + def resolve_account_id( + self, + config: dict, + account_id: str | None = None, + input_: object | None = None, + ) -> str: ... + + def resolve_binding_account_id( + self, + config: dict, + agent_id: str, + account_id: str | None = None, + ) -> str | None: ... + + def apply_account_name(self, config: dict, account_id: str, name: str | None = None) -> dict: ... + def apply_account_config(self, config: dict, account_id: str, input_: object) -> dict: ... + + async def after_account_config_written( + self, + prev_cfg: dict, + cfg: dict, + account_id: str, + input_: object, + runtime: object, + ) -> None: ... + + def validate_input(self, config: dict, account_id: str, input_: object) -> str | None: ... + + single_account_keys_to_move: list[str] | None + named_account_promotion_keys: list[str] | None + + def resolve_single_account_promotion_target(self, channel: dict) -> str | None: ... + + +# ── Setup Wizard ───────────────────────────────────────── + + +@dataclass +class SetupWizardStep: + """安装向导步骤""" + + key: str + label: str + description: str = "" + input_type: str = "text" + required: bool = True + default: str | None = None + options: list[dict] = field(default_factory=list) + + +@runtime_checkable +class SetupWizardProtocol(Protocol): + """渠道安装向导适配器 + + 为渠道提供交互式配置引导流程。 + """ + + def setup_wizard_steps(self) -> list[SetupWizardStep]: + """返回安装向导步骤列表""" + ... + + async def validate_wizard_input(self, step_key: str, value: str) -> str | None: + """验证向导输入,返回错误消息或 None""" + ... + + +# ── Agent Tool ─────────────────────────────────────────── + + +@dataclass +class AgentToolParam: + name: str + type: str = "string" + description: str = "" + required: bool = False + enum: list[str] | None = None + default: Any = None + items_type: str | None = None + additional_properties: bool | None = None + + +@dataclass +class AgentTool: + name: str + description: str + parameters: list[AgentToolParam] = field(default_factory=list) + handler_name: str = "" + scope: str = "current_channel" + + def to_openai_schema(self) -> dict: + properties = {} + required = [] + for p in self.parameters: + prop: dict[str, Any] = {"type": p.type, "description": p.description} + if p.enum: + prop["enum"] = p.enum + if p.type == "array" and p.items_type: + prop["items"] = {"type": p.items_type} + if p.type == "object" and p.additional_properties is not None: + prop["additionalProperties"] = p.additional_properties + properties[p.name] = prop + if p.required: + required.append(p.name) + + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }, + } + + +@runtime_checkable +class AgentToolProtocol(Protocol): + """渠道级 Agent 工具注册适配器 + + 渠道通过此协议向 Agent 暴露可调用的工具,例如飞书的 + wiki/doc/bitable/drive 等渠道专属能力。 + 每个工具包含 schema 定义和 handler 执行逻辑。 + """ + + def get_agent_tools(self) -> list[AgentTool]: + """返回当前渠道注册的 Agent 工具列表""" + ... + + async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict: + """执行 Agent 工具调用 + + Args: + tool_name: 工具名称 + params: 工具参数 + context: 执行上下文,包含 channel_type/account_id/peer_id 等 + + Returns: + {"success": bool, "result": ..., "error": str | None} + """ + ... + + +# ── Message Action ─────────────────────────────────────── + + +@dataclass +class MessageActionCapability: + action: str + description: str = "" + parameters: list[AgentToolParam] = field(default_factory=list) + requires_trusted_sender: bool = False + scope: str = "current_channel" + + +@runtime_checkable +class MessageActionProtocol(Protocol): + """统一消息操作命令适配器 + + 渠道通过此协议声明支持的消息操作命令体系: + send / edit / react / pin / unsend / channel-info 等。 + 与 AgentToolProtocol 协作,Agent 可通过 tool call 执行这些操作。 + """ + + def get_message_actions(self) -> list[MessageActionCapability]: + """返回渠道支持的消息操作列表""" + ... + + async def execute_message_action( + self, + action: str, + params: dict, + context: dict, + ) -> dict: + """执行消息操作 + + Args: + action: 操作名称 (send/edit/react/pin 等) + params: 操作参数 + context: 执行上下文 + + Returns: + {"success": bool, "result": ..., "error": str | None} + """ + ... + + # ── 扩展方法 ── + def describe_message_tool(self, ctx: dict) -> dict | None: ... + def supports_action(self, action: str) -> bool: ... + def resolve_execution_mode(self, action: str) -> str: ... + def requires_trusted_requester_sender(self, action: str, tool_context: object | None = None) -> bool: ... + def extract_tool_send(self, args: dict) -> dict | None: ... + async def prepare_send_payload( + self, ctx: object, to: str, payload: object, reply_to_id: str | None = None, thread_id: str | int | None = None + ) -> object | None: ... + async def handle_action(self, ctx: object) -> dict: ... + + +# ── Auth ───────────────────────────────────────────────── + + +@runtime_checkable +class AuthProtocol(Protocol): + """渠道认证/登录适配器 + + 对应 OpenClaw ChannelAuthAdapter。渠道特有的登录认证流程, + 如 OAuth 授权、QR 码登录、账号密码登录等。 + """ + + async def login(self, config: dict) -> dict: + """执行登录,返回认证凭据""" + ... + + async def logout(self, config: dict) -> None: + """执行登出,清除认证状态""" + ... + + async def refresh_token(self, config: dict) -> dict | None: + """刷新访问令牌,返回新的凭据或 None""" + ... + + async def qr_auth_start(self, config: dict) -> str: + """启动 QR 码认证,返回 QR 码 URL 或数据 + + Returns: + QR 码数据字符串(URL 或 base64) + """ + ... + + async def qr_auth_wait(self, config: dict) -> dict | None: + """等待 QR 码认证扫描结果 + + Returns: + 认证凭据 dict 或 None(超时未扫描) + """ + ... + + +# ── Elevated ───────────────────────────────────────────── + + +@runtime_checkable +class ElevatedProtocol(Protocol): + """渠道提权适配器 + + 对应 OpenClaw ChannelElevatedAdapter。 + 渠道需要以更高权限执行操作时使用(如管理群组、踢人)。 + """ + + async def elevate(self, account: dict, reason: str) -> bool: + """提升当前权限等级 + + Args: + account: 渠道账户配置 + reason: 提权原因 + + Returns: + 是否成功提权 + """ + ... + + async def revoke(self, account: dict) -> None: + """撤销提权""" + ... + + @property + def is_elevated(self) -> bool: + """当前是否处于提权状态""" + ... + + def allow_from_fallback( + self, + config: dict, + account_id: str | None = None, + ) -> list[str | int] | None: ... + + +# ── Allowlist Management ───────────────────────────────── + + +@runtime_checkable +class AllowlistManagementProtocol(Protocol): + """白名单管理适配器 + + 对应 OpenClaw ChannelAllowlistAdapter。 + 渠道通过此协议管理 DM/群组白名单的增删改查。 + """ + + async def list_allow_entries(self, config: dict, scope: str) -> list[str]: + """列出白名单条目 + + Args: + config: 渠道配置 + scope: "dm" 或 "group" + """ + ... + + async def add_allow_entry(self, config: dict, scope: str, entry: str) -> bool: + """添加白名单条目""" + ... + + async def remove_allow_entry(self, config: dict, scope: str, entry: str) -> bool: + """移除白名单条目""" + ... + + async def resolve_allow_from(self, config: dict, account_id: str) -> list[str]: + """解析完整的 allow_from 列表""" + ... + + # ── 扩展方法 ── + def apply_config_edit( + self, + config: dict, + account_id: str | None, + scope: str, + action: str, + entry: str, + ) -> dict | None: + """返回 {"kind": "ok", "changed": bool, "path_label": str, "write_target": object} + 或 {"kind": "invalid-entry"} 或 None""" + ... + + async def read_config(self, config: dict, account_id: str | None = None) -> dict: + """返回 {"dm_allow_from": ..., "group_allow_from": ..., "dm_policy": str, ...}""" + ... + + async def resolve_names( + self, + config: dict, + account_id: str | None, + scope: str, + entries: list[str], + ) -> list[dict]: ... + + def supports_scope(self, scope: str) -> bool: ... + + +# ── Binding Provider ───────────────────────────────────── + + +@dataclass +class BindingConversationRef: + conversation_id: str + parent_conversation_id: str | None = None + + +@runtime_checkable +class BindingProviderProtocol(Protocol): + """Agent 绑定编译与会话匹配适配器 + + 对应 OpenClaw ChannelConfiguredBindingProvider。 + """ + + self_parent_conversation_by_default: bool + + def compile_configured_binding( + self, + binding: dict, + conversation_id: str, + ) -> BindingConversationRef | None: ... + + def match_inbound_conversation( + self, + binding: dict, + compiled_binding: BindingConversationRef, + conversation_id: str, + parent_conversation_id: str | None = None, + ) -> object | None: ... + + def resolve_command_conversation(self, ctx: dict) -> BindingConversationRef | None: ... + + +# ── Conversation Binding ───────────────────────────────── + + +@runtime_checkable +class ConversationBindingProtocol(Protocol): + """会话绑定管理适配器 + + 对应 OpenClaw ChannelConversationBindingSupport。 + """ + + supports_current_conversation_binding: bool + default_top_level_placement: str + + def resolve_conversation_ref( + self, + account_id: str | None, + conversation_id: str, + parent_conversation_id: str | None = None, + thread_id: str | int | None = None, + ) -> dict | None: ... + + async def build_bound_reply_payload( + self, + operation: str, + placement: str, + conversation: dict, + ) -> object | None: ... + + def build_model_override_parent_candidates( + self, + parent_conversation_id: str | None = None, + ) -> list[str] | None: ... + + def should_strip_thread_from_announce_origin(self, requester: dict, entry: dict) -> bool: ... + + def set_idle_timeout_by_session_key( + self, + target_session_key: str, + account_id: str | None, + idle_timeout_ms: int, + ) -> list[dict]: ... + + def set_max_age_by_session_key( + self, + target_session_key: str, + account_id: str | None, + max_age_ms: int, + ) -> list[dict]: ... + + async def create_manager(self, config: dict, account_id: str | None = None) -> object: + """返回 {"stop": callable}""" + ... + + +# ── Message Adapter (Durable Final / Send / Live / Receive) ── + + +class MessageDurabilityPolicy(StrEnum): + REQUIRED = "required" + BEST_EFFORT = "best_effort" + DISABLED = "disabled" + + +@runtime_checkable +class MessageDurableFinalProtocol(Protocol): + """持久化最终投递适配器 + + 对应 OpenClaw ChannelMessageDurableFinalAdapter。 + """ + + capabilities: dict | None + + async def reconcile_unknown_send(self, ctx: object) -> object | None: ... + + +@runtime_checkable +class MessageSendProtocol(Protocol): + """消息发送适配器 + + 对应 OpenClaw ChannelMessageSendAdapter。 + """ + + async def send_text(self, ctx: object) -> object: ... + async def send_media(self, ctx: object) -> object: ... + async def send_payload(self, ctx: object) -> object: ... + + async def before_send_attempt(self, ctx: object) -> object: ... + async def after_send_success(self, ctx: object) -> None: ... + async def after_send_failure(self, ctx: object) -> None: ... + async def after_commit(self, ctx: object) -> None: ... + + +@runtime_checkable +class MessageLiveProtocol(Protocol): + """实时消息/流式预览适配器 + + 对应 OpenClaw ChannelMessageLiveAdapterShape。 + """ + + capabilities: dict | None + + finalizer_capabilities: dict | None + + +@runtime_checkable +class MessageReceiveProtocol(Protocol): + """消息接收适配器 + + 对应 OpenClaw ChannelMessageReceiveAdapterShape。 + """ + + default_ack_policy: str + supported_ack_policies: list[str] | None + + +# ── File Handling ─────────────────────────────────────── + + +@runtime_checkable +class FileHandlingProtocol(Protocol): + """文件上传/服务协议 + + 渠道通过此协议可使用统一的文件上传、分类和服务能力, + 替代各渠道分散实现的文件处理逻辑。 + """ + + async def upload_file(self, file_data: bytes, file_name: str, session_id: str) -> dict: + """上传文件,返回: + { + "file_path": "/abs/path/to/file", + "file_name": "original.png", + "file_type": "image" | "video" | "file", + "preview_url": "/api/files/preview/{uuid}" + } + """ + ... + + async def serve_file(self, file_path: str) -> tuple[bytes, str]: + """服务文件,返回 (content_bytes, mime_type)""" + ... + + @staticmethod + def classify_file(extension: str) -> str: + """根据扩展名分类:image / video / file""" + ... + + +# ── Reaction ───────────────────────────────────────────── + + +@runtime_checkable +class ReactionProtocol(Protocol): + """消息反应/表情协议 + + 渠道通过此协议支持对消息添加/移除表情反应。 + 对应 ChannelCapabilities.reactions 能力标志。 + """ + + async def send_reaction( + self, + target_id: str, + message_id: str, + emoji: str, + *, + account_id: str | None = None, + ) -> None: + """为指定消息添加表情反应""" + ... + + async def remove_reaction( + self, + target_id: str, + message_id: str, + emoji: str, + *, + account_id: str | None = None, + ) -> None: + """移除指定消息的表情反应""" + ... + + async def fetch_reactions( + self, + target_id: str, + message_id: str, + *, + account_id: str | None = None, + ) -> list[dict]: + """获取指定消息的反应列表""" + ... + + +# ── Webhook ────────────────────────────────────────────── + + +@runtime_checkable +class WebhookProtocol(Protocol): + """Webhook 接收处理协议 + + 渠道通过此协议注册和处理外部平台推送的 Webhook 事件。 + """ + + async def handle_webhook( + self, + request: object, + account_id: str | None = None, + ) -> object: + """处理 Webhook 请求,返回 HTTP Response""" + ... + + def verify_signature(self, body: bytes, signature: str, secret: str) -> bool: + """验证 Webhook 签名""" + ... + + def resolve_webhook_auth_bypass_paths(self) -> list[str]: + """返回不需要认证的 Webhook 路径列表""" + ... + + +# ── Inbound Handler ────────────────────────────────────── + + +@runtime_checkable +class InboundHandlerProtocol(Protocol): + """入站消息处理协议 + + 渠道通过此协议将原始平台事件转换为 UnifiedMessage。 + 对应各渠道的 Monitor 类核心职责。 + """ + + async def handle_raw_event( + self, + event: dict, + account: dict, + ) -> object | None: + """处理原始事件,返回 UnifiedMessage 或 None""" + ... + + def parse_to_unified( + self, + raw_event: dict, + account_id: str, + ) -> object | None: + """将原始事件解析为 UnifiedMessage""" + ... + + def resolve_event_type(self, raw_event: dict) -> str: + """解析原始事件类型(message/reaction/join/leave 等)""" + ... + + +# ── Media ──────────────────────────────────────────────── + + +@runtime_checkable +class MediaProtocol(Protocol): + """媒体上传/下载/处理协议 + + 渠道通过此协议统一管理媒体文件的上传、下载、类型检测和 URL 验证。 + """ + + async def upload_media( + self, + file_path: str, + media_type: str, + *, + account_id: str | None = None, + ) -> dict: + """上传媒体文件,返回 {"url": str, "type": str, "size": int}""" + ... + + async def download_media( + self, + media_url: str, + *, + account_id: str | None = None, + ) -> bytes: + """下载媒体文件,返回文件字节""" + ... + + def detect_media_type(self, filename: str, content_type: str = "") -> str: + """根据文件名和 Content-Type 检测媒体类型(image/video/file)""" + ... + + def validate_media_url(self, url: str) -> bool: + """验证媒体 URL 是否有效""" + ... + + def resolve_media_size_limit(self, media_type: str) -> int: + """返回指定媒体类型的最大允许大小(字节)""" + ... + + +# ── Format ─────────────────────────────────────────────── + + +@runtime_checkable +class FormatProtocol(Protocol): + """消息格式化协议 + + 渠道通过此协议统一处理文本清理、Markdown 转换、提及剥离和分块。 + """ + + def sanitize_text(self, text: str, payload: object | None = None) -> str: + """清理文本中的非法字符或格式""" + ... + + def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: + """将长文本分块,返回文本块列表""" + ... + + def markdown_to_native(self, md_text: str) -> dict | str: + """将 Markdown 转换为目标平台的原生格式""" + ... + + def native_to_markdown(self, native_content: dict | str) -> str: + """将目标平台的原生格式转换为 Markdown""" + ... + + def strip_mentions(self, text: str, ctx: object | None = None) -> str: + """从文本中剥离提及标记""" + ... + + +# ── Dedupe ─────────────────────────────────────────────── + + +@runtime_checkable +class DedupeProtocol(Protocol): + """消息去重协议 + + 渠道通过此协议防止重复处理相同的消息或事件。 + """ + + def is_duplicate(self, key: str) -> bool: + """检查给定 key 是否已存在(重复)""" + ... + + def mark_seen(self, key: str) -> None: + """标记给定 key 为已处理""" + ... + + def reset(self) -> None: + """重置去重缓存""" + ... + + @property + def ttl_seconds(self) -> int: + """去重缓存的 TTL(秒)""" + ... + + @property + def max_entries(self) -> int: + """去重缓存的最大条目数""" + ... + + +# ── Card ───────────────────────────────────────────────── + + +@runtime_checkable +class CardProtocol(Protocol): + """卡片/富文本消息协议 + + 渠道通过此协议支持发送和更新结构化卡片消息。 + """ + + async def send_card( + self, + target_id: str, + card_content: dict, + *, + reply_to_id: str | None = None, + account_id: str | None = None, + ) -> str | None: + """发送卡片消息,返回消息 ID""" + ... + + async def update_card( + self, + message_id: str, + card_content: dict, + *, + account_id: str | None = None, + ) -> bool: + """更新已发送的卡片消息""" + ... + + def build_text_card(self, text: str, title: str | None = None, **kwargs) -> dict: + """构建纯文本卡片""" + ... + + def supports_card_interactions(self) -> bool: + """是否支持卡片交互(按钮、选择器等)""" + ... + + +# ── Error Handling ─────────────────────────────────────── + + +class ErrorSeverity(StrEnum): + """错误严重级别""" + + RETRYABLE = "retryable" + FATAL = "fatal" + RATE_LIMITED = "rate_limited" + FORBIDDEN = "forbidden" + NETWORK = "network" + + +@dataclass +class ClassifiedError: + """分类后的错误信息""" + + severity: ErrorSeverity + retry_after_ms: int = 0 + original_error: BaseException | None = None + error_message: str = "" + + +@runtime_checkable +class ErrorHandlingProtocol(Protocol): + """错误分类与重试协议 + + 渠道通过此协议统一处理错误分类、重试策略和退避计算。 + """ + + def classify_error(self, error: BaseException) -> ClassifiedError: + """将异常分类为特定严重级别""" + ... + + def is_retryable(self, error: BaseException) -> bool: + """判断错误是否可重试""" + ... + + def should_backoff(self, error: BaseException, attempt: int) -> int: + """计算重试退避时间(毫秒),返回 0 表示不重试""" + ... + + +# ── Polling ────────────────────────────────────────────── + + +@runtime_checkable +class PollingProtocol(Protocol): + """轮询消息接收协议 + + 渠道通过此协议实现基于轮询的消息接收(替代 Webhook/Websocket)。 + """ + + poll_interval_seconds: int + + async def poll_once(self, account: dict) -> list[dict]: + """执行一次轮询,返回原始事件列表""" + ... + + async def should_poll(self, account: dict) -> bool: + """判断当前是否应该执行轮询""" + ... + + def get_poll_cursor(self, account_id: str) -> str | None: + """获取当前轮询游标""" + ... + + def set_poll_cursor(self, account_id: str, cursor: str) -> None: + """设置轮询游标""" + ... + + +# ── Pin ────────────────────────────────────────────────── + + +@runtime_checkable +class PinProtocol(Protocol): + """消息置顶协议 + + 渠道通过此协议支持消息的置顶和取消置顶操作。 + """ + + async def pin_message( + self, + message_id: str, + *, + account_id: str | None = None, + ) -> bool: + """置顶指定消息""" + ... + + async def unpin_message( + self, + message_id: str, + *, + account_id: str | None = None, + ) -> bool: + """取消置顶指定消息""" + ... + + async def list_pins( + self, + chat_id: str, + *, + account_id: str | None = None, + ) -> list[dict]: + """列出指定聊天的置顶消息""" + ... + + +# ── Websocket ──────────────────────────────────────────── + + +@runtime_checkable +class WebsocketProtocol(Protocol): + """WebSocket 生命周期协议 + + 渠道通过此协议管理 WebSocket 连接的生命周期。 + """ + + async def connect_ws(self, account: dict) -> object: + """建立 WebSocket 连接,返回连接句柄""" + ... + + async def disconnect_ws(self) -> None: + """断开 WebSocket 连接""" + ... + + async def on_ws_message(self, message: str | bytes) -> None: + """处理收到的 WebSocket 消息""" + ... + + @property + def ws_reconnect_interval_ms(self) -> int: + """WebSocket 重连间隔(毫秒)""" + ... + + @property + def ws_heartbeat_interval_ms(self) -> int: + """WebSocket 心跳间隔(毫秒)""" + ... + + +# ── Protocol Composition Type Aliases ──────────────────── +# +# 渠道按能力分层,每类渠道只需标注其实现的能力层级。 +# 使用示例: +# def start_channel(plugin: GatewayPluginProtocol) -> None: ... +# def deliver_message(plugin: FullMessagingPluginProtocol, msg: str) -> None: ... + + +type BasePluginProtocol = MetaProtocol & CapabilitiesProtocol +"""最小插件协议:每个渠道插件必须满足 MetaProtocol + CapabilitiesProtocol""" + +type ConfigPluginProtocol = BasePluginProtocol & ConfigProtocol +"""可配置插件:支持多账户、配置校验、启用/禁用管理""" + +type GatewayPluginProtocol = ConfigPluginProtocol & GatewayProtocol & SecurityProtocol & StatusProtocol & HeartbeatProtocol +"""网关插件:具备网关连接、安全校验、状态探测、心跳保活能力""" + +type OutboundPluginProtocol = GatewayPluginProtocol & OutboundProtocol +"""外发插件:在网关基础上具备消息发送(文本/媒体/卡片/投票)能力""" + +type SessionPluginProtocol = OutboundPluginProtocol & MessagingProtocol & ThreadingProtocol +"""会话感知插件:具备会话路由、线程管理、回复载荷构建能力""" + +type FullMessagingPluginProtocol = ( + SessionPluginProtocol + & StreamingProtocol + & AgentPromptProtocol + & PairingProtocol + & GroupsProtocol + & MentionsProtocol + & LifecycleProtocol +) +"""全功能消息插件:流程式消息 + 会话感知 + 完整生命周期""" + +type WebhookPluginProtocol = BasePluginProtocol & WebhookProtocol & InboundHandlerProtocol +"""Webhook 插件:通过 Webhook 接收消息的渠道""" + +type PollingPluginProtocol = BasePluginProtocol & PollingProtocol +"""轮询插件:通过定时轮询接收消息的渠道""" + +type ApprovalPluginProtocol = BasePluginProtocol & ApprovalProtocol +"""审批插件:支持执行/阅读审批流程的渠道""" diff --git a/backend/package/yuxi/channel/ui/__init__.py b/backend/package/yuxi/channel/ui/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/package/yuxi/channel/ui/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/package/yuxi/channel/ui/schema.py b/backend/package/yuxi/channel/ui/schema.py new file mode 100644 index 00000000..def9273e --- /dev/null +++ b/backend/package/yuxi/channel/ui/schema.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class UIFieldSchema: + """UI 字段定义 — 描述前端表单中单个字段的元数据。""" + + name: str + type: str + label: str = "" + description: str = "" + required: bool = False + default: Any = None + placeholder: str = "" + options: list[dict[str, Any]] = field(default_factory=list) + validation: dict[str, Any] = field(default_factory=dict) + sensitive: bool = False + hidden: bool = False + disabled: bool = False + order: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "type": self.type, + "label": self.label, + "description": self.description, + "required": self.required, + "default": self.default, + "placeholder": self.placeholder, + "options": self.options, + "validation": self.validation, + "sensitive": self.sensitive, + "hidden": self.hidden, + "disabled": self.disabled, + "order": self.order, + } + + +@dataclass +class UIFormSchema: + """UI 表单定义 — 描述前端表单的整体结构。""" + + id: str + title: str = "" + description: str = "" + fields: list[UIFieldSchema] = field(default_factory=list) + submit_label: str = "Save" + cancel_label: str = "Cancel" + layout: str = "vertical" + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "description": self.description, + "fields": [f.to_dict() for f in sorted(self.fields, key=lambda x: x.order)], + "submit_label": self.submit_label, + "cancel_label": self.cancel_label, + "layout": self.layout, + } + + +@dataclass +class UISectionSchema: + """UI 区块定义 — 描述前端页面中的一个区块。""" + + id: str + title: str = "" + description: str = "" + forms: list[UIFormSchema] = field(default_factory=list) + order: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "description": self.description, + "forms": [f.to_dict() for f in self.forms], + "order": self.order, + } + + +@dataclass +class UIPageSchema: + """UI 页面定义 — 描述前端页面的完整结构。""" + + id: str + title: str = "" + description: str = "" + sections: list[UISectionSchema] = field(default_factory=list) + route: str = "" + icon: str = "" + order: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "description": self.description, + "sections": [s.to_dict() for s in sorted(self.sections, key=lambda x: x.order)], + "route": self.route, + "icon": self.icon, + "order": self.order, + } + + +def build_ui_schema_from_config_schema(config_schema: dict[str, Any]) -> UIFormSchema: + """从配置 schema 自动生成 UI 表单 schema。""" + fields: list[UIFieldSchema] = [] + properties = config_schema.get("properties", {}) + required = set(config_schema.get("required", [])) + + for name, prop in properties.items(): + if not isinstance(prop, dict): + continue + + field_type = _map_json_schema_type_to_ui(prop.get("type", "string")) + field = UIFieldSchema( + name=name, + type=field_type, + label=prop.get("title", name.replace("_", " ").title()), + description=prop.get("description", ""), + required=name in required, + default=prop.get("default"), + placeholder=prop.get("examples", [""])[0] if prop.get("examples") else "", + sensitive=_is_sensitive_field(name), + ) + + if "enum" in prop: + field.options = [{"label": str(v), "value": v} for v in prop["enum"]] + + fields.append(field) + + return UIFormSchema( + id=config_schema.get("title", "form"), + title=config_schema.get("title", ""), + description=config_schema.get("description", ""), + fields=fields, + ) + + +def _map_json_schema_type_to_ui(json_type: str | list[str]) -> str: + if isinstance(json_type, list): + json_type = json_type[0] if json_type else "string" + mapping = { + "string": "text", + "integer": "number", + "number": "number", + "boolean": "switch", + "array": "list", + "object": "group", + } + return mapping.get(json_type, "text") + + +def _is_sensitive_field(name: str) -> bool: + sensitive_keywords = {"password", "token", "secret", "key", "api_key", "private"} + return any(keyword in name.lower() for keyword in sensitive_keywords)