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:
Kris 2026-07-08 22:56:45 +08:00
parent 063b28ddb2
commit 2a2385bacc
12 changed files with 307 additions and 144 deletions

View File

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

View File

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

View File

@ -5,19 +5,27 @@
配置落库后回调删除前缓存清理启用/禁用回调
设计要点
- 适配器无 mutable 实例变量INV-5
- 适配器无 mutable 实例变量INV-5仅持有 DI 注入的 ``CachePort`` /
``LoggerPort``
- 长轮询由传输引擎通过 ``ChannelAccountOnline``/``ChannelAccountOffline``
领域事件触发 ``PullerWorker`` /本适配器不自管理轮询任务决策 3
领域事件触发 ``PullerWorker`` 本适配器不自管理轮询任务决策 3
- 所有生命周期回调方法幂等重复调用安全
- ``resolveAccountId`` 纯本地解析不调 API
- ``applyAccountConfig`` 仅校验配置并规范化不写库也不写 CachePort凭证由
CredentialService 通过 ConfigPortCachePort 统一管理
- ``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`` 启动/停止本适配器不自管理轮询任务
决策 3INV-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 移除本方法仅清理运行时会话上下文 tokencontext_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"]

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
"""微信 iLink 渠道内部 DTO。"""

View File

@ -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 取自 manifestF-03 单真相源)。
# config_schema / max_connections 取自 manifestF-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))

View File

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

View File

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

View File

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