refactor(wechat-ilink): 重构事件工具与适配器,修复媒体类型映射

1. 新增QQ Bot事件解析通用工具类,抽离重复的事件提取逻辑
2. 修复微信iLink适配器的媒体类型映射错误
3. 完善微信iLink状态适配器的事件类型与元数据支持
4. 新增运行时缓存清理方法,优化登出逻辑
5. 更新manifest配置,新增媒体相关开关
6. 重构出站适配器的媒体上传逻辑,修复参数问题
This commit is contained in:
Kris 2026-07-09 04:19:35 +08:00
parent c888db4e1f
commit ebbd552970
7 changed files with 182 additions and 45 deletions

View File

@ -0,0 +1,43 @@
"""QQ Bot 事件解析共享辅助函数。
集中定义从 ``RawEvent`` 提取事件类型``t`` 字段与事件数据``d`` 字段
的辅助函数消除 ``inbound`` / ``mention`` / ``session`` / ``status`` 四个
适配器中的重复定义P2-3 代码去重
``extract_event_data`` 采用宽松语义缺失时返回空 dict需要严格校验
缺失即抛 ``ValidationError``的调用方 ``session`` / ``status``
在调用后自行检查返回值
依赖方向 import ``yuxi.channels.contract.*``不污染框架层
"""
from __future__ import annotations
from typing import Any
from yuxi.channels.contract.dtos.common import RawEvent
def extract_event_type(raw_event: RawEvent) -> str:
"""提取 QQ Bot 事件类型(``t`` 字段)。
@return 事件类型字符串缺失时返回空字符串
"""
return str(raw_event.payload.get("t", ""))
def extract_event_data(raw_event: RawEvent) -> dict[str, Any]:
"""从原始事件提取 ``d`` 字段QQ Bot WS 事件数据载荷)。
采用宽松语义 ``d`` 字段缺失或非 dict 时返回空 dict不抛异常
需要严格校验的调用方应在调用后检查返回值是否为空
@return 事件数据 dict缺失时返回 ``{}``
"""
data = raw_event.payload.get("d")
if not isinstance(data, dict):
return {}
return data
__all__ = ["extract_event_type", "extract_event_data"]

View File

@ -33,12 +33,12 @@ from ..ilink_client import ILinkClient
_MEDIA_SCHEME = "wechat_ilink" _MEDIA_SCHEME = "wechat_ilink"
_MEDIA_HOST = "media" _MEDIA_HOST = "media"
# iLink item type 映射 # iLink item type 映射(功能点清单 §2.53=VOICE, 5=VIDEO
_TEXT_ITEM_TYPE = 1 _TEXT_ITEM_TYPE = 1
_IMAGE_ITEM_TYPE = 2 _IMAGE_ITEM_TYPE = 2
_VIDEO_ITEM_TYPE = 3 _VOICE_ITEM_TYPE = 3
_FILE_ITEM_TYPE = 4 _FILE_ITEM_TYPE = 4
_VOICE_ITEM_TYPE = 5 _VIDEO_ITEM_TYPE = 5
class WeChatILinkInboundAdapter: class WeChatILinkInboundAdapter:
@ -123,18 +123,18 @@ class WeChatILinkInboundAdapter:
height=image_item.get("height"), height=image_item.get("height"),
) )
) )
elif item_type == _VIDEO_ITEM_TYPE: elif item_type == _VOICE_ITEM_TYPE:
video_item = item.get("video_item", {}) voice_item = item.get("voice_item", {})
attachments.append( attachments.append(
Attachment( Attachment(
type="video", type="audio",
url=_build_media_url( url=_build_media_url(
encrypt_query_param=video_item.get("encrypt_query_param", ""), encrypt_query_param=voice_item.get("encrypt_query_param", ""),
aes_key=video_item.get("aes_key", ""), aes_key=voice_item.get("aes_key", ""),
media_type="video", media_type="audio",
cdn_url=cdn_url, cdn_url=cdn_url,
), ),
duration_ms=video_item.get("duration"), duration_ms=voice_item.get("duration"),
) )
) )
elif item_type == _FILE_ITEM_TYPE: elif item_type == _FILE_ITEM_TYPE:
@ -152,18 +152,18 @@ class WeChatILinkInboundAdapter:
size=file_item.get("file_size"), size=file_item.get("file_size"),
) )
) )
elif item_type == _VOICE_ITEM_TYPE: elif item_type == _VIDEO_ITEM_TYPE:
voice_item = item.get("voice_item", {}) video_item = item.get("video_item", {})
attachments.append( attachments.append(
Attachment( Attachment(
type="audio", type="video",
url=_build_media_url( url=_build_media_url(
encrypt_query_param=voice_item.get("encrypt_query_param", ""), encrypt_query_param=video_item.get("encrypt_query_param", ""),
aes_key=voice_item.get("aes_key", ""), aes_key=video_item.get("aes_key", ""),
media_type="audio", media_type="video",
cdn_url=cdn_url, cdn_url=cdn_url,
), ),
duration_ms=voice_item.get("duration"), duration_ms=video_item.get("duration"),
) )
) )

View File

@ -5,8 +5,9 @@
``scaned_but_redirect`` 切换 Host与二维码过期检测``expired`` 返回 ``scaned_but_redirect`` 切换 Host与二维码过期检测``expired`` 返回
新二维码 新二维码
iLink 无显式登出 API``logout`` 不清理本地存储 iLink 无显式登出 API``logout`` 通过 ``ILinkClient.clear_runtime_cache``
``CredentialService.revokeCredentials`` 负责``logged_out=None`` 清理插件级运行时缓存context_token / cursor凭证缓存由
``CredentialService.revokeCredentials`` 统一负责``logged_out=None``
``forceLogout`` iLink 不支持 ``NotImplementedError`` ``forceLogout`` iLink 不支持 ``NotImplementedError``
设计要点 设计要点
@ -21,8 +22,9 @@ iLink 无显式登出 API``logout`` 不清理本地存储(由
``ilink_user_id`` / ``baseurl`` 填入 ``QrLoginWaitResult.credentials`` ``ilink_user_id`` / ``baseurl`` 填入 ``QrLoginWaitResult.credentials``
``CredentialService`` 加密落库适配器不调用任何 Port 写方法 ``CredentialService`` 加密落库适配器不调用任何 Port 写方法
INV-8不感知 ``onboarding_status`` INV-8不感知 ``onboarding_status``
- ``logout`` 幂等iLink 无登出 API适配器不清理本地存储直接返回 - ``logout`` 幂等iLink 无登出 API适配器通过 ``ILinkClient.clear_runtime_cache``
``LogoutResult(cleared=True, logged_out=None)`` 清理运行时缓存context_token / cursor凭证缓存由 CredentialService 清理
返回 ``LogoutResult(cleared=True, logged_out=None)``
- 适配器不得调用 ``ChannelAccountRepositoryPort`` 写方法凭据落库由 - 适配器不得调用 ``ChannelAccountRepositoryPort`` 写方法凭据落库由
``CredentialService`` 统一控制 ``CredentialService`` 统一控制
@ -219,15 +221,18 @@ class WeChatILinkLoginAdapter:
async def logout(self, account_id: str) -> LogoutResult: async def logout(self, account_id: str) -> LogoutResult:
"""登出账户。幂等:重复调用安全,不抛异常。 """登出账户。幂等:重复调用安全,不抛异常。
iLink 无显式登出 API``logged_out=None``适配器不清理本地存储 iLink 无显式登出 API``logged_out=None``适配器通过
``CredentialService.revokeCredentials`` 负责仅返回结果 ``ILinkClient.clear_runtime_cache`` 清理插件级运行时缓存
适配器不调用任何 Port 写方法INV-8 context_token / cursor避免登出后孤儿键残留凭证缓存
``credentials:{account_id}`` ``CredentialService.revokeCredentials``
统一清理CachePort 异常不抛降级为日志告警
@consistency: 最终一致eventual凭证撤销由 CredentialService 控制 @consistency: 最终一致eventual凭证撤销由 CredentialService 控制
@idempotent: True 重复调用返回相同结果不抛异常 @idempotent: True 重复调用返回相同结果不抛异常
""" """
await self._client.clear_runtime_cache(account_id)
await self._logger.info( await self._logger.info(
"iLink account logout: no channel-side API, local clear delegated to CredentialService", "iLink account logout: runtime cache cleared, credentials revocation delegated to CredentialService",
account_id=account_id, account_id=account_id,
) )
return LogoutResult(cleared=True, logged_out=None) return LogoutResult(cleared=True, logged_out=None)

View File

@ -18,6 +18,7 @@ iLink 不支持话题回复与批量发送,``supportsThreadReply`` /
from __future__ import annotations from __future__ import annotations
import hashlib
from uuid import uuid4 from uuid import uuid4
from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat
@ -29,8 +30,8 @@ from yuxi.channels.contract.dtos.outbound import (
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt from yuxi.channels.contract.dtos.outbox import MultiPartReceipt
from yuxi.channels.contract.errors import ( from yuxi.channels.contract.errors import (
DependencyError, DependencyError,
NotImplementedError,
NotFoundError, NotFoundError,
NotImplementedError,
ValidationError, ValidationError,
) )
from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.config_port import ConfigPort
@ -43,10 +44,17 @@ from ..ilink_client import ILinkClient
# iLink 消息类型常量 # iLink 消息类型常量
_MESSAGE_TYPE_BOT = 2 _MESSAGE_TYPE_BOT = 2
_MESSAGE_STATE_FINISH = 2 _MESSAGE_STATE_FINISH = 2
# item type 映射(功能点清单 §2.53=VOICE, 5=VIDEO
# 注VOICE(3) 仅入站使用,出站 audio 走 raise 分支(设计 §4.4
_TEXT_ITEM_TYPE = 1 _TEXT_ITEM_TYPE = 1
_IMAGE_ITEM_TYPE = 2 _IMAGE_ITEM_TYPE = 2
_VIDEO_ITEM_TYPE = 3
_FILE_ITEM_TYPE = 4 _FILE_ITEM_TYPE = 4
_VIDEO_ITEM_TYPE = 5
# media_type 映射(设计 §4.41=IMAGE, 2=VIDEO, 3=FILE无 VOICE
_MEDIA_TYPE_IMAGE = 1
_MEDIA_TYPE_VIDEO = 2
_MEDIA_TYPE_FILE = 3
class WeChatILinkOutboundAdapter: class WeChatILinkOutboundAdapter:
@ -141,8 +149,10 @@ class WeChatILinkOutboundAdapter:
分片策略 分片策略
- 长文本>4000 Unicode 字符按字符边界切割每片独立调用 ``sendMessage`` - 长文本>4000 Unicode 字符按字符边界切割每片独立调用 ``sendMessage``
- 媒体附件 ``content`` bytes先上传 CDN 获取 - 媒体附件 ``content`` bytes先上传 CDN 获取
``file_id`` + ``encrypt_query_param`` + ``aes_key``再发送 ``encrypt_query_param``再发送媒体 item
- 文本在前媒体在后 - 文本在前媒体在后
- audio 类型不支持上传设计 §4.4语音不经过 getuploadurl
``ValidationError``v1 ``enable_voice`` 默认关闭TD-6
发送前预校验 ``context_token``避免文本已发送媒体因缺 token 发送前预校验 ``context_token``避免文本已发送媒体因缺 token
失败导致半截消息每片独立调用 ``sendMessage`` 生成唯一 ``client_id`` 失败导致半截消息每片独立调用 ``sendMessage`` 生成唯一 ``client_id``
@ -219,26 +229,47 @@ class WeChatILinkOutboundAdapter:
if seq in skip_seqs: if seq in skip_seqs:
seq += 1 seq += 1
continue continue
# audio 类型:设计 §4.4 约束"语音通过 voice_item 直接引用,不经过
# getuploadurl"。v1 enable_voice 默认关闭TD-6不支持语音上传。
if attachment.type == "audio":
raise ValidationError(
field="attachment",
message="voice_upload_not_supported",
)
aes_key = crypto.generate_random_aes_key() aes_key = crypto.generate_random_aes_key()
ciphertext = crypto.encrypt(attachment.content, aes_key) ciphertext = crypto.encrypt(attachment.content, aes_key)
file_size = crypto.calc_ciphertext_size(len(attachment.content)) file_size = crypto.calc_ciphertext_size(len(attachment.content))
raw_size = len(attachment.content)
raw_md5 = hashlib.md5(attachment.content).hexdigest()
filekey = uuid4().hex
if attachment.type == "image": if attachment.type == "image":
file_type = "image" media_type = _MEDIA_TYPE_IMAGE
elif attachment.type == "video": elif attachment.type == "video":
file_type = "video" media_type = _MEDIA_TYPE_VIDEO
else: else:
file_type = "file" media_type = _MEDIA_TYPE_FILE
upload_resp = await self._client.getuploadurl(account_id, file_type, file_size)
upload_resp = await self._client.getuploadurl(
account_id,
media_type=media_type,
filekey=filekey,
to_user_id=peer_id,
rawsize=raw_size,
rawfilemd5=raw_md5,
filesize=file_size,
aeskey=aes_key,
)
upload_url = upload_resp["upload_url"] upload_url = upload_resp["upload_url"]
encrypt_query_param = upload_resp["encrypt_query_param"] encrypt_query_param = upload_resp["encrypt_query_param"]
file_id = upload_resp["file_id"]
await self._client.cdn_upload(upload_url, ciphertext, encrypt_query_param) await self._client.cdn_upload(upload_url, ciphertext, encrypt_query_param)
if attachment.type == "image": if attachment.type == "image":
item = { item = {
"type": _IMAGE_ITEM_TYPE, "type": _IMAGE_ITEM_TYPE,
"image_item": { "image_item": {
"file_id": file_id,
"aes_key": aes_key, "aes_key": aes_key,
"encrypt_query_param": encrypt_query_param, "encrypt_query_param": encrypt_query_param,
}, },
@ -247,7 +278,6 @@ class WeChatILinkOutboundAdapter:
item = { item = {
"type": _VIDEO_ITEM_TYPE, "type": _VIDEO_ITEM_TYPE,
"video_item": { "video_item": {
"file_id": file_id,
"aes_key": aes_key, "aes_key": aes_key,
"encrypt_query_param": encrypt_query_param, "encrypt_query_param": encrypt_query_param,
}, },
@ -256,7 +286,6 @@ class WeChatILinkOutboundAdapter:
item = { item = {
"type": _FILE_ITEM_TYPE, "type": _FILE_ITEM_TYPE,
"file_item": { "file_item": {
"file_id": file_id,
"aes_key": aes_key, "aes_key": aes_key,
"encrypt_query_param": encrypt_query_param, "encrypt_query_param": encrypt_query_param,
"file_name": attachment.filename or "file", "file_name": attachment.filename or "file",

View File

@ -10,6 +10,7 @@ iLink 长轮询仅接收用户消息message_type=1返回 EventType.MESS
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from yuxi.channels.contract.dtos.common import RawEvent from yuxi.channels.contract.dtos.common import RawEvent
from yuxi.channels.contract.dtos.status import EventType, StatusPayload from yuxi.channels.contract.dtos.status import EventType, StatusPayload
@ -40,17 +41,25 @@ class WeChatILinkStatusAdapter:
async def extractStatusPayload(self, raw_event: RawEvent) -> StatusPayload: async def extractStatusPayload(self, raw_event: RawEvent) -> StatusPayload:
"""提取状态负载。 """提取状态负载。
message_idiLink client_id from_user_id message_idiLink client_id from_user_id写入 metadata
event_type ``classifyEvent`` 结果联动 MESSAGE 事件使用实际分类
@failure client_id 缺失抛 ValidationError @failure client_id 缺失抛 ValidationError
""" """
client_id = raw_event.payload.get("client_id", "") client_id = raw_event.payload.get("client_id", "")
if not client_id: if not client_id:
raise ValidationError(field="client_id", message="client_id_missing") raise ValidationError(field="client_id", message="client_id_missing")
from_user_id = raw_event.payload.get("from_user_id", "")
event_type = await self.classifyEvent(raw_event)
metadata: dict[str, Any] = {}
if from_user_id:
metadata["from_user_id"] = from_user_id
return StatusPayload( return StatusPayload(
event_type=EventType.MESSAGE, event_type=event_type,
ref_channel_msg_id=client_id, ref_channel_msg_id=client_id,
timestamp=raw_event.received_at or datetime.now(UTC), timestamp=raw_event.received_at or datetime.now(UTC),
metadata=metadata or None,
) )

View File

@ -526,16 +526,41 @@ class ILinkClient:
async def getuploadurl( async def getuploadurl(
self, self,
account_id: str, account_id: str,
file_type: str, *,
file_size: int, media_type: int,
filekey: str,
to_user_id: str,
rawsize: int,
rawfilemd5: str,
filesize: int,
aeskey: str,
thumb_rawsize: int | None = None,
thumb_rawfilemd5: str | None = None,
thumb_filesize: int | None = None,
thumb_aeskey: str | None = None,
) -> dict: ) -> dict:
"""获取 CDN 上传地址。 """获取 CDN 上传地址。
POST /ilink/bot/getuploadurl POST /ilink/bot/getuploadurl
Body: {"file_type": file_type, "file_size": file_size} Body设计 §4.4: ``filekey`` / ``media_type``1=IMAGE, 2=VIDEO,
返回含 upload_url / encrypt_query_param / aes_key / file_id 3=FILE/ ``to_user_id`` / ``rawsize`` / ``rawfilemd5`` / ``filesize``
/ ``aeskey``图片/视频还需 ``thumb_*`` 参数
返回含 upload_url / encrypt_query_param / file_id
""" """
body = {"file_type": file_type, "file_size": file_size} body: dict[str, Any] = {
"filekey": filekey,
"media_type": media_type,
"to_user_id": to_user_id,
"rawsize": rawsize,
"rawfilemd5": rawfilemd5,
"filesize": filesize,
"aeskey": aeskey,
}
if thumb_rawsize is not None:
body["thumb_rawsize"] = thumb_rawsize
body["thumb_rawfilemd5"] = thumb_rawfilemd5 or ""
body["thumb_filesize"] = thumb_filesize or 0
body["thumb_aeskey"] = thumb_aeskey or ""
return await self._post("/ilink/bot/getuploadurl", account_id, body) return await self._post("/ilink/bot/getuploadurl", account_id, body)
async def cdn_upload( async def cdn_upload(
@ -626,5 +651,29 @@ class ILinkClient:
return cached.unwrap() return cached.unwrap()
return "" return ""
async def clear_runtime_cache(self, account_id: str) -> None:
"""清理指定账户的运行时缓存context_token / cursor
``LoginAdapter.logout`` 调用清理插件级运行时缓存键避免
登出后孤儿键残留凭证缓存``credentials:{account_id}``
``CredentialService.revokeCredentials`` 统一清理本方法不涉及
CachePort 异常不抛仅日志告警对齐 lifecycle 降级策略
"""
patterns = [
f"wechat_ilink:{account_id}:context_token:*",
f"wechat_ilink:{account_id}:cursor",
]
for pattern in patterns:
try:
await self._cache.invalidate(pattern=pattern)
except Exception as exc:
await self._logger.exception(
"iLink clear_runtime_cache: invalidate failed, non-blocking",
account_id=account_id,
pattern=pattern,
exc_info=exc,
)
__all__ = ["ILinkClient"] __all__ = ["ILinkClient"]

View File

@ -9,7 +9,7 @@
"lifecycle": ["init", "start", "stop", "pause", "resume", "unload", "reconfigure", "fail"], "lifecycle": ["init", "start", "stop", "pause", "resume", "unload", "reconfigure", "fail"],
"compatibility": { "compatibility": {
"min_host_version": "1.0.0", "min_host_version": "1.0.0",
"ilink_api_version": "2.0.0" "ilink_channel_version": "2.0.0"
}, },
"failure_policy": "degrade", "failure_policy": "degrade",
"depends": [], "depends": [],
@ -90,6 +90,8 @@
{"key": "long_poll_timeout_ms", "type": "integer", "required": false, "default": 35000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 10000, "max": 60000}}, {"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_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": "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": "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": {}}
] ]
} }