refactor(feishu): 飞书插件常量集中化与代码优化
1. 新建常量模块统一管理飞书插件共享常量,拆分重复定义的配置与标识 2. 重构配置读取逻辑,统一使用常量化的渠道标识 3. 修复登录适配器配置项键名错误,将qr_login_redirect_uri改为qr_redirect_uri 4. 补充出站适配器续发分片的参数支持与日志逻辑 5. 优化入站适配器的账户ID处理逻辑,修正资源下载URL的账户标识 6. 更新manifest配置,补充传输模式与能力需求声明 7. 完善注释说明,修正文档与实际代码不一致的问题
This commit is contained in:
parent
c88afb79ce
commit
1701d387e9
49
backend/package/yuxi/channels/plugins/feishu/_constants.py
Normal file
49
backend/package/yuxi/channels/plugins/feishu/_constants.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user