From 1701d387e9015d1d0da1926d75f54b6bee63c8f2 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Wed, 8 Jul 2026 22:55:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor(feishu):=20=E9=A3=9E=E4=B9=A6=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=B8=B8=E9=87=8F=E9=9B=86=E4=B8=AD=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新建常量模块统一管理飞书插件共享常量,拆分重复定义的配置与标识 2. 重构配置读取逻辑,统一使用常量化的渠道标识 3. 修复登录适配器配置项键名错误,将qr_login_redirect_uri改为qr_redirect_uri 4. 补充出站适配器续发分片的参数支持与日志逻辑 5. 优化入站适配器的账户ID处理逻辑,修正资源下载URL的账户标识 6. 更新manifest配置,补充传输模式与能力需求声明 7. 完善注释说明,修正文档与实际代码不一致的问题 --- .../channels/plugins/feishu/_constants.py | 49 +++++++++++++++ .../feishu/adapters/inbound_adapter.py | 36 ++++++----- .../plugins/feishu/adapters/login_adapter.py | 14 +++-- .../feishu/adapters/outbound_adapter.py | 59 +++++++++++-------- .../adapters/stream_connector_adapter.py | 12 ++-- .../yuxi/channels/plugins/feishu/entry.py | 4 +- .../channels/plugins/feishu/feishu_client.py | 4 +- .../yuxi/channels/plugins/feishu/lifecycle.py | 17 +++--- .../channels/plugins/feishu/manifest.json | 20 ++++++- 9 files changed, 154 insertions(+), 61 deletions(-) create mode 100644 backend/package/yuxi/channels/plugins/feishu/_constants.py diff --git a/backend/package/yuxi/channels/plugins/feishu/_constants.py b/backend/package/yuxi/channels/plugins/feishu/_constants.py new file mode 100644 index 00000000..9e0c25ad --- /dev/null +++ b/backend/package/yuxi/channels/plugins/feishu/_constants.py @@ -0,0 +1,49 @@ +"""飞书渠道插件共享常量。 + +集中定义在多个模块间共享的配置默认值、依赖标识与渠道标识,避免重复 +定义导致的不一致风险(P2-3 常量去重)。 + +仅汇集真正跨模块共享的常量;仅单一模块使用的常量仍保留在各自模块内。 +所有常量值需与 ``manifest.json`` 中 ``config_schema`` 的 ``default`` 字段 +及 ``resource_quota`` 保持一致(F-03 manifest 单真相源),本模块的常量 +作为 ConfigPort 未命中时的本地回退默认值。 + +依赖方向:仅标准库,不 import 任何框架层或同插件模块,避免循环依赖。 +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# 渠道与依赖标识 +# --------------------------------------------------------------------------- + +# 渠道标识,用于 ConfigPort CHANNEL 作用域 target(与 manifest.json +# channel_type / provides 对齐) +CHANNEL_TARGET = "feishu" + +# 飞书 API 依赖标识,统一用于 ``DependencyError.dep`` 字段 +FEISHU_API_DEP = "feishu_api" + +# --------------------------------------------------------------------------- +# HTTP 客户端配置(lifecycle 与 feishu_client 共享) +# --------------------------------------------------------------------------- + +# HTTP 客户端默认超时(秒),对齐 manifest.json config_schema +# request_timeout_ms.default=30000 +HTTP_TIMEOUT_SECONDS = 30.0 + +# HTTP 连接池最大连接数,对齐 manifest.json +# resource_quota.max_connections=50 +HTTP_MAX_CONNECTIONS = 50 + +# HTTP 连接池 keepalive 连接数上限 +HTTP_MAX_KEEPALIVE_CONNECTIONS = 10 + + +__all__ = [ + "CHANNEL_TARGET", + "FEISHU_API_DEP", + "HTTP_TIMEOUT_SECONDS", + "HTTP_MAX_CONNECTIONS", + "HTTP_MAX_KEEPALIVE_CONNECTIONS", +] diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/inbound_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/inbound_adapter.py index c4f11701..1454df76 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/inbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/inbound_adapter.py @@ -28,10 +28,9 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from .. import signature +from .._constants import CHANNEL_TARGET from ..feishu_client import FeishuClient -_CHANNEL_TYPE = "feishu" - # 飞书 msg_type → MessageFormat 映射(§3.4) _TEXT_MSG_TYPE = "text" _RICH_MSG_TYPES = frozenset( @@ -107,10 +106,15 @@ class FeishuInboundAdapter: content_str = message.get("content", "{}") message_id = message.get("message_id", "") + # 资源下载 URL 中的 account_id 必须为内部账户 ID(由 + # BaseTransportWorker._deliverMessage 自动填充到 RawEvent.account_id), + # 而非飞书事件 header 中的 app_id(应用标识 cli_xxx), + # 否则 downloadAttachment 调用 ConfigPort 查凭据时 target 不匹配。 + account_id = raw_event.account_id or "" text, fmt, attachments = _parse_message_content( msg_type, content_str, - app_id, + account_id, message_id, ) @@ -266,7 +270,7 @@ class FeishuInboundAdapter: cv = await self._config.get( "encrypt_key", scope=ConfigScope.CHANNEL, - target=_CHANNEL_TYPE, + target=CHANNEL_TARGET, ) value = cv.value return value if value else None @@ -319,7 +323,7 @@ def _extract_raw_body(payload: dict[str, Any]) -> str: def _parse_message_content( msg_type: str, content_str: str, - app_id: str, + account_id: str, message_id: str, ) -> tuple[str, MessageFormat, tuple[Attachment, ...]]: """按 msg_type 解析消息内容,返回 (text, format, attachments)。 @@ -338,7 +342,7 @@ def _parse_message_content( return content.get("text", ""), MessageFormat.TEXT, () if msg_type in _RICH_MSG_TYPES: - return _parse_rich_content(msg_type, content, app_id, message_id) + return _parse_rich_content(msg_type, content, account_id, message_id) return "", MessageFormat.TEXT, () @@ -346,7 +350,7 @@ def _parse_message_content( def _parse_rich_content( msg_type: str, content: dict[str, Any], - app_id: str, + account_id: str, message_id: str, ) -> tuple[str, MessageFormat, tuple[Attachment, ...]]: """解析富消息内容(image/file/audio/media/post/merge_forward/interactive/sticker)。""" @@ -354,7 +358,7 @@ def _parse_rich_content( file_key = content.get("image_key", "") attachment = _build_resource_attachment( "image", - app_id, + account_id, message_id, file_key, "image", @@ -366,7 +370,7 @@ def _parse_rich_content( filename = content.get("file_name", "") attachment = _build_resource_attachment( "file", - app_id, + account_id, message_id, file_key, "file", @@ -379,7 +383,7 @@ def _parse_rich_content( duration_ms = content.get("duration") attachment = _build_resource_attachment( "audio", - app_id, + account_id, message_id, file_key, "file", @@ -392,7 +396,7 @@ def _parse_rich_content( filename = content.get("file_name", "") attachment = _build_resource_attachment( "video", - app_id, + account_id, message_id, file_key, "file", @@ -404,7 +408,7 @@ def _parse_rich_content( file_key = content.get("file_key", "") attachment = _build_resource_attachment( "image", - app_id, + account_id, message_id, file_key, "image", @@ -426,7 +430,7 @@ def _parse_rich_content( def _build_resource_attachment( attach_type: str, - app_id: str, + account_id: str, message_id: str, file_key: str, file_type: str, @@ -437,10 +441,14 @@ def _build_resource_attachment( """构造飞书资源附件,URL 编码下载所需信息。 URL 格式:``feishu://resource?account_id=xxx&message_id=om_xxx&file_key=xxx&type=image`` + + ``account_id`` 为内部账户 ID(由 ``RawEvent.account_id`` 提供),供 + ``downloadAttachment`` 调用 ``FeishuClient.download_resource`` 时通过 + ``ConfigPort`` 查询该账户的凭据。 """ url = ( f"{_RESOURCE_SCHEME}://{_RESOURCE_HOST}" - f"?account_id={app_id}" + f"?account_id={account_id}" f"&message_id={message_id}" f"&file_key={file_key}" f"&type={file_type}" diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/login_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/login_adapter.py index 6cf22d65..f8c37abc 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/login_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/login_adapter.py @@ -40,9 +40,9 @@ from yuxi.channels.contract.errors import ( from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort +from .._constants import CHANNEL_TARGET from ..feishu_client import FeishuClient -_CHANNEL_TYPE = "feishu" _DEFAULT_ACCOUNT = "default" _LOGIN_ID_FRAGMENT_PREFIX = "login_id=" _POLL_INTERVAL_SECONDS = 1.0 @@ -85,9 +85,9 @@ class FeishuLoginAdapter: """ acct = account_id if account_id is not None else _DEFAULT_ACCOUNT cv_redirect = await self._config.get( - "qr_login_redirect_uri", + "qr_redirect_uri", scope=ConfigScope.CHANNEL, - target=_CHANNEL_TYPE, + target=CHANNEL_TARGET, ) redirect_uri = cv_redirect.value state = uuid.uuid4().hex @@ -137,7 +137,13 @@ class FeishuLoginAdapter: while True: try: data = await self._client.poll_qr_login_status(acct, login_id) - except Exception: + except Exception as exc: + await self._logger.warn( + "feishu qr poll failed", + account_id=acct, + login_id=login_id, + error=str(exc), + ) return QrLoginWaitResult( connected=False, message="qr_expired", diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/outbound_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/outbound_adapter.py index 3356d149..da85607a 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/outbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/outbound_adapter.py @@ -171,6 +171,8 @@ class FeishuOutboundAdapter: account_id: str, peer_id: str, payload: FormattedMessage, + *, + delivered_parts: list[int] | None = None, ) -> list[MultiPartReceipt]: """发送多部分消息,返回各分片回执列表(FR-22)。 @@ -180,6 +182,10 @@ class FeishuOutboundAdapter: - 分片 2+(附件):逐个发送,单个附件失败时标记 ``success=False`` 并继续后续分片(对齐契约 ``@post`` 部分失败仍返回列表)。 + H-15 续发:``delivered_parts`` 含已成功投递的分片序号,适配器跳过 + 这些序号对应的分片,仅发送未投递分片。重试场景下首个分片已投递时 + 跳过发送,直接从附件分片续发。 + @post 返回 ``MultiPartReceipt`` 列表,长度等于实际尝试的分片数; 部分分片失败时仍返回全部回执,调用方据 ``success`` 字段判定。 @failure 首个分片失败或依赖服务不可用时抛 ``DependencyError``; @@ -189,39 +195,46 @@ class FeishuOutboundAdapter: receipts: list[MultiPartReceipt] = [] sent_ids: list[str] = [] seq = 0 + skip_seqs = set(delivered_parts) if delivered_parts else set() # 分片 1:文本或卡片(必发,失败回滚并抛异常) first_part = _build_first_part(payload) if first_part is not None: - part_type, msg_type, content_str = first_part - try: - message_id = await self._send_part( - account_id, - peer_id, - receive_id_type, - msg_type, - content_str, - sent_ids, + if seq in skip_seqs: + seq += 1 + else: + part_type, msg_type, content_str = first_part + try: + message_id = await self._send_part( + account_id, + peer_id, + receive_id_type, + msg_type, + content_str, + sent_ids, + ) + except Error: + raise + except Exception as exc: + await self._rollback(account_id, sent_ids) + raise DependencyError(dep="feishu_api", cause=exc) from exc + receipts.append( + MultiPartReceipt( + platform_msg_id=message_id, + part_type=part_type, + sequence=seq, + success=True, + ) ) - except Error: - raise - except Exception as exc: - await self._rollback(account_id, sent_ids) - raise DependencyError(dep="feishu_api", cause=exc) from exc - receipts.append( - MultiPartReceipt( - platform_msg_id=message_id, - part_type=part_type, - sequence=seq, - success=True, - ) - ) - seq += 1 + seq += 1 # 分片 2+:附件(单个失败标记 success=False 继续) for attachment in payload.attachments: if not attachment.content: continue + if seq in skip_seqs: + seq += 1 + continue try: message_id = await self._send_attachment( account_id, diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py index c22b7bdc..9eb59d4c 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py @@ -37,8 +37,9 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort +from .._constants import CHANNEL_TARGET + _DEFAULT_WS_ENDPOINT_PATH = "/open-apis/event/v1/get_ws_endpoint" -_CHANNEL_TYPE = "feishu" _ACK_TIMEOUT = 3.0 @@ -69,7 +70,8 @@ class FeishuStreamConnectorAdapter: """建立飞书 WS 长连接,返回 ``StreamConnection``。 参数: - account_id: 飞书 app_id(渠道账户 ID)。 + account_id: 渠道账户 ID(内部账户标识,非飞书 app_id;由 + TransportManager 按 ChannelAccount.account_id 传入)。 token: per-account CancellationToken(飞书连接不直接使用, 由 StreamWorker 管理取消)。 @@ -77,7 +79,7 @@ class FeishuStreamConnectorAdapter: TransportError: 账户不存在、凭据缺失或连接失败。 """ account = await self._persistence.getChannelAccount( - ChannelType("feishu"), + ChannelType(CHANNEL_TARGET), account_id, ) if account is None: @@ -160,14 +162,14 @@ class FeishuStreamConnectorAdapter: cv_path = await self._config.get( "ws_endpoint_path", scope=ConfigScope.CHANNEL, - target=_CHANNEL_TYPE, + target=CHANNEL_TARGET, ) ws_path = cv_path.value or _DEFAULT_WS_ENDPOINT_PATH cv_base = await self._config.get( "api_base_url", scope=ConfigScope.CHANNEL, - target=_CHANNEL_TYPE, + target=CHANNEL_TARGET, ) api_base_url = cv_base.value return f"{api_base_url}{ws_path}" diff --git a/backend/package/yuxi/channels/plugins/feishu/entry.py b/backend/package/yuxi/channels/plugins/feishu/entry.py index f5fc2924..bb69198d 100644 --- a/backend/package/yuxi/channels/plugins/feishu/entry.py +++ b/backend/package/yuxi/channels/plugins/feishu/entry.py @@ -189,8 +189,8 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest FeishuIdentityResolverAdapter(client, cache_port), ) # stream_connector:飞书 WS 长连接适配器,由 StreamWorker 管理连接生命周期。 - # 需 ConfigPort 读取 ws_endpoint_path / api_base_url,支持按部署环境定制 - # WS 端点 API 路径(私有化部署场景)。 + # 需 ConfigPort 读取 ws_endpoint_path / api_base_url(均声明于 manifest + # config_schema),支持按部署环境定制 WS 端点 API 路径(私有化部署场景)。 host.registerAdapter( "stream_connector", FeishuStreamConnectorAdapter(persistence_port, logger_port, config_port), diff --git a/backend/package/yuxi/channels/plugins/feishu/feishu_client.py b/backend/package/yuxi/channels/plugins/feishu/feishu_client.py index 8939e4fd..0b177f8e 100644 --- a/backend/package/yuxi/channels/plugins/feishu/feishu_client.py +++ b/backend/package/yuxi/channels/plugins/feishu/feishu_client.py @@ -29,11 +29,11 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from . import error_translator +from ._constants import CHANNEL_TARGET _TOKEN_CACHE_KEY_PREFIX = "feishu:token:" _APP_TOKEN_CACHE_KEY_PREFIX = "feishu:app_token:" _TOKEN_REFRESH_LEAD_TIME_SEC = 60 -_CHANNEL_TYPE = "feishu" class FeishuClient: @@ -110,7 +110,7 @@ class FeishuClient: cv = await self._config.get( key, scope=ConfigScope.CHANNEL, - target=_CHANNEL_TYPE, + target=CHANNEL_TARGET, ) return cv.value diff --git a/backend/package/yuxi/channels/plugins/feishu/lifecycle.py b/backend/package/yuxi/channels/plugins/feishu/lifecycle.py index 982fe0bc..de563e2e 100644 --- a/backend/package/yuxi/channels/plugins/feishu/lifecycle.py +++ b/backend/package/yuxi/channels/plugins/feishu/lifecycle.py @@ -33,14 +33,15 @@ from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort +from ._constants import ( + HTTP_MAX_CONNECTIONS, + HTTP_MAX_KEEPALIVE_CONNECTIONS, + HTTP_TIMEOUT_SECONDS, +) + if TYPE_CHECKING: from .feishu_client import FeishuClient -# httpx 连接池配置(对齐 manifest.json resource_quota: max_connections=50) -_HTTP_TIMEOUT_SECONDS = 30.0 -_HTTP_MAX_CONNECTIONS = 50 -_HTTP_MAX_KEEPALIVE_CONNECTIONS = 10 - class FeishuLifecycleHandler: """飞书渠道插件生命周期钩子处理。 @@ -80,10 +81,10 @@ class FeishuLifecycleHandler: async def onInit(self) -> None: """初始化资源:创建 httpx 连接池并注入 FeishuClient。""" self._http_client = httpx.AsyncClient( - timeout=_HTTP_TIMEOUT_SECONDS, + timeout=HTTP_TIMEOUT_SECONDS, limits=httpx.Limits( - max_connections=_HTTP_MAX_CONNECTIONS, - max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS, + max_connections=HTTP_MAX_CONNECTIONS, + max_keepalive_connections=HTTP_MAX_KEEPALIVE_CONNECTIONS, ), ) if self._client is not None: diff --git a/backend/package/yuxi/channels/plugins/feishu/manifest.json b/backend/package/yuxi/channels/plugins/feishu/manifest.json index ce0613a0..3e9f921b 100644 --- a/backend/package/yuxi/channels/plugins/feishu/manifest.json +++ b/backend/package/yuxi/channels/plugins/feishu/manifest.json @@ -26,6 +26,7 @@ }, "max_message_length": 30720, "supports_credential_cloning": false, + "transport_mode": "stream", "capabilities": { "rich_message": true, "streaming": true, @@ -47,13 +48,26 @@ "whitelist": true, "wizard": true, "tools": false, - "status": false, - "probeable": false, + "status": true, + "probeable": true, "identity_resolver": true, "lifecycle": true, "supports_qr_login": true, "agent_collaboration": false }, + "capability_requirements": [ + ["supports_image_inbound", ["app_id", "app_secret"]], + ["supports_image_outbound", ["app_id", "app_secret"]], + ["supports_video_outbound", ["app_id", "app_secret"]], + ["rich_message", ["app_id", "app_secret"]], + ["streaming", ["app_id", "app_secret"]], + ["mention", ["app_id", "app_secret"]], + ["directory", ["app_id", "app_secret"]], + ["supports_qr_login", ["enable_qr_login", "qr_redirect_uri"]], + ["lifecycle", []], + ["probeable", []], + ["status", []] + ], "resource_quota": { "max_cpu": "20.0", "max_memory": "256MB", @@ -80,7 +94,7 @@ {"key": "qr_redirect_uri", "type": "string", "required": false, "default": null, "hot_reloadable": true, "scope": "channel", "constraints": {"pattern": "^https://"}}, {"key": "qr_timeout_ms", "type": "integer", "required": false, "default": 120000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 30000, "max": 300000}}, {"key": "api_base_url", "type": "string", "required": false, "default": "https://open.feishu.cn", "hot_reloadable": true, "scope": "channel", "constraints": {}}, - {"key": "ws_endpoint", "type": "string", "required": false, "default": "wss://open.feishu.cn", "hot_reloadable": true, "scope": "channel", "constraints": {}}, + {"key": "ws_endpoint_path", "type": "string", "required": false, "default": "/open-apis/event/v1/get_ws_endpoint", "hot_reloadable": true, "scope": "channel", "constraints": {}, "description": "飞书 WS 端点 API 路径,与 api_base_url 组合获取长连接地址"}, {"key": "request_timeout_ms", "type": "integer", "required": false, "default": 30000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 5000, "max": 60000}}, {"key": "max_retries", "type": "integer", "required": false, "default": 3, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 0, "max": 5}}, {"key": "rate_limit_per_sec", "type": "integer", "required": false, "default": 50, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 1, "max": 100}},