refactor(feishu): 飞书插件常量集中化与代码优化

1. 新建常量模块统一管理飞书插件共享常量,拆分重复定义的配置与标识
2. 重构配置读取逻辑,统一使用常量化的渠道标识
3. 修复登录适配器配置项键名错误,将qr_login_redirect_uri改为qr_redirect_uri
4. 补充出站适配器续发分片的参数支持与日志逻辑
5. 优化入站适配器的账户ID处理逻辑,修正资源下载URL的账户标识
6. 更新manifest配置,补充传输模式与能力需求声明
7. 完善注释说明,修正文档与实际代码不一致的问题
This commit is contained in:
Kris 2026-07-08 22:55:27 +08:00
parent c88afb79ce
commit 1701d387e9
9 changed files with 154 additions and 61 deletions

View 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",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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