From 6c0d0c7bd8ae78eb913c523f84e349d06c4b6166 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 9 Jul 2026 04:18:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(feishu):=20=E5=AE=8C=E5=96=84=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E4=B8=8E=E5=8A=9F=E8=83=BD=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为飞书登录、消息操作等适配器添加基础错误捕获转发 2. 新增未加密事件校验逻辑,完善会话解析文档 3. 关闭飞书原生输入指示支持,移除相关缓存逻辑 4. 新增飞书向导OAuth适配方法,修正OAuth方法命名 5. 更新插件配置项,添加bot菜单与用户访问令牌配置 6. 修正消息操作枚举匹配与文档描述 --- .../plugins/feishu/adapters/login_adapter.py | 2 + .../feishu/adapters/message_ops_adapter.py | 147 +++++++++++++----- .../feishu/adapters/session_adapter.py | 10 +- .../feishu/adapters/streaming_adapter.py | 66 ++++---- .../plugins/feishu/adapters/wizard_adapter.py | 32 +++- .../channels/plugins/feishu/manifest.json | 6 +- 6 files changed, 177 insertions(+), 86 deletions(-) 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 f8c37abc..e28efd18 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/login_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/login_adapter.py @@ -93,6 +93,8 @@ class FeishuLoginAdapter: state = uuid.uuid4().hex try: data = await self._client.get_qr_login_url(acct, redirect_uri, state) + except Error: + raise except Exception as exc: raise DependencyError(dep="feishu_api", cause=exc) from exc diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/message_ops_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/message_ops_adapter.py index ee02af68..d941898a 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/message_ops_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/message_ops_adapter.py @@ -138,11 +138,16 @@ class FeishuMessageOpsAdapter: if sender_type != "app": raise PermissionDeniedError(reason="not_bot_message") - await self._client.update_message( - account_id, - channel_msg_id, - json.dumps({"text": content.text}), - ) + try: + await self._client.update_message( + account_id, + channel_msg_id, + json.dumps({"text": content.text}), + ) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def recallMessage(self, channel_msg_id: str) -> bool: @@ -155,7 +160,12 @@ class FeishuMessageOpsAdapter: field="channel_msg_id", message="recall_time_exceeded", ) - await self._client.recall_message(account_id, channel_msg_id) + try: + await self._client.recall_message(account_id, channel_msg_id) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def getMessage(self, channel_msg_id: str) -> Message | None: @@ -170,7 +180,12 @@ class FeishuMessageOpsAdapter: async def sendReaction(self, channel_msg_id: str, emoji: str) -> bool: """发送表情反应,并将 reaction_id 存入缓存。""" account_id = await self._resolve_account_id(channel_msg_id) - resp = await self._client.add_reaction(account_id, channel_msg_id, emoji) + try: + resp = await self._client.add_reaction(account_id, channel_msg_id, emoji) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc reaction_id = resp["data"]["reaction_id"] await self._cache.set(_reaction_key(channel_msg_id, emoji), reaction_id) return True @@ -182,20 +197,35 @@ class FeishuMessageOpsAdapter: if opt.is_nothing(): return False reaction_id = opt.unwrap() - await self._client.delete_reaction(account_id, channel_msg_id, reaction_id) + try: + await self._client.delete_reaction(account_id, channel_msg_id, reaction_id) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc await self._cache.delete(_reaction_key(channel_msg_id, emoji)) return True async def pinMessage(self, channel_msg_id: str) -> bool: """置顶消息。""" account_id = await self._resolve_account_id(channel_msg_id) - await self._client.pin_message(account_id, channel_msg_id) + try: + await self._client.pin_message(account_id, channel_msg_id) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def unpinMessage(self, channel_msg_id: str) -> bool: """取消置顶消息。""" account_id = await self._resolve_account_id(channel_msg_id) - await self._client.unpin_message(account_id, channel_msg_id) + try: + await self._client.unpin_message(account_id, channel_msg_id) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def updateCard( @@ -213,13 +243,23 @@ class FeishuMessageOpsAdapter: card_content = payload.rich_message if card_content is None: card_content = card_builder.build_markdown_card(payload.content) - await self._client.update_card(account_id, channel_msg_id, card_content) + try: + await self._client.update_card(account_id, channel_msg_id, card_content) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def deleteCard(self, channel_msg_id: str) -> bool: """删除卡片消息(飞书通过撤回实现删除)。""" account_id = await self._resolve_account_id(channel_msg_id) - await self._client.recall_message(account_id, channel_msg_id) + try: + await self._client.recall_message(account_id, channel_msg_id) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def replyMessage( @@ -229,12 +269,17 @@ class FeishuMessageOpsAdapter: ) -> str: """回复消息,返回新消息的 channel_msg_id。""" account_id = await self._resolve_account_id(channel_msg_id) - resp = await self._client.reply_message( - account_id, - channel_msg_id, - "text", - json.dumps({"text": content.text}), - ) + try: + resp = await self._client.reply_message( + account_id, + channel_msg_id, + "text", + json.dumps({"text": content.text}), + ) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return resp["data"]["message_id"] async def sendUrgentMessage( @@ -244,12 +289,17 @@ class FeishuMessageOpsAdapter: ) -> str: """发送加急消息,返回加急消息的 channel_msg_id。""" account_id = await self._resolve_account_id(channel_msg_id) - resp = await self._client.request( - "POST", - f"/open-apis/im/v1/messages/{channel_msg_id}/urgent", - account_id, - json={"msg_type": "text", "content": json.dumps({"text": content.text})}, - ) + try: + resp = await self._client.request( + "POST", + f"/open-apis/im/v1/messages/{channel_msg_id}/urgent", + account_id, + json={"msg_type": "text", "content": json.dumps({"text": content.text})}, + ) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return resp["data"]["message_id"] async def batchGetMessages( @@ -265,10 +315,15 @@ class FeishuMessageOpsAdapter: if not channel_msg_ids: return [] account_id = await self._resolve_account_id(channel_msg_ids[0]) - resp = await self._client.batch_get_messages( - account_id, - list(channel_msg_ids), - ) + try: + resp = await self._client.batch_get_messages( + account_id, + list(channel_msg_ids), + ) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc items = resp.get("data", {}).get("items", []) return [self._to_message(item) for item in items] @@ -291,16 +346,21 @@ class FeishuMessageOpsAdapter: message="requires_at_least_2", ) account_id = await self._resolve_account_id(channel_msg_ids[0]) - resp = await self._client.request( - "POST", - "/open-apis/im/v1/messages/merge_forward", - account_id, - json={ - "message_ids": list(channel_msg_ids), - "receive_id": target.get("receive_id", ""), - }, - receive_id_type=target.get("receive_id_type", "open_id"), - ) + try: + resp = await self._client.request( + "POST", + "/open-apis/im/v1/messages/merge_forward", + account_id, + json={ + "message_ids": list(channel_msg_ids), + "receive_id": target.get("receive_id", ""), + }, + receive_id_type=target.get("receive_id_type", "open_id"), + ) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return resp["data"]["message_id"] async def markMessage( @@ -310,7 +370,12 @@ class FeishuMessageOpsAdapter: ) -> bool: """标记消息(飞书通过 Pin 实现,``mark_type`` 不区分)。""" account_id = await self._resolve_account_id(channel_msg_id) - await self._client.pin_message(account_id, channel_msg_id) + try: + await self._client.pin_message(account_id, channel_msg_id) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc return True async def favoriteMessage(self, channel_msg_id: str) -> bool: @@ -407,7 +472,7 @@ class FeishuMessageOpsAdapter: has_side_effects=True, ), MessageOperationDefinition( - operation="recall", + operation=MessageOperation.RECALL, tool_name="撤回消息", description="撤回指定消息(24小时内)", parameters=(_PARAM_CHANNEL_MSG_ID,), @@ -539,7 +604,7 @@ class FeishuMessageOpsAdapter: parameters["content"], ) result_data = None - elif op == "recall": + elif op == MessageOperation.RECALL: ok = await self.recallMessage(parameters["channel_msg_id"]) elif op == "get": msg = await self.getMessage(parameters["channel_msg_id"]) diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py index 0ad77bf3..de79e439 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py @@ -30,10 +30,16 @@ class FeishuSessionAdapter: async def parseSession(self, raw_event: RawEvent) -> SessionInfo: """从原始事件解析会话信息。 - @failure 事件结构不符合飞书协议(缺少 ``app_id`` 或 ``open_id``) - 抛 ``ValidationError``。 + @failure 事件尚未解密(payload 含 ``encrypt`` 字段)抛 + ``ValidationError``;事件结构不符合飞书协议(缺少 ``app_id`` + 或 ``open_id``)抛 ``ValidationError``。 """ event = raw_event.payload + if "encrypt" in event: + raise ValidationError( + field="encrypt", + message="event_not_decrypted: parseSession requires decrypted payload", + ) header = event.get("header", {}) inner = event.get("event", {}) message = inner.get("message", {}) diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/streaming_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/streaming_adapter.py index 22784d59..6727a998 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/streaming_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/streaming_adapter.py @@ -1,8 +1,7 @@ """飞书流式适配器。 实现 ``StreamingAdapter`` Protocol,管理飞书流式卡片的生命周期 -(开始 / 分块更新 / 结束)、输入指示(通过 Reaction API 模拟)与 -卡片整体 / 局部更新。覆盖 FR-13 流式输出能力。 +(开始 / 分块更新 / 结束)与卡片整体 / 局部更新。覆盖 FR-13 流式输出能力。 流式会话状态通过 ``CachePort`` 管理(INV-5 无 mutable 实例变量): - ``feishu:streaming:{account_id}:{peer_id}`` → ``card_id`` @@ -10,8 +9,10 @@ - ``feishu:streaming:card:{card_id}`` → ``account_id`` 反向映射,供 ``updateFullCard`` / ``updatePartialCard`` 与 ``MessageOpsAdapter`` 解析 account_id。 - - ``feishu:typing:{account_id}:{peer_id}`` → ``reaction_id`` - 输入指示状态,飞书无原生 typing indicator,通过 Reaction 模拟。 + +输入指示(typing indicator):飞书无原生 typing indicator API, +``supports_typing_indicator`` 声明为 ``False``,``startTypingIndicator`` +/ ``stopTypingIndicator`` 为无操作返回 ``False``。 依赖方向:仅 import yuxi.channels.contract.* 与同插件内部模块 (card_builder / FeishuClient),不污染框架层。 @@ -35,9 +36,7 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from .. import card_builder from ..feishu_client import FeishuClient -_TYPING_EMOJI = "TYPING" _STREAMING_TTL_SECONDS = 600 -_TYPING_TTL_SECONDS = 30 def _streaming_key(account_id: str, peer_id: str) -> str: @@ -48,10 +47,6 @@ def _card_key(card_id: str) -> str: return f"feishu:streaming:card:{card_id}" -def _typing_key(account_id: str, peer_id: str) -> str: - return f"feishu:typing:{account_id}:{peer_id}" - - class FeishuStreamingAdapter: """飞书流式适配器。 @@ -167,43 +162,26 @@ class FeishuStreamingAdapter: return True async def getStreamingCapability(self) -> StreamingCapability: - """飞书流式能力声明。""" + """飞书流式能力声明。 + + ``supports_typing_indicator=False``:飞书无原生 typing indicator API, + 不模拟输入指示。 + """ return StreamingCapability( supports_streaming=True, - supports_typing_indicator=True, + supports_typing_indicator=False, chunk_mode=ChunkMode.TEXT, min_chunk_interval_ms=200, max_chunk_length=4000, ) async def startTypingIndicator(self, account_id: str, peer_id: str) -> bool: - """开始输入指示(通过 Reaction API 添加 TYPING emoji)。 - - 飞书无原生 typing indicator,通过 ``client.add_reaction`` 对 - 对端最近消息添加 TYPING emoji 模拟。若已有活跃 typing reaction, - 返回 ``False``。 - """ - typing_key = _typing_key(account_id, peer_id) - existing = await self._cache.get(typing_key) - if existing.is_some(): - return False - - resp = await self._client.add_reaction(account_id, peer_id, _TYPING_EMOJI) - reaction_id = resp["data"]["reaction_id"] - await self._cache.set(typing_key, reaction_id, ttl_seconds=_TYPING_TTL_SECONDS) - return True + """开始输入指示。飞书无原生 API,返回 ``False`` 表示未执行。""" + return False async def stopTypingIndicator(self, account_id: str, peer_id: str) -> bool: - """停止输入指示(移除 TYPING emoji Reaction)。""" - typing_key = _typing_key(account_id, peer_id) - opt = await self._cache.get(typing_key) - if opt.is_nothing(): - return False - reaction_id = opt.unwrap() - - await self._client.delete_reaction(account_id, peer_id, reaction_id) - await self._cache.delete(typing_key) - return True + """停止输入指示。飞书无原生 API,返回 ``False`` 表示未执行。""" + return False async def updateFullCard(self, card_id: str, content: Any) -> None: """整体更新卡片内容。 @@ -212,7 +190,12 @@ class FeishuStreamingAdapter: ``client.update_card`` 全量替换卡片。 """ account_id = await self._resolve_account_id(card_id) - await self._client.update_card(account_id, card_id, content) + try: + await self._client.update_card(account_id, card_id, content) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc async def updatePartialCard( self, @@ -233,7 +216,12 @@ class FeishuStreamingAdapter: "content": content, "render_strategy": str(render_strategy), } - await self._client.update_card(account_id, card_id, payload) + try: + await self._client.update_card(account_id, card_id, payload) + except Error: + raise + except Exception as exc: + raise DependencyError(dep="feishu_api", cause=exc) from exc async def _resolve_account_id(self, card_id: str) -> str: """从 ``CachePort`` 反向映射解析 card_id 对应的 account_id。 diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/wizard_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/wizard_adapter.py index 75e0c599..8115a0f5 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/wizard_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/wizard_adapter.py @@ -23,6 +23,8 @@ from yuxi.channels.contract.dtos.wizard import ( ) from yuxi.channels.contract.errors import ( DependencyError, + Error, + NotImplementedError, ValidationError, ) from yuxi.channels.contract.ports.driven.config_port import ConfigPort @@ -312,6 +314,17 @@ class FeishuWizardAdapter: ) return WizardConfigPatch(step_id=step_id, values=dict(values)) + def supportsWizardOAuth(self) -> bool: + """声明本适配器是否支持 OAuth 授权流程。 + + 飞书向导的扫码登录步骤通过 ``handleOAuthCallback`` 处理授权回调, + 但不通过 ``buildOAuthAuthorizeUrl`` 发起标准 OAuth 授权(扫码由 + 飞书客户端原生完成),返回 ``False``。``WizardService.initiateOAuth`` + 据此守卫,避免对不支持标准 OAuth 发起的渠道调用 + ``buildOAuthAuthorizeUrl``。 + """ + return False + async def handleOAuthCallback( self, code: str, @@ -352,13 +365,13 @@ class FeishuWizardAdapter: ) try: - data = await self._client.exchangeOAuthCode( + data = await self._client.exchange_oauth_code( app_id, app_secret, code, redirect_uri, ) - except ValidationError: + except Error: raise except Exception as exc: raise DependencyError(dep="feishu_oauth", cause=exc) from exc @@ -380,6 +393,21 @@ class FeishuWizardAdapter: }, ) + def buildOAuthAuthorizeUrl( + self, + redirect_uri: str, + state: str, + *, + applied_config: dict[str, Any], + ) -> str: + """构造 OAuth 授权 URL(飞书向导不支持,抛 ``NotImplementedError``)。 + + 飞书扫码登录由客户端原生完成,不通过后端构造授权 URL,本方法始终 + 抛 ``NotImplementedError``,由 dispatch handler 映射为 501 + NOT_IMPLEMENTED。 + """ + raise NotImplementedError(operation="buildOAuthAuthorizeUrl") + # ---------------------------------------------------------------------- # 步骤校验辅助 diff --git a/backend/package/yuxi/channels/plugins/feishu/manifest.json b/backend/package/yuxi/channels/plugins/feishu/manifest.json index 3e9f921b..cdd16cf8 100644 --- a/backend/package/yuxi/channels/plugins/feishu/manifest.json +++ b/backend/package/yuxi/channels/plugins/feishu/manifest.json @@ -30,7 +30,7 @@ "capabilities": { "rich_message": true, "streaming": true, - "typing_indicator": true, + "typing_indicator": false, "message_edit": true, "message_recall": true, "supports_reaction": true, @@ -99,7 +99,9 @@ {"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}}, {"key": "user_cache_ttl_sec", "type": "integer", "required": false, "default": 1800, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 60, "max": 7200}}, - {"key": "identity_cache_ttl_sec", "type": "integer", "required": false, "default": 60, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 30, "max": 600}} + {"key": "identity_cache_ttl_sec", "type": "integer", "required": false, "default": 60, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 30, "max": 600}}, + {"key": "bot_menu_commands", "type": "json", "required": false, "default": null, "hot_reloadable": true, "scope": "channel", "constraints": {}, "description": "飞书 Bot 菜单命令列表,每项为 {name, description, permission, is_silent}"}, + {"key": "user_access_token", "type": "string", "required": false, "default": null, "hot_reloadable": false, "sensitive": true, "scope": "account", "constraints": {}, "description": "用户访问令牌(OAuth 授权获取,用于用户身份操作)"} ], "env_vars": [ {"name": "FEISHU_APP_ID", "description": "飞书应用 App ID", "required": true, "default": null, "sensitive": false},