feat(wecom): 完善客服消息适配逻辑

1. 新增客服open_kfid缓存常量与key生成工具
2. 重构入站适配器缓存客服映射关系
3. 重写出站适配器使用缓存判断客服场景
4. 修复多选卡片构建逻辑与日志方法调用
5. 调整wechat_woc常量导入顺序
6. 优化命令适配器配置异常处理逻辑
This commit is contained in:
Kris 2026-07-09 04:20:26 +08:00
parent feee2e1166
commit eda29502ed
9 changed files with 105 additions and 28 deletions

View File

@ -63,6 +63,7 @@ from yuxi.channels.contract.plugin.manifest import (
PluginManifest,
)
from ._constants import DEFAULT_MAX_CONNECTIONS
from .adapters.capability_prover import WocCapabilityProver
from .adapters.directory_adapter import WeChatWocDirectoryAdapter
from .adapters.doctor_adapter import WeChatWocDoctorAdapter
@ -77,7 +78,6 @@ from .adapters.status_adapter import WeChatWocStatusAdapter
from .adapters.stream_connector_adapter import WeChatWocStreamConnectorAdapter
from .adapters.whitelist_adapter import WeChatWocWhitelistAdapter
from .adapters.wizard_adapter import WeChatWocWizardAdapter
from ._constants import DEFAULT_MAX_CONNECTIONS
from .lifecycle import WeChatWocLifecycleHandler
from .woc_bridge_client import WocBridgeClient

View File

@ -66,6 +66,10 @@ RESPONSE_CODE_CACHE_TTL_SEC = 72 * 3600
# 消息反向映射缓存 TTL1 天
MSG_CACHE_TTL_SEC = 86400
# 客服 open_kfid 映射缓存 TTL1 天(对齐 MSG_CACHE_TTL_SEC
# 覆盖客服会话生命周期,确保出站回复能反查到 open_kfid
KF_OPEN_KFID_CACHE_TTL_SEC = 86400
# ---------------------------------------------------------------------------
# 企业微信 API 常量
# ---------------------------------------------------------------------------
@ -94,6 +98,7 @@ __all__ = [
"USER_CACHE_TTL_SEC",
"RESPONSE_CODE_CACHE_TTL_SEC",
"MSG_CACHE_TTL_SEC",
"KF_OPEN_KFID_CACHE_TTL_SEC",
"WEBHOOK_TIMEOUT_SEC",
"DUPLICATE_CHECK_INTERVAL_SEC",
"RESPONSE_CODE_VALID_HOURS",

View File

@ -27,6 +27,7 @@ from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.errors import (
DependencyError,
Error,
NotFoundError,
ValidationError,
)
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
@ -69,10 +70,11 @@ class WecomCommandAdapter:
"""获取渠道命令列表。
``ConfigPort`` 读取 CHANNEL 作用域 ``commands`` 配置返回
``ChannelCommand`` 元组配置缺失或为空时返回内置默认命令
``/help`` / ``/status``
``ChannelCommand`` 元组配置缺失``NotFoundError``或为空时
降级返回内置默认命令``/help`` / ``/status``确保命令功能
在配置异常时仍可用
@failure: 配置加载失败``DependencyError``
@failure: 配置系统故障 NotFoundError``DependencyError``
"""
try:
cv = await self._config.get(
@ -80,6 +82,11 @@ class WecomCommandAdapter:
scope=ConfigScope.CHANNEL,
target=CHANNEL_TARGET,
)
except NotFoundError:
# 配置未设置,降级返回内置默认命令
return _DEFAULT_COMMANDS
except Error:
raise
except Exception as exc:
raise DependencyError(dep="wecom_config", cause=exc) from exc

View File

@ -30,11 +30,12 @@ from yuxi.channels.contract.dtos.common import (
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.inbound import SignatureVerifyResult
from yuxi.channels.contract.errors import DependencyError, Error
from yuxi.channels.contract.ports.driven.cache_port import CachePort
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from .. import signature
from .._constants import WECOM_API_DEP
from .._constants import KF_OPEN_KFID_CACHE_TTL_SEC, WECOM_API_DEP
from ..wecom_client import WecomClient
# 企业微信 MsgType → MessageFormat 映射
@ -46,6 +47,11 @@ _RESOURCE_SCHEME = "wecom"
_RESOURCE_HOST = "resource"
def _kf_open_kfid_key(account_id: str, peer_id: str) -> str:
"""构造 ``external_userid → open_kfid`` 反向映射的缓存 key。"""
return f"wecom:kf:{account_id}:{peer_id}"
class WecomInboundAdapter:
"""企业微信入站适配器。
@ -64,10 +70,12 @@ class WecomInboundAdapter:
client: WecomClient,
config_port: ConfigPort,
logger_port: LoggerPort,
cache_port: CachePort,
) -> None:
self._client = client
self._config = config_port
self._logger = logger_port
self._cache = cache_port
async def normalizeInbound(self, raw_event: RawEvent) -> MessageContent:
"""将企业微信原始事件规范化为统一消息内容。
@ -99,6 +107,16 @@ class WecomInboundAdapter:
account_id = raw_event.account_id or ""
text, fmt, attachments = _parse_message_content(event, account_id)
# 客服场景:写入 external_userid → open_kfid 映射,供出站适配器反查
# open_kfid 以切换客服 API 域并填充消息体B-4 修复)
open_kfid = event.get("OpenKfId", "") or event.get("open_kfid", "")
if open_kfid and from_user:
await self._cache.set(
_kf_open_kfid_key(account_id, from_user),
open_kfid,
ttl_seconds=KF_OPEN_KFID_CACHE_TTL_SEC,
)
# event_id 优先使用 MsgId缺失时回退到 CreateTime + FromUserName
event_id = msg_id or f"{create_time}_{from_user}"

View File

@ -57,6 +57,11 @@ def _response_code_key(channel_msg_id: str) -> str:
return f"wecom:response_code:{channel_msg_id}"
def _kf_open_kfid_key(account_id: str, peer_id: str) -> str:
"""构造 ``external_userid → open_kfid`` 反向映射的缓存 key。"""
return f"wecom:kf:{account_id}:{peer_id}"
# 附件类型 → 企业微信媒体类型映射
_ATTACH_MEDIA_TYPE = {
"image": "image",
@ -97,8 +102,9 @@ class WecomOutboundAdapter:
) -> str:
"""发送消息,返回企业微信 ``msgid``。
依据设计 §2.1.2 / 决策 8 ``peer_id`` 为客服场景``open_kfid``
前缀时切换至 ``kf_secret`` 并调用 ``/cgi-bin/kf/send_msg``其余
依据设计 §2.1.2 / 决策 8 ``peer_id`` 存在客服场景映射
``external_userid open_kfid``时切换至 ``kf_secret`` 并调用
``/cgi-bin/kf/send_msg``消息体补充 ``open_kfid`` 字段其余
场景调用应用消息 ``/cgi-bin/message/send``成功后缓存 ``msgid``
``account_id`` 反向映射若响应含 ``response_code``模板卡片
消息同时缓存供 ``updateMessage`` 使用
@ -109,8 +115,10 @@ class WecomOutboundAdapter:
msg_body = _build_msg_body(payload)
msg_body["touser"] = peer_id
open_kfid = await self._resolve_open_kfid(account_id, peer_id)
try:
if _is_kf_peer(peer_id):
if open_kfid:
msg_body["open_kfid"] = open_kfid
resp = await self._client.send_kf_message(account_id, msg_body)
else:
resp = await self._client.send_message(account_id, msg_body)
@ -185,7 +193,7 @@ class WecomOutboundAdapter:
part_type, msg_body = first_part
msg_body["touser"] = peer_id
try:
msg_id = await self._send_msg(account_id, msg_body, sent_ids)
msg_id = await self._send_msg(account_id, msg_body, sent_ids, peer_id=peer_id)
except Error:
raise
except Exception as exc:
@ -352,6 +360,18 @@ class WecomOutboundAdapter:
raise NotFoundError(resource="response_code", id=channel_msg_id)
return opt.unwrap()
async def _resolve_open_kfid(self, account_id: str, peer_id: str) -> str:
"""从缓存反查 ``peer_id`` 对应的 ``open_kfid``。
客服场景下入站适配器写入 ``external_userid open_kfid`` 映射
出站时据此判断是否走客服 API 并填充消息体 ``open_kfid`` 字段
无映射时返回空字符串非客服场景
"""
opt = await self._cache.get(_kf_open_kfid_key(account_id, peer_id))
if opt.is_nothing():
return ""
return opt.unwrap()
async def _send_msg(
self,
account_id: str,
@ -362,10 +382,15 @@ class WecomOutboundAdapter:
) -> str:
"""发送单条消息并记录到 ``sent_ids``(供回滚使用)。
``peer_id`` 为客服场景时切换至 ``kf_secret`` API
``peer_id`` 存在客服场景映射时切换至 ``kf_secret`` API
并在消息体中补充 ``open_kfid`` 字段
"""
open_kfid = ""
if peer_id:
open_kfid = await self._resolve_open_kfid(account_id, peer_id)
try:
if peer_id and _is_kf_peer(peer_id):
if open_kfid:
msg_body["open_kfid"] = open_kfid
resp = await self._client.send_kf_message(account_id, msg_body)
else:
resp = await self._client.send_message(account_id, msg_body)
@ -430,7 +455,7 @@ class WecomOutboundAdapter:
msg_body = _build_attachment_msg_body(attachment.type, media_id)
msg_body["touser"] = peer_id
return await self._send_msg(account_id, msg_body, sent_ids)
return await self._send_msg(account_id, msg_body, sent_ids, peer_id=peer_id)
async def _rollback(self, account_id: str, sent_ids: list[str]) -> None:
"""撤回已发送消息best-effort失败记录日志不抛异常"""
@ -523,15 +548,6 @@ def _build_card_data(payload: FormattedMessage) -> dict[str, Any]:
return dict(card["template_card"])
def _is_kf_peer(peer_id: str) -> bool:
"""判断 peer_id 是否为客服场景open_kfid 前缀)。
企业微信客服账号 ID ``kfid_`` / ``kf_`` 前缀开头见企业微信开发
文档 "客服账号 ID" 说明据此切换 ``kf_secret`` API
"""
return peer_id.startswith(("kfid_", "kf_"))
def _build_attachment_msg_body(
attach_type: str,
media_id: str,

View File

@ -60,6 +60,11 @@ def _card_key(card_id: str) -> str:
return f"wecom:card:{card_id}"
def _kf_open_kfid_key(account_id: str, peer_id: str) -> str:
"""构造 ``external_userid → open_kfid`` 反向映射的缓存 key。"""
return f"wecom:kf:{account_id}:{peer_id}"
class WecomStreamingAdapter:
"""企业微信流式适配器。
@ -102,7 +107,12 @@ class WecomStreamingAdapter:
msg_body: dict[str, Any] = {"touser": peer_id}
msg_body.update(card)
try:
resp = await self._client.send_message(account_id, msg_body)
open_kfid = await self._resolve_open_kfid(account_id, peer_id)
if open_kfid:
msg_body["open_kfid"] = open_kfid
resp = await self._client.send_kf_message(account_id, msg_body)
else:
resp = await self._client.send_message(account_id, msg_body)
except Error:
# 契约异常DependencyError/TimeoutError 等)向上透传,由调用方
# 据异常类型决定重试/降级/失败语义,对齐 Protocol @failure 契约。
@ -269,5 +279,16 @@ class WecomStreamingAdapter:
raise NotFoundError(resource="streaming_card", id=card_id)
return opt.unwrap()
async def _resolve_open_kfid(self, account_id: str, peer_id: str) -> str:
"""从缓存反查 ``peer_id`` 对应的 ``open_kfid``。
客服场景下入站适配器写入 ``external_userid open_kfid`` 映射
流式出站时据此判断是否走客服 API无映射时返回空字符串
"""
opt = await self._cache.get(_kf_open_kfid_key(account_id, peer_id))
if opt.is_nothing():
return ""
return opt.unwrap()
__all__ = ["WecomStreamingAdapter"]

View File

@ -286,7 +286,7 @@ class WecomWhitelistAdapter:
entries: list[WhitelistEntry] = []
for raw in raw_list:
if not isinstance(raw, dict):
await self._logger.warning(
await self._logger.warn(
"wecom whitelist entry skipped: not a dict",
account_id=account_id,
policy_type=policy_type.value,
@ -297,7 +297,7 @@ class WecomWhitelistAdapter:
entries.append(whitelistEntryFromDict(raw))
except ValidationError as exc:
# 跳过历史脏数据,避免单条非法条目阻断整批读取
await self._logger.warning(
await self._logger.warn(
"wecom whitelist entry skipped: invalid entry",
account_id=account_id,
policy_type=policy_type.value,

View File

@ -171,9 +171,19 @@ def _build_vote_options(selects: tuple[Any, ...]) -> list[dict[str, Any]]:
def _build_multiple_options(checkboxes: tuple[Any, ...]) -> list[dict[str, Any]]:
"""构建多项选择型 checkboxes。"""
"""构建多项选择型 checkboxes嵌套结构
企业微信 multiple_interaction 卡片要求每个元素含 ``question_name``
``option_list``嵌套数组而非扁平列表每个 ``RichMessageCheckbox``
对应一个问题组``cb.label`` 为问题名``cb.options`` 为选项列表
"""
result: list[dict[str, Any]] = []
for cb in checkboxes:
for opt in cb.options:
result.append({"text": opt.label, "key": opt.value})
option_list = [{"text": opt.label, "key": opt.value} for opt in cb.options]
result.append(
{
"question_name": cb.label,
"option_list": option_list,
}
)
return result

View File

@ -133,7 +133,7 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest
# 15 个列表追加 + 1 个 identity_resolver 单实例赋值
host.registerAdapter(
"inbound",
WecomInboundAdapter(client, config_port, logger_port),
WecomInboundAdapter(client, config_port, logger_port, cache_port),
)
host.registerAdapter(
"outbound",