refactor(wechat-ilink): 重构微信iLink插件代码,统一常量与缓存键格式
本次重构对微信iLink渠道插件进行了全面整理:
1. 新增共享常量模块`_constants.py`,集中管理所有跨模块共享配置与标识,对齐manifest单真相源
2. 统一缓存键格式为`wechat_ilink:{account_id}:{key}`,修复白名单存储键顺序错误
3. 重构登录适配器,移除不必要的account_id参数,简化接口调用
4. 重构生命周期相关代码,新增缓存清理逻辑,支持按账户清理孤儿缓存
5. 移除冗余重复定义的常量与配置默认值,统一从常量模块导入
6. 更新manifest配置,补充传输模式与能力要求声明
7. 优化出站适配器,新增续传支持与分片发送逻辑
This commit is contained in:
parent
063b28ddb2
commit
2a2385bacc
@ -0,0 +1,81 @@
|
||||
"""微信 iLink 渠道插件共享常量。
|
||||
|
||||
集中定义在多个模块间共享的配置默认值、依赖标识与渠道标识,避免重复
|
||||
定义导致的不一致风险(README §3 推荐布局,对齐 wechat_woc 模式)。
|
||||
|
||||
仅汇集真正跨模块共享的常量;仅单一模块使用的常量仍保留在各自模块内。
|
||||
所有常量值需与 ``manifest.json`` 中 ``config_schema`` 的 ``default`` 字段
|
||||
保持一致(F-03 manifest 单真相源),本模块的常量作为 ConfigPort 未命中
|
||||
时的本地回退默认值。
|
||||
|
||||
依赖方向:仅标准库,不 import 任何框架层或同插件模块,避免循环依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 渠道与依赖标识
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 渠道标识,用于 ConfigPort CHANNEL 作用域 target(插件 provides 值,
|
||||
# 与 manifest.json 对齐)
|
||||
CHANNEL_TARGET = "wechat_ilink"
|
||||
|
||||
# iLink API 依赖标识,统一用于 ``DependencyError.dep`` 字段
|
||||
ILINK_API_DEP = "ilink_api"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP 客户端配置(lifecycle 与 ilink_client 共享)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# HTTP 客户端默认超时(秒),对齐 manifest.json config_schema.long_poll_timeout_ms
|
||||
HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# HTTP 连接池最大连接数默认值,manifest.json resource_quota.max_connections.default=10
|
||||
# (entry.py 与 lifecycle 共享,避免硬编码重复)
|
||||
DEFAULT_MAX_CONNECTIONS = 10
|
||||
|
||||
# HTTP 连接池最大 keepalive 连接数
|
||||
DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 5
|
||||
|
||||
# 在途请求 drain 超时(秒),onStop/onUnload 等待在途请求完成的最长时间
|
||||
DRAIN_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置默认值(与 manifest.json config_schema default 一致,ConfigPort 未
|
||||
# 命中时本地回退使用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# iLink API 基础地址,manifest.json config_schema.baseurl.default
|
||||
DEFAULT_BASEURL = "https://ilinkai.weixin.qq.com"
|
||||
|
||||
# iLink 渠道协议版本,manifest.json config_schema.channel_version.default
|
||||
DEFAULT_CHANNEL_VERSION = "2.0.0"
|
||||
|
||||
# iLink CDN 地址,manifest.json config_schema.cdn_url.default
|
||||
DEFAULT_CDN_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
# 长轮询超时(毫秒),manifest.json config_schema.long_poll_timeout_ms.default=35000
|
||||
DEFAULT_LONG_POLL_TIMEOUT_MS = 35000
|
||||
|
||||
# 长轮询客户端超时余量(ms):长于服务端 35s hang,避免客户端先超时
|
||||
LONG_POLL_EXTRA_MS = 5000
|
||||
|
||||
# 单条消息最大长度,manifest.json config_schema.max_message_length.default=4000
|
||||
DEFAULT_MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CHANNEL_TARGET",
|
||||
"ILINK_API_DEP",
|
||||
"HTTP_TIMEOUT_SECONDS",
|
||||
"DEFAULT_MAX_CONNECTIONS",
|
||||
"DEFAULT_MAX_KEEPALIVE_CONNECTIONS",
|
||||
"DRAIN_TIMEOUT_SECONDS",
|
||||
"DEFAULT_BASEURL",
|
||||
"DEFAULT_CHANNEL_VERSION",
|
||||
"DEFAULT_CDN_URL",
|
||||
"DEFAULT_LONG_POLL_TIMEOUT_MS",
|
||||
"LONG_POLL_EXTRA_MS",
|
||||
"DEFAULT_MAX_MESSAGE_LENGTH",
|
||||
]
|
||||
@ -26,6 +26,7 @@ from yuxi.channels.contract.errors import DependencyError, Error
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
|
||||
from .. import crypto
|
||||
from .._constants import DEFAULT_CDN_URL
|
||||
from ..ilink_client import ILinkClient
|
||||
|
||||
# 媒体 URL scheme
|
||||
@ -39,9 +40,6 @@ _VIDEO_ITEM_TYPE = 3
|
||||
_FILE_ITEM_TYPE = 4
|
||||
_VOICE_ITEM_TYPE = 5
|
||||
|
||||
# CDN 默认地址
|
||||
_DEFAULT_CDN_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
|
||||
class WeChatILinkInboundAdapter:
|
||||
"""微信 iLink 入站消息规范化适配器。
|
||||
@ -104,7 +102,7 @@ class WeChatILinkInboundAdapter:
|
||||
|
||||
text_parts: list[str] = []
|
||||
attachments: list[Attachment] = []
|
||||
cdn_url = await self._client.get_channel_config("cdn_url", _DEFAULT_CDN_URL)
|
||||
cdn_url = await self._client.get_channel_config("cdn_url", DEFAULT_CDN_URL)
|
||||
|
||||
for item in item_list:
|
||||
item_type = item.get("type")
|
||||
@ -205,7 +203,7 @@ class WeChatILinkInboundAdapter:
|
||||
|
||||
encrypt_query_param = params.get("encrypt_query_param", "")
|
||||
aes_key = params.get("aes_key", "")
|
||||
cdn_url = params.get("cdn_url", _DEFAULT_CDN_URL)
|
||||
cdn_url = params.get("cdn_url", DEFAULT_CDN_URL)
|
||||
|
||||
try:
|
||||
encrypted = await self._client.cdn_download(cdn_url, encrypt_query_param)
|
||||
|
||||
@ -5,19 +5,27 @@
|
||||
配置落库后回调、删除前缓存清理、启用/禁用回调。
|
||||
|
||||
设计要点:
|
||||
- 适配器无 mutable 实例变量(INV-5)。
|
||||
- 适配器无 mutable 实例变量(INV-5),仅持有 DI 注入的 ``CachePort`` /
|
||||
``LoggerPort``。
|
||||
- 长轮询由传输引擎通过 ``ChannelAccountOnline``/``ChannelAccountOffline``
|
||||
领域事件触发 ``PullerWorker`` 启动/停止,本适配器不自管理轮询任务(决策 3)。
|
||||
领域事件触发 ``PullerWorker`` 启停,本适配器不自管理轮询任务(决策 3)。
|
||||
- 所有生命周期回调方法幂等:重复调用安全。
|
||||
- ``resolveAccountId`` 纯本地解析,不调 API。
|
||||
- ``applyAccountConfig`` 仅校验配置并规范化,不写库也不写 CachePort(凭证由
|
||||
CredentialService 通过 ConfigPort→CachePort 统一管理)。
|
||||
- ``beforeAccountDelete`` / ``onAccountDisabled`` 清理 CachePort 缓存,
|
||||
按账户隔离清理运行时缓存,避免孤儿键残留。CachePort 异常不抛(仅日志
|
||||
告警,避免阻塞生命周期流程),对齐 wechat_woc 模式。
|
||||
- ``beforeAccountDelete`` 使用 ``wechat_ilink:{account_id}:*`` 通配符
|
||||
清空该账户全部缓存(context_token / cursor / 白名单等)+ 凭证缓存。
|
||||
- ``onAccountDisabled`` 仅清运行态(context_token / cursor)+ 凭证缓存,
|
||||
保留白名单以便快速恢复(对齐 wechat_woc DISABLE 保留白名单语义)。
|
||||
- ``rotateCredentials`` 未实现(iLink bot_token 通过扫码获取,不支持运行时轮换)。
|
||||
- ``validateCredentials`` 仅校验 bot_token 非空,完整验证由 ProbeableAdapter 完成。
|
||||
- ``revokeOldCredentials`` 默认空实现:重新扫码即自动失效旧 token。
|
||||
|
||||
依赖方向:仅 import ``yuxi.channels.contract.*`` 与同插件内部模块
|
||||
(``ilink_client``),不污染框架层。
|
||||
(``_constants``),不污染框架层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -32,17 +40,28 @@ from yuxi.channels.contract.errors import (
|
||||
NotImplementedError,
|
||||
ValidationError,
|
||||
)
|
||||
from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
|
||||
from ..ilink_client import ILinkClient
|
||||
from .._constants import DEFAULT_BASEURL
|
||||
|
||||
_DEFAULT_BASEURL = "https://ilinkai.weixin.qq.com"
|
||||
# CachePort 失效模式
|
||||
# beforeAccountDelete:清空该账户所有运行时缓存。使用
|
||||
# ``wechat_ilink:{account_id}:*`` 通配符,避免多实例场景下跨账户清理。
|
||||
# 同时失效凭证缓存与 cursor / context_token / 白名单缓存。
|
||||
_CACHE_PATTERN_DELETE = "wechat_ilink:{account_id}:*"
|
||||
# onAccountDisabled:仅清运行态(context_token / cursor)+ 凭证缓存,
|
||||
# 保留白名单以便快速恢复(对齐 wechat_woc DISABLE 保留白名单语义)。
|
||||
_CACHE_PATTERNS_DISABLE = (
|
||||
"wechat_ilink:{account_id}:context_token:*",
|
||||
"wechat_ilink:{account_id}:cursor",
|
||||
)
|
||||
|
||||
|
||||
class WeChatILinkLifecycleAdapter:
|
||||
"""微信 iLink 账户生命周期适配器。
|
||||
|
||||
通过 DI 接收 ``ILinkClient`` / ``LoggerPort``,不访问全局 settings 或
|
||||
通过 DI 接收 ``CachePort`` / ``LoggerPort``,不访问全局 settings 或
|
||||
logger。长轮询由传输引擎通过 ``ChannelAccountOnline``/``ChannelAccountOffline``
|
||||
领域事件触发 ``PullerWorker`` 启动/停止,本适配器不自管理轮询任务
|
||||
(决策 3,INV-8)。所有生命周期回调方法幂等:重复调用安全。
|
||||
@ -50,10 +69,10 @@ class WeChatILinkLifecycleAdapter:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: ILinkClient,
|
||||
cache_port: CachePort,
|
||||
logger_port: LoggerPort,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._cache = cache_port
|
||||
self._logger = logger_port
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -99,7 +118,7 @@ class WeChatILinkLifecycleAdapter:
|
||||
field="bot_token",
|
||||
message="bot_token_missing",
|
||||
)
|
||||
baseurl = raw_config.get("baseurl") or _DEFAULT_BASEURL
|
||||
baseurl = raw_config.get("baseurl") or DEFAULT_BASEURL
|
||||
normalized = dict(raw_config)
|
||||
normalized["baseurl"] = baseurl
|
||||
await self._logger.info(
|
||||
@ -120,23 +139,46 @@ class WeChatILinkLifecycleAdapter:
|
||||
)
|
||||
|
||||
async def beforeAccountDelete(self, account: ChannelAccount) -> None:
|
||||
"""删除前回调。
|
||||
"""删除前回调。清空该账户的所有 CachePort 缓存。幂等。
|
||||
|
||||
清除 context_token 运行时缓存(幂等):清除已清空的缓存安全无副作用。
|
||||
缓存清理失败时让异常自然传播,由框架决定是否继续删除。
|
||||
使用 ``wechat_ilink:{account_id}:*`` 通配符按账户隔离清理运行时
|
||||
缓存(context_token / cursor / 白名单等),避免多实例场景下跨账户
|
||||
清理。同时删除凭证缓存。CachePort 异常 **不抛**(仅日志告警,
|
||||
避免阻塞删除流程)。
|
||||
|
||||
Note: 凭证(bot_token 等)的撤销由 ``CredentialService.revokeCredentials``
|
||||
统一负责,本适配器不再调用 ``ILinkClient.clear_credentials``(该方法
|
||||
已于 Task 8 移除)。本方法仅清理运行时会话上下文 token(context_token),
|
||||
该 token 属于运行时缓存而非持久化凭证,由 ILinkClient 自身管理。
|
||||
统一负责,本方法仅清理运行时缓存,确保无孤儿键残留。
|
||||
"""
|
||||
await self._client.clear_context_token(
|
||||
account.account_id,
|
||||
from_user_id=None,
|
||||
)
|
||||
account_id = account.account_id
|
||||
pattern = _CACHE_PATTERN_DELETE.format(account_id=account_id)
|
||||
try:
|
||||
await self._cache.invalidate(pattern=pattern)
|
||||
except Exception as exc:
|
||||
# CachePort 故障降级:不阻塞删除流程,但需记录完整堆栈
|
||||
await self._logger.exception(
|
||||
"iLink beforeAccountDelete: cache invalidate failed, non-blocking",
|
||||
account_id=account_id,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
# 精确删除凭证缓存(键名与 CredentialService 约定一致)
|
||||
precise_keys = [
|
||||
f"credentials:{account_id}",
|
||||
]
|
||||
for key in precise_keys:
|
||||
try:
|
||||
await self._cache.delete(key)
|
||||
except Exception as exc:
|
||||
await self._logger.exception(
|
||||
"iLink beforeAccountDelete: cache delete failed, non-blocking",
|
||||
account_id=account_id,
|
||||
key=key,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
await self._logger.info(
|
||||
"iLink account delete: context_token cleared, credentials revocation delegated to CredentialService",
|
||||
account_id=account.account_id,
|
||||
"iLink account delete: cache invalidated, credentials revocation delegated to CredentialService",
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
async def onAccountEnabled(self, account: ChannelAccount) -> None:
|
||||
@ -151,19 +193,40 @@ class WeChatILinkLifecycleAdapter:
|
||||
)
|
||||
|
||||
async def onAccountDisabled(self, account: ChannelAccount) -> None:
|
||||
"""禁用后回调。
|
||||
"""禁用后回调。清空运行态缓存与凭证缓存(保留白名单)。
|
||||
|
||||
框架发布 ``ChannelAccountOffline`` 事件触发停止 ``PullerWorker``。
|
||||
清除 context_token 缓存(保留 bot_token 缓存以便快速恢复)。
|
||||
清空 ``CachePort`` 中运行态缓存(context_token / cursor)与
|
||||
``credentials:{account_id}`` 凭证缓存,保留白名单以便快速恢复
|
||||
(对齐 wechat_woc DISABLE 保留白名单语义)。CachePort 异常不抛
|
||||
(仅日志告警,避免阻塞禁用流程)。
|
||||
"""
|
||||
await self._client.clear_context_token(
|
||||
account.account_id,
|
||||
from_user_id=None,
|
||||
)
|
||||
account_id = account.account_id
|
||||
for pattern in _CACHE_PATTERNS_DISABLE:
|
||||
formatted = pattern.format(account_id=account_id)
|
||||
try:
|
||||
await self._cache.invalidate(pattern=formatted)
|
||||
except Exception as exc:
|
||||
# CachePort 故障降级:不阻塞禁用流程,但需记录完整堆栈
|
||||
await self._logger.exception(
|
||||
"iLink onAccountDisabled: cache invalidate failed, non-blocking",
|
||||
account_id=account_id,
|
||||
pattern=formatted,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._cache.delete(f"credentials:{account_id}")
|
||||
except Exception as exc:
|
||||
await self._logger.exception(
|
||||
"iLink onAccountDisabled: credentials delete failed, non-blocking",
|
||||
account_id=account_id,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
await self._logger.info(
|
||||
"iLink account disabled: PullerWorker will be stopped by transport engine, "
|
||||
"context_token cleared, bot_token cached for fast recovery",
|
||||
account_id=account.account_id,
|
||||
"iLink account disabled: runtime cache cleared, whitelist preserved",
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
def supportsRotateCredentials(self) -> bool:
|
||||
@ -198,7 +261,7 @@ class WeChatILinkLifecycleAdapter:
|
||||
iLink bot_token 通过 QR 扫码登录获取,重新扫码即自动失效旧 token,
|
||||
无需显式撤销。
|
||||
"""
|
||||
pass # 默认空实现
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["WeChatILinkLifecycleAdapter"]
|
||||
|
||||
@ -90,14 +90,15 @@ class WeChatILinkLoginAdapter:
|
||||
qrcode 令牌编码到 ``qr_data_url`` 的 fragment(``#qrcode=xxx``)中,
|
||||
由 ``QrLoginService`` 会话管理承载,不落 ``CachePort``(INV-5)。
|
||||
|
||||
@pre: ``account_id`` 为 ``None`` 时使用默认账户 ``default``。
|
||||
iLink ``get_bot_qrcode`` 为登录前接口,无需鉴权,``account_id``
|
||||
仅用于 Protocol 签名兼容,不影响请求。
|
||||
|
||||
@post: 返回 ``QrLoginStartResult``,``connected`` 始终为 False,
|
||||
``qr_data_url`` 携带 qrcode 令牌 fragment 供
|
||||
``loginWithQrWait`` 提取。
|
||||
@failure: 渠道 API 调用失败抛契约层异常(由 ``ILinkClient`` 转换)。
|
||||
"""
|
||||
acct = account_id or _DEFAULT_ACCOUNT
|
||||
resp = await self._client.get_bot_qrcode(acct)
|
||||
resp = await self._client.get_bot_qrcode()
|
||||
qr_content = resp.get("qrcode_img_content")
|
||||
qrcode = resp.get("qrcode", "")
|
||||
qr_data_url = self._normalize_qr_data_url(qr_content, qrcode)
|
||||
|
||||
@ -37,7 +37,8 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
|
||||
from .. import crypto
|
||||
from ..ilink_client import CHANNEL_TARGET, ILinkClient
|
||||
from .._constants import CHANNEL_TARGET, DEFAULT_MAX_MESSAGE_LENGTH
|
||||
from ..ilink_client import ILinkClient
|
||||
|
||||
# iLink 消息类型常量
|
||||
_MESSAGE_TYPE_BOT = 2
|
||||
@ -47,9 +48,6 @@ _IMAGE_ITEM_TYPE = 2
|
||||
_VIDEO_ITEM_TYPE = 3
|
||||
_FILE_ITEM_TYPE = 4
|
||||
|
||||
# 长文本分片默认阈值(Unicode 字符数),ConfigPort 未命中时回退
|
||||
_DEFAULT_MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
|
||||
class WeChatILinkOutboundAdapter:
|
||||
"""微信 iLink 出站消息投递适配器。
|
||||
@ -135,6 +133,8 @@ class WeChatILinkOutboundAdapter:
|
||||
account_id: str,
|
||||
peer_id: str,
|
||||
payload: FormattedMessage,
|
||||
*,
|
||||
delivered_parts: list[int] | None = None,
|
||||
) -> list[MultiPartReceipt]:
|
||||
"""发送多部分消息。
|
||||
|
||||
@ -147,6 +147,9 @@ class WeChatILinkOutboundAdapter:
|
||||
发送前预校验 ``context_token``,避免文本已发送、媒体因缺 token
|
||||
失败导致半截消息。每片独立调用 ``sendMessage`` 生成唯一 ``client_id``。
|
||||
|
||||
H-15 续发:``delivered_parts`` 含已成功投递的分片序号,适配器跳过
|
||||
这些序号对应的分片(含文本与媒体),仅发送未投递分片。
|
||||
|
||||
@consistency: 最终一致(eventual),部分分片可能成功部分失败。
|
||||
@idempotent: False — 每片生成独立 ``client_id``,重试会产生重复分片。
|
||||
"""
|
||||
@ -161,39 +164,21 @@ class WeChatILinkOutboundAdapter:
|
||||
# 分片阈值从 ConfigPort 读取(支持热更新)
|
||||
max_length = await self._read_channel_config(
|
||||
"max_message_length",
|
||||
_DEFAULT_MAX_MESSAGE_LENGTH,
|
||||
DEFAULT_MAX_MESSAGE_LENGTH,
|
||||
)
|
||||
|
||||
receipts: list[MultiPartReceipt] = []
|
||||
seq = 0
|
||||
skip_seqs = set(delivered_parts) if delivered_parts else set()
|
||||
|
||||
if payload.content:
|
||||
if len(payload.content) <= max_length:
|
||||
msg_id = await self.sendMessage(
|
||||
account_id,
|
||||
peer_id,
|
||||
FormattedMessage(
|
||||
content=payload.content,
|
||||
format=payload.format,
|
||||
),
|
||||
)
|
||||
receipts.append(
|
||||
MultiPartReceipt(
|
||||
platform_msg_id=msg_id,
|
||||
part_type="text",
|
||||
sequence=seq,
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
else:
|
||||
chunks = [payload.content[i : i + max_length] for i in range(0, len(payload.content), max_length)]
|
||||
for chunk in chunks:
|
||||
if seq not in skip_seqs:
|
||||
msg_id = await self.sendMessage(
|
||||
account_id,
|
||||
peer_id,
|
||||
FormattedMessage(
|
||||
content=chunk,
|
||||
content=payload.content,
|
||||
format=payload.format,
|
||||
),
|
||||
)
|
||||
@ -205,11 +190,35 @@ class WeChatILinkOutboundAdapter:
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
else:
|
||||
chunks = [payload.content[i : i + max_length] for i in range(0, len(payload.content), max_length)]
|
||||
for chunk in chunks:
|
||||
if seq not in skip_seqs:
|
||||
msg_id = await self.sendMessage(
|
||||
account_id,
|
||||
peer_id,
|
||||
FormattedMessage(
|
||||
content=chunk,
|
||||
format=payload.format,
|
||||
),
|
||||
)
|
||||
receipts.append(
|
||||
MultiPartReceipt(
|
||||
platform_msg_id=msg_id,
|
||||
part_type="text",
|
||||
sequence=seq,
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
|
||||
for attachment in payload.attachments:
|
||||
if attachment.content is None:
|
||||
continue
|
||||
if seq in skip_seqs:
|
||||
seq += 1
|
||||
continue
|
||||
aes_key = crypto.generate_random_aes_key()
|
||||
ciphertext = crypto.encrypt(attachment.content, aes_key)
|
||||
file_size = crypto.calc_ciphertext_size(len(attachment.content))
|
||||
|
||||
@ -23,11 +23,9 @@ from yuxi.channels.contract.errors import DependencyError, NotFoundError
|
||||
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, DEFAULT_LONG_POLL_TIMEOUT_MS
|
||||
from ..error_translator import translate_poll_error
|
||||
from ..ilink_client import CHANNEL_TARGET, ILinkClient
|
||||
|
||||
# 配置默认值(与 manifest.json long_poll_timeout_ms 一致,ConfigPort 未命中时回退)
|
||||
_DEFAULT_LONG_POLL_TIMEOUT_MS = 35000
|
||||
from ..ilink_client import ILinkClient
|
||||
|
||||
|
||||
class WeChatILinkPullerAdapter:
|
||||
@ -104,7 +102,7 @@ class WeChatILinkPullerAdapter:
|
||||
"""
|
||||
timeout_ms = await self._read_channel_config(
|
||||
"long_poll_timeout_ms",
|
||||
_DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
)
|
||||
return PollingConfig(
|
||||
long_poll_timeout_ms=timeout_ms,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
实现 ``WhitelistAdapter`` Protocol,管理 C2C 私聊白名单。iLink(个人微信)
|
||||
无服务端白名单 API,白名单条目本地存储于 ``CachePort``,键模式为
|
||||
``wechat_ilink:whitelist:{account_id}:{policy_type}``。每个 bot_token 对应
|
||||
``wechat_ilink:{account_id}:whitelist:{policy_type}``。每个 bot_token 对应
|
||||
一个微信账号,``account_id`` 由调用方(``WhitelistHandler``)传入,多账户
|
||||
场景下按账户隔离。
|
||||
|
||||
@ -59,7 +59,7 @@ class WeChatILinkWhitelistAdapter:
|
||||
|
||||
def _key(self, account_id: str, policy_type: WhitelistPolicyType) -> str:
|
||||
"""构造白名单存储键。"""
|
||||
return f"wechat_ilink:whitelist:{account_id}:{policy_type.value}"
|
||||
return f"wechat_ilink:{account_id}:whitelist:{policy_type.value}"
|
||||
|
||||
async def _load_entries(
|
||||
self,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""微信 iLink 渠道内部 DTO。"""
|
||||
@ -21,12 +21,13 @@ client 实例化、LifecycleHandler 构造与 ``registerAdapter`` 调用。
|
||||
适配器 DI 说明:
|
||||
- ``puller`` / ``outbound`` 适配器接收 ``(client, config_port, logger_port)``:
|
||||
适配器直接持有 ConfigPort 读取渠道级配置(对齐 wechat_woc 模式)。
|
||||
- 5 个适配器(inbound / login / lifecycle / doctor / wizard)接收
|
||||
- 4 个适配器(inbound / login / doctor / wizard)接收
|
||||
``(client, logger_port)``。
|
||||
- ``lifecycle`` / ``whitelist`` 适配器接收 ``(cache_port, logger_port)``:
|
||||
仅做账户级缓存清理与白名单数据存储,不调用渠道侧 API,无需 ILinkClient
|
||||
(对齐 wechat_woc 模式)。
|
||||
- ``probeable`` 适配器接收 ``(client, persistence_port, logger_port)``:
|
||||
需动态发现 account_id 调用 getconfig 探活。
|
||||
- ``whitelist`` 适配器接收 ``(cache_port, logger_port)``:白名单数据本地
|
||||
存储于 CachePort,不调用渠道侧 API,无需 ILinkClient。
|
||||
- ``session`` / ``status`` 适配器无状态(INV-5),无 ``__init__`` 参数,
|
||||
仅做协议转换,所有数据从 raw_event.payload 提取。
|
||||
|
||||
@ -110,7 +111,7 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest
|
||||
# 2. 实例化 ILinkClient 与 LifecycleHandler
|
||||
# 依赖关系为单向:handler 在 onInit 调 client.attach_http_client 注入连接池,
|
||||
# client 不引用 handler。先创建 client,再创建 handler 并通过构造器注入。
|
||||
# config_schema 取自 manifest(F-03 单真相源)。
|
||||
# config_schema / max_connections 取自 manifest(F-03 单真相源)。
|
||||
client = ILinkClient(config_port, cache_port, logger_port)
|
||||
handler = WeChatILinkLifecycleHandler(
|
||||
config_port,
|
||||
@ -118,21 +119,24 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest
|
||||
cache_port,
|
||||
manifest.config_schema,
|
||||
client,
|
||||
max_connections=(manifest.resource_quota.max_connections if manifest.resource_quota is not None else 10),
|
||||
)
|
||||
|
||||
# 3. 实例化并注册 11 个适配器
|
||||
# puller / outbound 接收 (client, config_port, logger_port):
|
||||
# 适配器直接持有 ConfigPort 读取渠道级配置(对齐 wechat_woc 模式)。
|
||||
# inbound / login / lifecycle / doctor / wizard 接收 (client, logger_port);
|
||||
# inbound / login / doctor / wizard 接收 (client, logger_port);
|
||||
# lifecycle / whitelist 接收 (cache_port, logger_port)(账户级缓存清理,
|
||||
# 不调用渠道侧 API,对齐 wechat_woc 模式);
|
||||
# probeable 接收 (client, persistence_port, logger_port);
|
||||
# whitelist 接收 (cache_port, logger_port);session / status 无状态。
|
||||
# session / status 无状态。
|
||||
host.registerAdapter("puller", WeChatILinkPullerAdapter(client, config_port, logger_port))
|
||||
host.registerAdapter("inbound", WeChatILinkInboundAdapter(client, logger_port))
|
||||
host.registerAdapter("outbound", WeChatILinkOutboundAdapter(client, config_port, logger_port))
|
||||
host.registerAdapter("session", WeChatILinkSessionAdapter())
|
||||
host.registerAdapter("status", WeChatILinkStatusAdapter())
|
||||
host.registerAdapter("login", WeChatILinkLoginAdapter(client, logger_port))
|
||||
host.registerAdapter("lifecycle", WeChatILinkLifecycleAdapter(client, logger_port))
|
||||
host.registerAdapter("lifecycle", WeChatILinkLifecycleAdapter(cache_port, logger_port))
|
||||
host.registerAdapter("probeable", WeChatILinkProbeableAdapter(client, persistence_port, logger_port))
|
||||
host.registerAdapter("doctor", WeChatILinkDoctorAdapter(client, logger_port))
|
||||
host.registerAdapter("wizard", WeChatILinkWizardAdapter(client, logger_port))
|
||||
|
||||
@ -29,21 +29,17 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
|
||||
from . import crypto, error_translator
|
||||
|
||||
# 渠道级配置的 target(插件 provides 值)
|
||||
# 公开供同插件适配器(puller / outbound)复用,避免各模块重复定义字符串字面量
|
||||
CHANNEL_TARGET = "wechat_ilink"
|
||||
|
||||
# 渠道级配置默认值(与 manifest.json 保持一致)
|
||||
_DEFAULT_BASEURL = "https://ilinkai.weixin.qq.com"
|
||||
_DEFAULT_CHANNEL_VERSION = "2.0.0"
|
||||
_DEFAULT_LONG_POLL_TIMEOUT_MS = 35000
|
||||
|
||||
# 长轮询客户端超时余量(ms):长于服务端 35s hang,避免客户端先超时
|
||||
_LONG_POLL_EXTRA_MS = 5000
|
||||
from ._constants import (
|
||||
CHANNEL_TARGET,
|
||||
DEFAULT_BASEURL,
|
||||
DEFAULT_CHANNEL_VERSION,
|
||||
DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
HTTP_TIMEOUT_SECONDS,
|
||||
LONG_POLL_EXTRA_MS,
|
||||
)
|
||||
|
||||
# 普通请求默认超时(秒)
|
||||
_DEFAULT_REQUEST_TIMEOUT_S = 30.0
|
||||
_DEFAULT_REQUEST_TIMEOUT_S = HTTP_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
class ILinkClient:
|
||||
@ -337,14 +333,14 @@ class ILinkClient:
|
||||
timeout_seconds: 请求超时秒数。
|
||||
"""
|
||||
if base_url is None:
|
||||
baseurl = await self.get_channel_config("baseurl", _DEFAULT_BASEURL)
|
||||
baseurl = await self.get_channel_config("baseurl", DEFAULT_BASEURL)
|
||||
else:
|
||||
baseurl = base_url
|
||||
effective_body = body if body is not None else {}
|
||||
if inject_base_info:
|
||||
channel_version = await self.get_channel_config(
|
||||
"channel_version",
|
||||
_DEFAULT_CHANNEL_VERSION,
|
||||
DEFAULT_CHANNEL_VERSION,
|
||||
)
|
||||
base_info = effective_body.get("base_info")
|
||||
if isinstance(base_info, dict):
|
||||
@ -383,7 +379,7 @@ class ILinkClient:
|
||||
- 返回校验后的 JSON 响应体
|
||||
"""
|
||||
if base_url is None:
|
||||
base_url = await self.get_channel_config("baseurl", _DEFAULT_BASEURL)
|
||||
base_url = await self.get_channel_config("baseurl", DEFAULT_BASEURL)
|
||||
elif not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
url = f"{base_url}{path}"
|
||||
@ -414,9 +410,9 @@ class ILinkClient:
|
||||
"""
|
||||
timeout_ms = await self.get_channel_config(
|
||||
"long_poll_timeout_ms",
|
||||
_DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
)
|
||||
timeout_s = (int(timeout_ms) + _LONG_POLL_EXTRA_MS) / 1000.0
|
||||
timeout_s = (int(timeout_ms) + LONG_POLL_EXTRA_MS) / 1000.0
|
||||
body = {"get_updates_buf": cursor}
|
||||
return await self._post(
|
||||
"/ilink/bot/getupdates",
|
||||
@ -438,7 +434,7 @@ class ILinkClient:
|
||||
{"msg": msg},
|
||||
)
|
||||
|
||||
async def get_bot_qrcode(self, account_id: str = "") -> dict:
|
||||
async def get_bot_qrcode(self) -> dict:
|
||||
"""获取登录二维码(登录前,无需鉴权)。
|
||||
|
||||
GET /ilink/bot/get_bot_qrcode?bot_type=3
|
||||
@ -466,9 +462,9 @@ class ILinkClient:
|
||||
"""
|
||||
timeout_ms = await self.get_channel_config(
|
||||
"long_poll_timeout_ms",
|
||||
_DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
DEFAULT_LONG_POLL_TIMEOUT_MS,
|
||||
)
|
||||
timeout_s = (int(timeout_ms) + _LONG_POLL_EXTRA_MS) / 1000.0
|
||||
timeout_s = (int(timeout_ms) + LONG_POLL_EXTRA_MS) / 1000.0
|
||||
return await self._get(
|
||||
"/ilink/bot/get_qrcode_status",
|
||||
None,
|
||||
@ -598,7 +594,7 @@ class ILinkClient:
|
||||
from_user_id: str,
|
||||
) -> str | None:
|
||||
"""读取 context_token,未命中返回 None。"""
|
||||
key = f"wechat_ilink:context_token:{account_id}:{from_user_id}"
|
||||
key = f"wechat_ilink:{account_id}:context_token:{from_user_id}"
|
||||
cached = await self._cache.get(key)
|
||||
if cached.is_some():
|
||||
return cached.unwrap()
|
||||
@ -611,26 +607,9 @@ class ILinkClient:
|
||||
context_token: str,
|
||||
) -> None:
|
||||
"""写入 context_token。"""
|
||||
key = f"wechat_ilink:context_token:{account_id}:{from_user_id}"
|
||||
key = f"wechat_ilink:{account_id}:context_token:{from_user_id}"
|
||||
await self._cache.set(key, context_token)
|
||||
|
||||
async def clear_context_token(
|
||||
self,
|
||||
account_id: str,
|
||||
from_user_id: str | None = None,
|
||||
) -> None:
|
||||
"""清除 context_token。
|
||||
|
||||
from_user_id 为 None 时清除该 account_id 下所有 context_token
|
||||
(使用 invalidate 模式匹配)。
|
||||
"""
|
||||
if from_user_id is None:
|
||||
pattern = f"wechat_ilink:context_token:{account_id}:*"
|
||||
await self._cache.invalidate(pattern)
|
||||
else:
|
||||
key = f"wechat_ilink:context_token:{account_id}:{from_user_id}"
|
||||
await self._cache.delete(key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# cursor 缓存
|
||||
# ------------------------------------------------------------------
|
||||
@ -641,7 +620,7 @@ class ILinkClient:
|
||||
供 ``DoctorAdapter`` 健康检查使用:非空游标表示长轮询已建立。
|
||||
游标写入由框架通过 ``PollResult.next_cursor`` 管理,适配器不持久化。
|
||||
"""
|
||||
key = f"wechat_ilink:cursor:{account_id}"
|
||||
key = f"wechat_ilink:{account_id}:cursor"
|
||||
cached = await self._cache.get(key)
|
||||
if cached.is_some():
|
||||
return cached.unwrap()
|
||||
|
||||
@ -36,10 +36,15 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
if TYPE_CHECKING:
|
||||
from .ilink_client import ILinkClient
|
||||
|
||||
# httpx 连接池配置(对齐 manifest.json resource_quota: max_connections=10)
|
||||
_HTTP_TIMEOUT_SECONDS = 30.0
|
||||
_HTTP_MAX_CONNECTIONS = 10
|
||||
_HTTP_MAX_KEEPALIVE_CONNECTIONS = 5
|
||||
from ._constants import (
|
||||
DEFAULT_MAX_CONNECTIONS,
|
||||
DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
|
||||
DRAIN_TIMEOUT_SECONDS,
|
||||
HTTP_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
# CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键残留)
|
||||
_CACHE_INVALIDATE_PATTERN = "wechat_ilink:*"
|
||||
|
||||
|
||||
class WeChatILinkLifecycleHandler:
|
||||
@ -58,11 +63,13 @@ class WeChatILinkLifecycleHandler:
|
||||
cache_port: CachePort,
|
||||
config_schema: tuple[ConfigField, ...],
|
||||
client: ILinkClient | None = None,
|
||||
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
||||
) -> None:
|
||||
self._config = config_port
|
||||
self._logger = logger_port
|
||||
self._cache = cache_port
|
||||
self._client = client
|
||||
self._max_connections = max_connections
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._started: bool = False
|
||||
# 从 config_schema 派生不可热更新键(hot_reloadable=False 的字段 key)
|
||||
@ -77,10 +84,10 @@ class WeChatILinkLifecycleHandler:
|
||||
async def onInit(self) -> None:
|
||||
"""初始化资源:创建 httpx 连接池并注入 ILinkClient。"""
|
||||
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=self._max_connections,
|
||||
max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
|
||||
),
|
||||
)
|
||||
if self._client is not None:
|
||||
@ -106,7 +113,12 @@ class WeChatILinkLifecycleHandler:
|
||||
self._started = False
|
||||
if self._client is not None:
|
||||
self._client.set_active(False)
|
||||
await self._client.drain(timeout=30.0)
|
||||
drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS)
|
||||
if not drained:
|
||||
await self._logger.warn(
|
||||
"WeChat iLink plugin drain timed out, in-flight requests may be interrupted",
|
||||
timeout=DRAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
async def onPause(self) -> None:
|
||||
"""暂停插件:停止接收新请求。"""
|
||||
@ -123,17 +135,24 @@ class WeChatILinkLifecycleHandler:
|
||||
await self._logger.info("WeChat iLink plugin resumed")
|
||||
|
||||
async def onUnload(self) -> None:
|
||||
"""释放所有资源:drain 在途请求后关闭 httpx 连接池。
|
||||
"""释放所有资源:drain 在途请求后关闭 httpx 连接池并清空缓存。
|
||||
|
||||
长轮询连接由 ``StreamWorker`` 在宿主关停时首先停止,
|
||||
本方法先 drain 在途请求(默认 30s 超时),再解除 client 引用并
|
||||
关闭连接池。
|
||||
关闭连接池,最后清空 CachePort 中 ``wechat_ilink:*`` 缓存避免
|
||||
孤儿键残留(FR-32 资源释放约束)。
|
||||
"""
|
||||
# 先 drain 在途请求,避免 aclose 打断正在进行的请求
|
||||
# 1. 先 drain 在途请求,避免 aclose 打断正在进行的请求
|
||||
if self._client is not None:
|
||||
self._client.set_active(False)
|
||||
await self._client.drain(timeout=30.0)
|
||||
drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS)
|
||||
if not drained:
|
||||
await self._logger.warn(
|
||||
"WeChat iLink plugin unload drain timed out, in-flight requests may be interrupted",
|
||||
timeout=DRAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._client.detach_http_client()
|
||||
# 2. 关闭 httpx 连接池(异常告警不抛,避免阻断后续缓存清理)
|
||||
if self._http_client is not None:
|
||||
try:
|
||||
await self._http_client.aclose()
|
||||
@ -144,6 +163,15 @@ class WeChatILinkLifecycleHandler:
|
||||
)
|
||||
self._http_client = None
|
||||
|
||||
# 3. 清空 CachePort 中 wechat_ilink:* 缓存,避免孤儿键残留
|
||||
try:
|
||||
await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
"WeChat iLink cache invalidate failed during unload",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
self._started = False
|
||||
await self._logger.info("WeChat iLink plugin unloaded")
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
},
|
||||
"max_message_length": 4000,
|
||||
"supports_credential_cloning": false,
|
||||
"transport_mode": "pull",
|
||||
"capabilities": {
|
||||
"rich_message": false,
|
||||
"streaming": false,
|
||||
@ -54,6 +55,18 @@
|
||||
"supports_qr_login": true,
|
||||
"agent_collaboration": false
|
||||
},
|
||||
"capability_requirements": [
|
||||
["supports_image_inbound", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["supports_video_inbound", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["supports_image_outbound", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["supports_video_outbound", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["doctor", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["whitelist", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["wizard", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]],
|
||||
["probeable", []],
|
||||
["lifecycle", []],
|
||||
["supports_qr_login", ["bot_token", "ilink_bot_id", "ilink_user_id", "baseurl"]]
|
||||
],
|
||||
"resource_quota": {
|
||||
"max_cpu": "15.0",
|
||||
"max_memory": "128MB",
|
||||
@ -77,16 +90,6 @@
|
||||
{"key": "long_poll_timeout_ms", "type": "integer", "required": false, "default": 35000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 10000, "max": 60000}},
|
||||
{"key": "qr_poll_interval_ms", "type": "integer", "required": false, "default": 2000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 1000, "max": 5000}},
|
||||
{"key": "qr_timeout_ms", "type": "integer", "required": false, "default": 120000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 30000, "max": 300000}},
|
||||
{"key": "max_message_length", "type": "integer", "required": false, "default": 4000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 500, "max": 10000}},
|
||||
{"key": "enable_media", "type": "boolean", "required": false, "default": true, "hot_reloadable": true, "scope": "channel", "constraints": {}},
|
||||
{"key": "enable_voice", "type": "boolean", "required": false, "default": false, "hot_reloadable": true, "scope": "channel", "constraints": {}}
|
||||
],
|
||||
"env_vars": [
|
||||
{"name": "ILINK_BASE_URL", "description": "iLink API 基础地址", "required": false, "default": "https://ilinkai.weixin.qq.com", "sensitive": false},
|
||||
{"name": "ILINK_CDN_URL", "description": "iLink CDN 地址", "required": false, "default": "https://novac2c.cdn.weixin.qq.com/c2c", "sensitive": false},
|
||||
{"name": "ILINK_CHANNEL_VERSION", "description": "iLink 渠道协议版本", "required": false, "default": "2.0.0", "sensitive": false},
|
||||
{"name": "ILINK_QR_TIMEOUT_MS", "description": "扫码登录超时时间(毫秒)", "required": false, "default": "120000", "sensitive": false},
|
||||
{"name": "ILINK_LONGPOLL_TIMEOUT_MS", "description": "长轮询超时时间(毫秒)", "required": false, "default": "35000", "sensitive": false},
|
||||
{"name": "ILINK_QR_POLL_INTERVAL_MS", "description": "扫码状态轮询间隔(毫秒)", "required": false, "default": "2000", "sensitive": false}
|
||||
{"key": "max_message_length", "type": "integer", "required": false, "default": 4000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 500, "max": 10000}}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user