ForcePilot/backend/package/yuxi/channel/extensions/wechat-mp/passive_reply.py
Kris 87a8931db3 feat(channel): 添加微信客服、微信公众号和微信支付通知渠道扩展
新增微信客服、微信公众号、微信支付通知三个渠道扩展。

微信客服渠道扩展功能模块:
- account: 账户管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 加解密处理
- dedupe: 消息去重
- customer: 客户管理
- servicer: 客服管理
- session: 会话管理
- status: 会话状态管理
- media: 媒体资源处理
- statistics: 统计功能
- sync: 数据同步
- upgrade: 升级处理

微信公众号渠道扩展功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 加解密处理
- dedupe: 消息去重
- passive_reply: 被动回复
- message: 消息处理
- broadcast: 群发消息
- template: 模板消息
- menu: 菜单管理
- qrcode: 二维码管理
- user: 用户管理
- media: 媒体资源处理
- status: 会话状态管理

微信支付通知渠道扩展功能模块:
- config: 渠道配置管理
- webhook: Webhook 事件处理
- crypto: 加解密与签名校验
- cert_manager: 证书管理
- event_router: 事件路由
- dedupe: 消息去重
- pay_repo: 支付数据仓库
- query_client: 查询客户端
- arq_tasks: 异步任务
- callback_compensator: 回调补偿
2026-05-21 12:00:30 +08:00

227 lines
7.1 KiB
Python

import asyncio
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, UTC
from yuxi.channel.extensions.wechat_mp.message import extract_content
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
MAX_RETRY = 3
WAIT_WINDOW_SEC = 4.0
POLL_INTERVAL_SEC = 0.1
_passive_state: "PassiveReplyState | None" = None
def _get_passive_state() -> "PassiveReplyState":
global _passive_state
if _passive_state is None:
_passive_state = PassiveReplyState()
return _passive_state
def _reset_passive_state() -> None:
global _passive_state
_passive_state = None
@dataclass
class PassiveReplyState:
cache: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
running: set[str] = field(default_factory=set)
request_cnt: dict[str, int] = field(default_factory=dict)
def is_new_request(self, from_user: str, message_id: str, content: str) -> bool:
has_cache = from_user in self.cache and bool(self.cache[from_user])
is_running = from_user in self.running
is_command = content.startswith("#")
is_new_msg = message_id not in self.request_cnt
return (not has_cache and not is_running) or (is_command and is_new_msg)
def mark_running(self, from_user: str):
self.running.add(from_user)
def mark_done(self, from_user: str):
self.running.discard(from_user)
def incr_request(self, message_id: str):
self.request_cnt[message_id] = self.request_cnt.get(message_id, 0) + 1
def is_done(self, from_user: str) -> bool:
return from_user not in self.running
def reap(self, from_user: str) -> tuple[str, str] | None:
entries = self.cache.get(from_user)
if not entries:
return None
result = entries.pop(0)
if not entries:
del self.cache[from_user]
return result
def add_cache(self, from_user: str, reply_type: str, content: str):
if from_user not in self.cache:
self.cache[from_user] = []
self.cache[from_user].append((reply_type, content))
def cleanup_user(self, from_user: str):
self.cache.pop(from_user, None)
self.running.discard(from_user)
def reset(self):
self.cache.clear()
self.running.clear()
self.request_cnt.clear()
async def handle_passive_callback(
raw_msg,
decrypted_xml: str,
processor,
outbound,
subscribe_msg: str = "",
) -> str:
state = _get_passive_state()
msg_content = raw_msg.content or ""
if not state.is_new_request(raw_msg.from_user, raw_msg.msg_id, msg_content):
state.incr_request(raw_msg.msg_id)
state.mark_running(raw_msg.from_user)
asyncio.create_task(
_process_passive_msg(raw_msg, processor, outbound),
name=f"wechat-mp-passive-{raw_msg.from_user}",
)
deadline = time.time() + WAIT_WINDOW_SEC
while time.time() < deadline:
if state.is_done(raw_msg.from_user):
result = state.reap(raw_msg.from_user)
if result:
reply_type, content = result
return _build_passive_xml(result, raw_msg)
await asyncio.sleep(POLL_INTERVAL_SEC)
count = state.request_cnt.get(raw_msg.msg_id, 1)
state.cleanup_user(raw_msg.from_user)
if count < MAX_RETRY:
return "success"
return _build_text_xml(raw_msg.to_user, raw_msg.from_user, "【正在思考中,请稍后再试】")
async def _process_passive_msg(raw_msg, processor, outbound) -> None:
state = _get_passive_state()
from_user = raw_msg.from_user
try:
content = extract_content(raw_msg)
if not content:
state.mark_done(from_user)
return
msg_type_map = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"voice": MessageType.VOICE,
"video": MessageType.VIDEO,
"shortvideo": MessageType.VIDEO,
"location": MessageType.TEXT,
}
msg_type = msg_type_map.get(raw_msg.msg_type, MessageType.TEXT)
unified = UnifiedMessage(
msg_id=raw_msg.msg_id,
channel_type="wechat-mp",
account_id="default",
content=content,
message_type=msg_type,
sender=PeerInfo(
id=raw_msg.from_user,
kind=PeerKind.DIRECT,
display_name=raw_msg.from_user,
),
timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=UTC) if raw_msg.create_time else None,
raw_payload=raw_msg.raw_xml,
body_for_agent=content,
metadata={
"FromUserName": raw_msg.from_user,
"ToUserName": raw_msg.to_user,
"MsgType": raw_msg.msg_type,
"Event": raw_msg.event,
"EventKey": raw_msg.event_key,
"MediaId": raw_msg.media_id,
"PicUrl": raw_msg.pic_url,
},
)
if processor is not None:
try:
await asyncio.wait_for(processor.process(unified), timeout=120.0)
except TimeoutError:
logger.error("Passive agent response timeout for wechat-mp user %s", from_user)
except Exception:
logger.exception("Failed to process passive WeChat MP message for user %s", from_user)
state.mark_done(from_user)
except Exception:
logger.exception("Passive message processing error for user %s", from_user)
state.mark_done(from_user)
def _build_passive_xml(result: tuple[str, str], raw_msg) -> str:
reply_type, content = result
to_user = raw_msg.from_user
from_user = raw_msg.to_user
if reply_type == "text":
return _build_text_xml(from_user, to_user, content)
if reply_type == "image":
return (
'<xml>'
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
f'<CreateTime>{int(time.time())}</CreateTime>'
'<MsgType><![CDATA[image]]></MsgType>'
'<Image>'
f'<MediaId><![CDATA[{content}]]></MediaId>'
'</Image>'
'</xml>'
)
if reply_type == "voice":
return (
'<xml>'
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
f'<CreateTime>{int(time.time())}</CreateTime>'
'<MsgType><![CDATA[voice]]></MsgType>'
'<Voice>'
f'<MediaId><![CDATA[{content}]]></MediaId>'
'</Voice>'
'</xml>'
)
return _build_text_xml(from_user, to_user, content)
def _build_text_xml(from_user: str, to_user: str, content: str) -> str:
return (
'<xml>'
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
f'<CreateTime>{int(time.time())}</CreateTime>'
'<MsgType><![CDATA[text]]></MsgType>'
f'<Content><![CDATA[{content}]]></Content>'
'</xml>'
)