feat(feishu): 完善飞书适配器的错误处理与功能适配

1.  为飞书登录、消息操作等适配器添加基础错误捕获转发
2.  新增未加密事件校验逻辑,完善会话解析文档
3.  关闭飞书原生输入指示支持,移除相关缓存逻辑
4.  新增飞书向导OAuth适配方法,修正OAuth方法命名
5.  更新插件配置项,添加bot菜单与用户访问令牌配置
6.  修正消息操作枚举匹配与文档描述
This commit is contained in:
Kris 2026-07-09 04:18:24 +08:00
parent ce0a7e7232
commit 6c0d0c7bd8
6 changed files with 177 additions and 86 deletions

View File

@ -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

View File

@ -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"])

View File

@ -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", {})

View File

@ -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。

View File

@ -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")
# ----------------------------------------------------------------------
# 步骤校验辅助

View File

@ -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},