feat(wecom): 完善客服消息适配逻辑
1. 新增客服open_kfid缓存常量与key生成工具 2. 重构入站适配器缓存客服映射关系 3. 重写出站适配器使用缓存判断客服场景 4. 修复多选卡片构建逻辑与日志方法调用 5. 调整wechat_woc常量导入顺序 6. 优化命令适配器配置异常处理逻辑
This commit is contained in:
parent
feee2e1166
commit
eda29502ed
@ -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
|
||||
|
||||
|
||||
@ -66,6 +66,10 @@ RESPONSE_CODE_CACHE_TTL_SEC = 72 * 3600
|
||||
# 消息反向映射缓存 TTL(秒),1 天
|
||||
MSG_CACHE_TTL_SEC = 86400
|
||||
|
||||
# 客服 open_kfid 映射缓存 TTL(秒),1 天(对齐 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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}"
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user