feat(feishu): 完善飞书适配器的错误处理与功能适配
1. 为飞书登录、消息操作等适配器添加基础错误捕获转发 2. 新增未加密事件校验逻辑,完善会话解析文档 3. 关闭飞书原生输入指示支持,移除相关缓存逻辑 4. 新增飞书向导OAuth适配方法,修正OAuth方法命名 5. 更新插件配置项,添加bot菜单与用户访问令牌配置 6. 修正消息操作枚举匹配与文档描述
This commit is contained in:
parent
ce0a7e7232
commit
6c0d0c7bd8
@ -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
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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", {})
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 步骤校验辅助
|
||||
|
||||
@ -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},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user