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 yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||||
|
|
||||||
from .. import signature
|
from .. import signature
|
||||||
|
from .._constants import CHANNEL_TARGET
|
||||||
from ..feishu_client import FeishuClient
|
from ..feishu_client import FeishuClient
|
||||||
|
|
||||||
_CHANNEL_TYPE = "feishu"
|
|
||||||
|
|
||||||
# 飞书 msg_type → MessageFormat 映射(§3.4)
|
# 飞书 msg_type → MessageFormat 映射(§3.4)
|
||||||
_TEXT_MSG_TYPE = "text"
|
_TEXT_MSG_TYPE = "text"
|
||||||
_RICH_MSG_TYPES = frozenset(
|
_RICH_MSG_TYPES = frozenset(
|
||||||
@ -107,10 +106,15 @@ class FeishuInboundAdapter:
|
|||||||
content_str = message.get("content", "{}")
|
content_str = message.get("content", "{}")
|
||||||
message_id = message.get("message_id", "")
|
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(
|
text, fmt, attachments = _parse_message_content(
|
||||||
msg_type,
|
msg_type,
|
||||||
content_str,
|
content_str,
|
||||||
app_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -266,7 +270,7 @@ class FeishuInboundAdapter:
|
|||||||
cv = await self._config.get(
|
cv = await self._config.get(
|
||||||
"encrypt_key",
|
"encrypt_key",
|
||||||
scope=ConfigScope.CHANNEL,
|
scope=ConfigScope.CHANNEL,
|
||||||
target=_CHANNEL_TYPE,
|
target=CHANNEL_TARGET,
|
||||||
)
|
)
|
||||||
value = cv.value
|
value = cv.value
|
||||||
return value if value else None
|
return value if value else None
|
||||||
@ -319,7 +323,7 @@ def _extract_raw_body(payload: dict[str, Any]) -> str:
|
|||||||
def _parse_message_content(
|
def _parse_message_content(
|
||||||
msg_type: str,
|
msg_type: str,
|
||||||
content_str: str,
|
content_str: str,
|
||||||
app_id: str,
|
account_id: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
) -> tuple[str, MessageFormat, tuple[Attachment, ...]]:
|
) -> tuple[str, MessageFormat, tuple[Attachment, ...]]:
|
||||||
"""按 msg_type 解析消息内容,返回 (text, format, attachments)。
|
"""按 msg_type 解析消息内容,返回 (text, format, attachments)。
|
||||||
@ -338,7 +342,7 @@ def _parse_message_content(
|
|||||||
return content.get("text", ""), MessageFormat.TEXT, ()
|
return content.get("text", ""), MessageFormat.TEXT, ()
|
||||||
|
|
||||||
if msg_type in _RICH_MSG_TYPES:
|
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, ()
|
return "", MessageFormat.TEXT, ()
|
||||||
|
|
||||||
@ -346,7 +350,7 @@ def _parse_message_content(
|
|||||||
def _parse_rich_content(
|
def _parse_rich_content(
|
||||||
msg_type: str,
|
msg_type: str,
|
||||||
content: dict[str, Any],
|
content: dict[str, Any],
|
||||||
app_id: str,
|
account_id: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
) -> tuple[str, MessageFormat, tuple[Attachment, ...]]:
|
) -> tuple[str, MessageFormat, tuple[Attachment, ...]]:
|
||||||
"""解析富消息内容(image/file/audio/media/post/merge_forward/interactive/sticker)。"""
|
"""解析富消息内容(image/file/audio/media/post/merge_forward/interactive/sticker)。"""
|
||||||
@ -354,7 +358,7 @@ def _parse_rich_content(
|
|||||||
file_key = content.get("image_key", "")
|
file_key = content.get("image_key", "")
|
||||||
attachment = _build_resource_attachment(
|
attachment = _build_resource_attachment(
|
||||||
"image",
|
"image",
|
||||||
app_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
file_key,
|
file_key,
|
||||||
"image",
|
"image",
|
||||||
@ -366,7 +370,7 @@ def _parse_rich_content(
|
|||||||
filename = content.get("file_name", "")
|
filename = content.get("file_name", "")
|
||||||
attachment = _build_resource_attachment(
|
attachment = _build_resource_attachment(
|
||||||
"file",
|
"file",
|
||||||
app_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
file_key,
|
file_key,
|
||||||
"file",
|
"file",
|
||||||
@ -379,7 +383,7 @@ def _parse_rich_content(
|
|||||||
duration_ms = content.get("duration")
|
duration_ms = content.get("duration")
|
||||||
attachment = _build_resource_attachment(
|
attachment = _build_resource_attachment(
|
||||||
"audio",
|
"audio",
|
||||||
app_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
file_key,
|
file_key,
|
||||||
"file",
|
"file",
|
||||||
@ -392,7 +396,7 @@ def _parse_rich_content(
|
|||||||
filename = content.get("file_name", "")
|
filename = content.get("file_name", "")
|
||||||
attachment = _build_resource_attachment(
|
attachment = _build_resource_attachment(
|
||||||
"video",
|
"video",
|
||||||
app_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
file_key,
|
file_key,
|
||||||
"file",
|
"file",
|
||||||
@ -404,7 +408,7 @@ def _parse_rich_content(
|
|||||||
file_key = content.get("file_key", "")
|
file_key = content.get("file_key", "")
|
||||||
attachment = _build_resource_attachment(
|
attachment = _build_resource_attachment(
|
||||||
"image",
|
"image",
|
||||||
app_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
file_key,
|
file_key,
|
||||||
"image",
|
"image",
|
||||||
@ -426,7 +430,7 @@ def _parse_rich_content(
|
|||||||
|
|
||||||
def _build_resource_attachment(
|
def _build_resource_attachment(
|
||||||
attach_type: str,
|
attach_type: str,
|
||||||
app_id: str,
|
account_id: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
file_key: str,
|
file_key: str,
|
||||||
file_type: str,
|
file_type: str,
|
||||||
@ -437,10 +441,14 @@ def _build_resource_attachment(
|
|||||||
"""构造飞书资源附件,URL 编码下载所需信息。
|
"""构造飞书资源附件,URL 编码下载所需信息。
|
||||||
|
|
||||||
URL 格式:``feishu://resource?account_id=xxx&message_id=om_xxx&file_key=xxx&type=image``
|
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 = (
|
url = (
|
||||||
f"{_RESOURCE_SCHEME}://{_RESOURCE_HOST}"
|
f"{_RESOURCE_SCHEME}://{_RESOURCE_HOST}"
|
||||||
f"?account_id={app_id}"
|
f"?account_id={account_id}"
|
||||||
f"&message_id={message_id}"
|
f"&message_id={message_id}"
|
||||||
f"&file_key={file_key}"
|
f"&file_key={file_key}"
|
||||||
f"&type={file_type}"
|
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.config_port import ConfigPort
|
||||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||||
|
|
||||||
|
from .._constants import CHANNEL_TARGET
|
||||||
from ..feishu_client import FeishuClient
|
from ..feishu_client import FeishuClient
|
||||||
|
|
||||||
_CHANNEL_TYPE = "feishu"
|
|
||||||
_DEFAULT_ACCOUNT = "default"
|
_DEFAULT_ACCOUNT = "default"
|
||||||
_LOGIN_ID_FRAGMENT_PREFIX = "login_id="
|
_LOGIN_ID_FRAGMENT_PREFIX = "login_id="
|
||||||
_POLL_INTERVAL_SECONDS = 1.0
|
_POLL_INTERVAL_SECONDS = 1.0
|
||||||
@ -85,9 +85,9 @@ class FeishuLoginAdapter:
|
|||||||
"""
|
"""
|
||||||
acct = account_id if account_id is not None else _DEFAULT_ACCOUNT
|
acct = account_id if account_id is not None else _DEFAULT_ACCOUNT
|
||||||
cv_redirect = await self._config.get(
|
cv_redirect = await self._config.get(
|
||||||
"qr_login_redirect_uri",
|
"qr_redirect_uri",
|
||||||
scope=ConfigScope.CHANNEL,
|
scope=ConfigScope.CHANNEL,
|
||||||
target=_CHANNEL_TYPE,
|
target=CHANNEL_TARGET,
|
||||||
)
|
)
|
||||||
redirect_uri = cv_redirect.value
|
redirect_uri = cv_redirect.value
|
||||||
state = uuid.uuid4().hex
|
state = uuid.uuid4().hex
|
||||||
@ -137,7 +137,13 @@ class FeishuLoginAdapter:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = await self._client.poll_qr_login_status(acct, login_id)
|
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(
|
return QrLoginWaitResult(
|
||||||
connected=False,
|
connected=False,
|
||||||
message="qr_expired",
|
message="qr_expired",
|
||||||
|
|||||||
@ -171,6 +171,8 @@ class FeishuOutboundAdapter:
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
peer_id: str,
|
peer_id: str,
|
||||||
payload: FormattedMessage,
|
payload: FormattedMessage,
|
||||||
|
*,
|
||||||
|
delivered_parts: list[int] | None = None,
|
||||||
) -> list[MultiPartReceipt]:
|
) -> list[MultiPartReceipt]:
|
||||||
"""发送多部分消息,返回各分片回执列表(FR-22)。
|
"""发送多部分消息,返回各分片回执列表(FR-22)。
|
||||||
|
|
||||||
@ -180,6 +182,10 @@ class FeishuOutboundAdapter:
|
|||||||
- 分片 2+(附件):逐个发送,单个附件失败时标记 ``success=False``
|
- 分片 2+(附件):逐个发送,单个附件失败时标记 ``success=False``
|
||||||
并继续后续分片(对齐契约 ``@post`` 部分失败仍返回列表)。
|
并继续后续分片(对齐契约 ``@post`` 部分失败仍返回列表)。
|
||||||
|
|
||||||
|
H-15 续发:``delivered_parts`` 含已成功投递的分片序号,适配器跳过
|
||||||
|
这些序号对应的分片,仅发送未投递分片。重试场景下首个分片已投递时
|
||||||
|
跳过发送,直接从附件分片续发。
|
||||||
|
|
||||||
@post 返回 ``MultiPartReceipt`` 列表,长度等于实际尝试的分片数;
|
@post 返回 ``MultiPartReceipt`` 列表,长度等于实际尝试的分片数;
|
||||||
部分分片失败时仍返回全部回执,调用方据 ``success`` 字段判定。
|
部分分片失败时仍返回全部回执,调用方据 ``success`` 字段判定。
|
||||||
@failure 首个分片失败或依赖服务不可用时抛 ``DependencyError``;
|
@failure 首个分片失败或依赖服务不可用时抛 ``DependencyError``;
|
||||||
@ -189,39 +195,46 @@ class FeishuOutboundAdapter:
|
|||||||
receipts: list[MultiPartReceipt] = []
|
receipts: list[MultiPartReceipt] = []
|
||||||
sent_ids: list[str] = []
|
sent_ids: list[str] = []
|
||||||
seq = 0
|
seq = 0
|
||||||
|
skip_seqs = set(delivered_parts) if delivered_parts else set()
|
||||||
|
|
||||||
# 分片 1:文本或卡片(必发,失败回滚并抛异常)
|
# 分片 1:文本或卡片(必发,失败回滚并抛异常)
|
||||||
first_part = _build_first_part(payload)
|
first_part = _build_first_part(payload)
|
||||||
if first_part is not None:
|
if first_part is not None:
|
||||||
part_type, msg_type, content_str = first_part
|
if seq in skip_seqs:
|
||||||
try:
|
seq += 1
|
||||||
message_id = await self._send_part(
|
else:
|
||||||
account_id,
|
part_type, msg_type, content_str = first_part
|
||||||
peer_id,
|
try:
|
||||||
receive_id_type,
|
message_id = await self._send_part(
|
||||||
msg_type,
|
account_id,
|
||||||
content_str,
|
peer_id,
|
||||||
sent_ids,
|
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:
|
seq += 1
|
||||||
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
|
|
||||||
|
|
||||||
# 分片 2+:附件(单个失败标记 success=False 继续)
|
# 分片 2+:附件(单个失败标记 success=False 继续)
|
||||||
for attachment in payload.attachments:
|
for attachment in payload.attachments:
|
||||||
if not attachment.content:
|
if not attachment.content:
|
||||||
continue
|
continue
|
||||||
|
if seq in skip_seqs:
|
||||||
|
seq += 1
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
message_id = await self._send_attachment(
|
message_id = await self._send_attachment(
|
||||||
account_id,
|
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.logger_port import LoggerPort
|
||||||
from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort
|
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"
|
_DEFAULT_WS_ENDPOINT_PATH = "/open-apis/event/v1/get_ws_endpoint"
|
||||||
_CHANNEL_TYPE = "feishu"
|
|
||||||
_ACK_TIMEOUT = 3.0
|
_ACK_TIMEOUT = 3.0
|
||||||
|
|
||||||
|
|
||||||
@ -69,7 +70,8 @@ class FeishuStreamConnectorAdapter:
|
|||||||
"""建立飞书 WS 长连接,返回 ``StreamConnection``。
|
"""建立飞书 WS 长连接,返回 ``StreamConnection``。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
account_id: 飞书 app_id(渠道账户 ID)。
|
account_id: 渠道账户 ID(内部账户标识,非飞书 app_id;由
|
||||||
|
TransportManager 按 ChannelAccount.account_id 传入)。
|
||||||
token: per-account CancellationToken(飞书连接不直接使用,
|
token: per-account CancellationToken(飞书连接不直接使用,
|
||||||
由 StreamWorker 管理取消)。
|
由 StreamWorker 管理取消)。
|
||||||
|
|
||||||
@ -77,7 +79,7 @@ class FeishuStreamConnectorAdapter:
|
|||||||
TransportError: 账户不存在、凭据缺失或连接失败。
|
TransportError: 账户不存在、凭据缺失或连接失败。
|
||||||
"""
|
"""
|
||||||
account = await self._persistence.getChannelAccount(
|
account = await self._persistence.getChannelAccount(
|
||||||
ChannelType("feishu"),
|
ChannelType(CHANNEL_TARGET),
|
||||||
account_id,
|
account_id,
|
||||||
)
|
)
|
||||||
if account is None:
|
if account is None:
|
||||||
@ -160,14 +162,14 @@ class FeishuStreamConnectorAdapter:
|
|||||||
cv_path = await self._config.get(
|
cv_path = await self._config.get(
|
||||||
"ws_endpoint_path",
|
"ws_endpoint_path",
|
||||||
scope=ConfigScope.CHANNEL,
|
scope=ConfigScope.CHANNEL,
|
||||||
target=_CHANNEL_TYPE,
|
target=CHANNEL_TARGET,
|
||||||
)
|
)
|
||||||
ws_path = cv_path.value or _DEFAULT_WS_ENDPOINT_PATH
|
ws_path = cv_path.value or _DEFAULT_WS_ENDPOINT_PATH
|
||||||
|
|
||||||
cv_base = await self._config.get(
|
cv_base = await self._config.get(
|
||||||
"api_base_url",
|
"api_base_url",
|
||||||
scope=ConfigScope.CHANNEL,
|
scope=ConfigScope.CHANNEL,
|
||||||
target=_CHANNEL_TYPE,
|
target=CHANNEL_TARGET,
|
||||||
)
|
)
|
||||||
api_base_url = cv_base.value
|
api_base_url = cv_base.value
|
||||||
return f"{api_base_url}{ws_path}"
|
return f"{api_base_url}{ws_path}"
|
||||||
|
|||||||
@ -189,8 +189,8 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest
|
|||||||
FeishuIdentityResolverAdapter(client, cache_port),
|
FeishuIdentityResolverAdapter(client, cache_port),
|
||||||
)
|
)
|
||||||
# stream_connector:飞书 WS 长连接适配器,由 StreamWorker 管理连接生命周期。
|
# stream_connector:飞书 WS 长连接适配器,由 StreamWorker 管理连接生命周期。
|
||||||
# 需 ConfigPort 读取 ws_endpoint_path / api_base_url,支持按部署环境定制
|
# 需 ConfigPort 读取 ws_endpoint_path / api_base_url(均声明于 manifest
|
||||||
# WS 端点 API 路径(私有化部署场景)。
|
# config_schema),支持按部署环境定制 WS 端点 API 路径(私有化部署场景)。
|
||||||
host.registerAdapter(
|
host.registerAdapter(
|
||||||
"stream_connector",
|
"stream_connector",
|
||||||
FeishuStreamConnectorAdapter(persistence_port, logger_port, config_port),
|
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 yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||||
|
|
||||||
from . import error_translator
|
from . import error_translator
|
||||||
|
from ._constants import CHANNEL_TARGET
|
||||||
|
|
||||||
_TOKEN_CACHE_KEY_PREFIX = "feishu:token:"
|
_TOKEN_CACHE_KEY_PREFIX = "feishu:token:"
|
||||||
_APP_TOKEN_CACHE_KEY_PREFIX = "feishu:app_token:"
|
_APP_TOKEN_CACHE_KEY_PREFIX = "feishu:app_token:"
|
||||||
_TOKEN_REFRESH_LEAD_TIME_SEC = 60
|
_TOKEN_REFRESH_LEAD_TIME_SEC = 60
|
||||||
_CHANNEL_TYPE = "feishu"
|
|
||||||
|
|
||||||
|
|
||||||
class FeishuClient:
|
class FeishuClient:
|
||||||
@ -110,7 +110,7 @@ class FeishuClient:
|
|||||||
cv = await self._config.get(
|
cv = await self._config.get(
|
||||||
key,
|
key,
|
||||||
scope=ConfigScope.CHANNEL,
|
scope=ConfigScope.CHANNEL,
|
||||||
target=_CHANNEL_TYPE,
|
target=CHANNEL_TARGET,
|
||||||
)
|
)
|
||||||
return cv.value
|
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.config_port import ConfigPort
|
||||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
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:
|
if TYPE_CHECKING:
|
||||||
from .feishu_client import FeishuClient
|
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:
|
class FeishuLifecycleHandler:
|
||||||
"""飞书渠道插件生命周期钩子处理。
|
"""飞书渠道插件生命周期钩子处理。
|
||||||
@ -80,10 +81,10 @@ class FeishuLifecycleHandler:
|
|||||||
async def onInit(self) -> None:
|
async def onInit(self) -> None:
|
||||||
"""初始化资源:创建 httpx 连接池并注入 FeishuClient。"""
|
"""初始化资源:创建 httpx 连接池并注入 FeishuClient。"""
|
||||||
self._http_client = httpx.AsyncClient(
|
self._http_client = httpx.AsyncClient(
|
||||||
timeout=_HTTP_TIMEOUT_SECONDS,
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
limits=httpx.Limits(
|
limits=httpx.Limits(
|
||||||
max_connections=_HTTP_MAX_CONNECTIONS,
|
max_connections=HTTP_MAX_CONNECTIONS,
|
||||||
max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS,
|
max_keepalive_connections=HTTP_MAX_KEEPALIVE_CONNECTIONS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"max_message_length": 30720,
|
"max_message_length": 30720,
|
||||||
"supports_credential_cloning": false,
|
"supports_credential_cloning": false,
|
||||||
|
"transport_mode": "stream",
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"rich_message": true,
|
"rich_message": true,
|
||||||
"streaming": true,
|
"streaming": true,
|
||||||
@ -47,13 +48,26 @@
|
|||||||
"whitelist": true,
|
"whitelist": true,
|
||||||
"wizard": true,
|
"wizard": true,
|
||||||
"tools": false,
|
"tools": false,
|
||||||
"status": false,
|
"status": true,
|
||||||
"probeable": false,
|
"probeable": true,
|
||||||
"identity_resolver": true,
|
"identity_resolver": true,
|
||||||
"lifecycle": true,
|
"lifecycle": true,
|
||||||
"supports_qr_login": true,
|
"supports_qr_login": true,
|
||||||
"agent_collaboration": false
|
"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": {
|
"resource_quota": {
|
||||||
"max_cpu": "20.0",
|
"max_cpu": "20.0",
|
||||||
"max_memory": "256MB",
|
"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_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": "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": "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": "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": "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": "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