282 lines
11 KiB
Python
282 lines
11 KiB
Python
|
|
"""微信 iLink(ClawBot)渠道插件入口。
|
|||
|
|
|
|||
|
|
实现 ``CHANNEL_ENTRY`` 契约,注册 11 个适配器与生命周期钩子,返回
|
|||
|
|
``PluginManifest``。由宿主在 loaded 阶段通过 ``importlib`` 导入并调用。
|
|||
|
|
|
|||
|
|
调用顺序(严格):
|
|||
|
|
1. ``declareAccessiblePorts`` / ``declareInjectablePipelines`` /
|
|||
|
|
``declareResourceQuota`` 声明能力边界(必须在 ``getXxxPort`` 之前)。
|
|||
|
|
2. ``getXxxPort`` 获取被驱动端口实例。
|
|||
|
|
3. 实例化 ``WeChatILinkLifecycleHandler`` 与 ``ILinkClient``。
|
|||
|
|
4. 实例化并注册 11 个适配器(puller / inbound / outbound / session /
|
|||
|
|
status / login / lifecycle / probeable / doctor / wizard / whitelist)。
|
|||
|
|
5. ``registerLifecycleHandler`` 注册生命周期钩子(鸭子类型调用)。
|
|||
|
|
6. 返回 ``PluginManifest``。
|
|||
|
|
|
|||
|
|
适配器 DI 说明:
|
|||
|
|
- 8 个适配器(puller / inbound / outbound / login / lifecycle / probeable /
|
|||
|
|
doctor / wizard)接收 ``(client, logger_port)``。
|
|||
|
|
- ``whitelist`` 适配器接收 ``(cache_port, logger_port)``:白名单数据本地
|
|||
|
|
存储于 CachePort,不调用渠道侧 API,无需 ILinkClient。
|
|||
|
|
- ``session`` / ``status`` 适配器无状态(INV-5),无 ``__init__`` 参数,
|
|||
|
|
仅做协议转换,所有数据从 raw_event.payload 提取。
|
|||
|
|
|
|||
|
|
长轮询传输管理:iLink 通过 ``PullerAdapter`` 长轮询 ``getupdates`` 拉取
|
|||
|
|
消息,``PullerWorker`` 由传输引擎通过 ``ChannelAccountOnline`` /
|
|||
|
|
``ChannelAccountOffline`` 领域事件触发启停,``LifecycleAdapter`` 与
|
|||
|
|
``LifecycleHandler`` 不自管理轮询任务。
|
|||
|
|
|
|||
|
|
依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + 同插件内部模块,
|
|||
|
|
不污染框架层。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from yuxi.channels.contract.dtos.capability import ChannelCapabilities
|
|||
|
|
from yuxi.channels.contract.dtos.channel import ChannelType
|
|||
|
|
from yuxi.channels.contract.dtos.config import ConfigField
|
|||
|
|
from yuxi.channels.contract.dtos.plugin import EnvVar
|
|||
|
|
from yuxi.channels.contract.plugin.entry import CHANNEL_ENTRY, PluginHost
|
|||
|
|
from yuxi.channels.contract.plugin.manifest import (
|
|||
|
|
ChannelManifest,
|
|||
|
|
FailurePolicy,
|
|||
|
|
PluginManifest,
|
|||
|
|
ResourceQuota,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
from .adapters.doctor_adapter import WeChatILinkDoctorAdapter
|
|||
|
|
from .adapters.inbound_adapter import WeChatILinkInboundAdapter
|
|||
|
|
from .adapters.lifecycle_adapter import WeChatILinkLifecycleAdapter
|
|||
|
|
from .adapters.login_adapter import WeChatILinkLoginAdapter
|
|||
|
|
from .adapters.outbound_adapter import WeChatILinkOutboundAdapter
|
|||
|
|
from .adapters.probeable_adapter import WeChatILinkProbeableAdapter
|
|||
|
|
from .adapters.puller_adapter import WeChatILinkPullerAdapter
|
|||
|
|
from .adapters.session_adapter import WeChatILinkSessionAdapter
|
|||
|
|
from .adapters.status_adapter import WeChatILinkStatusAdapter
|
|||
|
|
from .adapters.whitelist_adapter import WeChatILinkWhitelistAdapter
|
|||
|
|
from .adapters.wizard_adapter import WeChatILinkWizardAdapter
|
|||
|
|
from .ilink_client import ILinkClient
|
|||
|
|
from .lifecycle import WeChatILinkLifecycleHandler
|
|||
|
|
|
|||
|
|
# 可访问端口(与 manifest.json accessible_ports 一致)
|
|||
|
|
_ACCESSIBLE_PORTS: tuple[str, ...] = (
|
|||
|
|
"ConfigPort",
|
|||
|
|
"LoggerPort",
|
|||
|
|
"PersistencePort",
|
|||
|
|
"CachePort",
|
|||
|
|
"TracerPort",
|
|||
|
|
"ConversationPort",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 可注入管道(与 manifest.json injectable_pipelines 一致)
|
|||
|
|
_INJECTABLE_PIPELINES: tuple[str, ...] = ("inbound", "outbound")
|
|||
|
|
|
|||
|
|
# 已注册适配器类型列表(与 registerAdapter 调用顺序一致)
|
|||
|
|
_ADAPTER_TYPES: tuple[str, ...] = (
|
|||
|
|
"puller", "inbound", "outbound", "session", "status",
|
|||
|
|
"login", "lifecycle", "probeable", "doctor", "wizard", "whitelist",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def channel_entry(host: PluginHost) -> PluginManifest:
|
|||
|
|
"""微信 iLink 渠道插件入口。
|
|||
|
|
|
|||
|
|
由宿主在 loaded 阶段调用,完成端口声明、适配器注册、生命周期钩子注册,
|
|||
|
|
返回 ``PluginManifest``。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# 1. 声明能力边界(必须在 getXxxPort 之前,宿主 _checkPortAccess 校验)
|
|||
|
|
host.declareAccessiblePorts(_ACCESSIBLE_PORTS)
|
|||
|
|
host.declareInjectablePipelines(_INJECTABLE_PIPELINES)
|
|||
|
|
host.declareResourceQuota(ResourceQuota(
|
|||
|
|
max_cpu="15.0",
|
|||
|
|
max_memory="128MB",
|
|||
|
|
max_connections=10,
|
|||
|
|
max_calls_per_sec=20,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# 2. 获取端口实例(声明之后)
|
|||
|
|
config_port = host.getConfigPort()
|
|||
|
|
logger_port = host.getLoggerPort()
|
|||
|
|
cache_port = host.getCachePort()
|
|||
|
|
persistence_port = host.getPersistencePort()
|
|||
|
|
|
|||
|
|
# 3. 实例化生命周期处理器与 ILinkClient
|
|||
|
|
# handler 需 client 引用以注入连接池;先实例化 handler(client=None),
|
|||
|
|
# 再实例化 client,最后通过 attach_client 建立引用,解决循环依赖。
|
|||
|
|
handler = WeChatILinkLifecycleHandler(config_port, logger_port, cache_port)
|
|||
|
|
client = ILinkClient(config_port, cache_port, persistence_port, logger_port)
|
|||
|
|
handler.attach_client(client)
|
|||
|
|
|
|||
|
|
# 4. 实例化并注册 11 个适配器
|
|||
|
|
# 9 个适配器接收 (client, logger_port);session / status 无状态。
|
|||
|
|
host.registerAdapter("puller", WeChatILinkPullerAdapter(client, logger_port))
|
|||
|
|
host.registerAdapter("inbound", WeChatILinkInboundAdapter(client, logger_port))
|
|||
|
|
host.registerAdapter("outbound", WeChatILinkOutboundAdapter(client, 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("probeable", WeChatILinkProbeableAdapter(client, logger_port))
|
|||
|
|
host.registerAdapter("doctor", WeChatILinkDoctorAdapter(client, logger_port))
|
|||
|
|
host.registerAdapter("wizard", WeChatILinkWizardAdapter(client, logger_port))
|
|||
|
|
host.registerAdapter("whitelist", WeChatILinkWhitelistAdapter(cache_port, logger_port))
|
|||
|
|
|
|||
|
|
# 5. 注册生命周期钩子
|
|||
|
|
# PluginHost Protocol 未声明 registerLifecycleHandler,但 PluginHostImpl
|
|||
|
|
# 已实现,运行时通过鸭子类型调用。
|
|||
|
|
host.registerLifecycleHandler(handler)
|
|||
|
|
|
|||
|
|
# 6. 返回 PluginManifest
|
|||
|
|
return PluginManifest(
|
|||
|
|
manifest=ChannelManifest(
|
|||
|
|
id="com.yuxi.channels.wechat_ilink",
|
|||
|
|
name="微信iLink",
|
|||
|
|
version="1.0.0",
|
|||
|
|
channel_type=ChannelType.CUSTOM,
|
|||
|
|
provides=("wechat_ilink",),
|
|||
|
|
entry_module="yuxi.channels.plugins.wechat_ilink.entry",
|
|||
|
|
capabilities=ChannelCapabilities(
|
|||
|
|
rich_message=False,
|
|||
|
|
streaming=False,
|
|||
|
|
typing_indicator=False,
|
|||
|
|
message_edit=False,
|
|||
|
|
message_recall=False,
|
|||
|
|
mention=False,
|
|||
|
|
command=False,
|
|||
|
|
directory=False,
|
|||
|
|
doctor=True,
|
|||
|
|
whitelist=True,
|
|||
|
|
supports_reaction=False,
|
|||
|
|
supports_pin=False,
|
|||
|
|
supports_card_update=False,
|
|||
|
|
supports_image_inbound=True,
|
|||
|
|
supports_video_inbound=True,
|
|||
|
|
supports_image_outbound=True,
|
|||
|
|
supports_video_outbound=True,
|
|||
|
|
max_message_length=4000,
|
|||
|
|
lifecycle=True,
|
|||
|
|
supports_qr_login=True,
|
|||
|
|
),
|
|||
|
|
config_schema=_build_config_schema(),
|
|||
|
|
lifecycle=("init", "start", "stop", "pause", "resume", "unload"),
|
|||
|
|
compatibility={
|
|||
|
|
"min_host_version": "1.0.0",
|
|||
|
|
"ilink_api_version": "2.0.0",
|
|||
|
|
},
|
|||
|
|
failure_policy=FailurePolicy.DEGRADE,
|
|||
|
|
resource_quota=ResourceQuota(
|
|||
|
|
max_cpu="15.0",
|
|||
|
|
max_memory="128MB",
|
|||
|
|
max_connections=10,
|
|||
|
|
max_calls_per_sec=20,
|
|||
|
|
),
|
|||
|
|
accessible_ports=_ACCESSIBLE_PORTS,
|
|||
|
|
injectable_pipelines=_INJECTABLE_PIPELINES,
|
|||
|
|
env_vars=_build_env_vars(),
|
|||
|
|
critical=False,
|
|||
|
|
requires_dm_pairing=True,
|
|||
|
|
requires_outbound_delivery=True,
|
|||
|
|
),
|
|||
|
|
adapters=_ADAPTER_TYPES,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_config_schema() -> tuple[ConfigField, ...]:
|
|||
|
|
"""构造 12 个 ConfigField(与 manifest.json config_schema 一致)。"""
|
|||
|
|
return (
|
|||
|
|
ConfigField(
|
|||
|
|
key="bot_token", type="string", required=True,
|
|||
|
|
hot_reloadable=False,
|
|||
|
|
constraints={"sensitive": True},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="ilink_bot_id", type="string", required=True,
|
|||
|
|
hot_reloadable=False,
|
|||
|
|
constraints={},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="ilink_user_id", type="string", required=True,
|
|||
|
|
hot_reloadable=False,
|
|||
|
|
constraints={},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="baseurl", type="string", required=False,
|
|||
|
|
default="https://ilinkai.weixin.qq.com", hot_reloadable=False,
|
|||
|
|
constraints={"pattern": "^https?://"},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="channel_version", type="string", required=False,
|
|||
|
|
default="2.0.0", hot_reloadable=True,
|
|||
|
|
constraints={},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="cdn_url", type="string", required=False,
|
|||
|
|
default="https://novac2c.cdn.weixin.qq.com/c2c", hot_reloadable=True,
|
|||
|
|
constraints={"pattern": "^https?://"},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="long_poll_timeout_ms", type="integer", required=False,
|
|||
|
|
default=35000, hot_reloadable=True,
|
|||
|
|
constraints={"min": 10000, "max": 60000},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="qr_poll_interval_ms", type="integer", required=False,
|
|||
|
|
default=2000, hot_reloadable=True,
|
|||
|
|
constraints={"min": 1000, "max": 5000},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="qr_timeout_ms", type="integer", required=False,
|
|||
|
|
default=120000, hot_reloadable=True,
|
|||
|
|
constraints={"min": 30000, "max": 300000},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="max_message_length", type="integer", required=False,
|
|||
|
|
default=4000, hot_reloadable=True,
|
|||
|
|
constraints={"min": 500, "max": 10000},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="enable_media", type="boolean", required=False,
|
|||
|
|
default=True, hot_reloadable=True,
|
|||
|
|
constraints={},
|
|||
|
|
),
|
|||
|
|
ConfigField(
|
|||
|
|
key="enable_voice", type="boolean", required=False,
|
|||
|
|
default=False, hot_reloadable=True,
|
|||
|
|
constraints={},
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_env_vars() -> tuple[EnvVar, ...]:
|
|||
|
|
"""构造 6 个 EnvVar(与 manifest.json env_vars 一致)。"""
|
|||
|
|
return (
|
|||
|
|
EnvVar(
|
|||
|
|
name="ILINK_BASE_URL", description="iLink API 基础地址",
|
|||
|
|
required=False, default="https://ilinkai.weixin.qq.com",
|
|||
|
|
),
|
|||
|
|
EnvVar(
|
|||
|
|
name="ILINK_CDN_URL", description="iLink CDN 地址",
|
|||
|
|
required=False, default="https://novac2c.cdn.weixin.qq.com/c2c",
|
|||
|
|
),
|
|||
|
|
EnvVar(
|
|||
|
|
name="ILINK_CHANNEL_VERSION", description="iLink 渠道协议版本",
|
|||
|
|
required=False, default="2.0.0",
|
|||
|
|
),
|
|||
|
|
EnvVar(
|
|||
|
|
name="ILINK_QR_TIMEOUT_MS", description="扫码登录超时时间(毫秒)",
|
|||
|
|
required=False, default="120000",
|
|||
|
|
),
|
|||
|
|
EnvVar(
|
|||
|
|
name="ILINK_LONGPOLL_TIMEOUT_MS", description="长轮询超时时间(毫秒)",
|
|||
|
|
required=False, default="35000",
|
|||
|
|
),
|
|||
|
|
EnvVar(
|
|||
|
|
name="ILINK_QR_POLL_INTERVAL_MS", description="扫码状态轮询间隔(毫秒)",
|
|||
|
|
required=False, default="2000",
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
CHANNEL_ENTRY = channel_entry
|