"""微信 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