ForcePilot/backend/package/yuxi/channel/extensions/wechat-kf/gateway.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

441 lines
18 KiB
Python

import asyncio
import logging
import time
from datetime import UTC, datetime
import httpx
from yuxi.channel.extensions.wechat_kf.dedupe import MessageDeduplicator
from yuxi.channel.extensions.wechat_kf.config import WeChatKFConfig
from yuxi.channel.extensions.wechat_kf.outbound import WeChatKFOutbound
from yuxi.channel.extensions.wechat_kf.session import KFSessionManager
from yuxi.channel.extensions.wechat_kf.sync import sync_messages
from yuxi.channel.extensions.wechat_kf.types import InboundKFMessage, KFAccount, SyncCursor
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
from yuxi.channel.runtime.manager import gateway
logger = logging.getLogger(__name__)
WEIXIN_API_BASE = "https://qyapi.weixin.qq.com"
SERVICE_STATE_GET_URL = "/cgi-bin/kf/service_state/get"
SERVICE_STATE_TRANS_URL = "/cgi-bin/kf/service_state/trans"
_gateway_instance: "WeChatKFGateway | None" = None
def _set_gateway(gw: "WeChatKFGateway") -> None:
global _gateway_instance
_gateway_instance = gw
def _get_gateway() -> "WeChatKFGateway | None":
return _gateway_instance
class WeChatKFGateway:
def __init__(self):
self._account: KFAccount | None = None
self._http: httpx.AsyncClient | None = None
self._access_token: str | None = None
self._token_expires_at: float = 0
self._token_lock = asyncio.Lock()
self._cancel_event = asyncio.Event()
self._sync_tasks: list[asyncio.Task] = []
self._cursors: dict[str, SyncCursor] = {}
self._session_manager: KFSessionManager | None = None
self._deduplicator = MessageDeduplicator()
self._running = False
async def start(self, ctx) -> object:
account = self._resolve_account(ctx)
if not account.is_configured():
logger.warning("WeChat KF account not configured, skipping start")
return {"running": False, "reason": "not-configured"}
self._account = account
self._cancel_event.clear()
self._http = httpx.AsyncClient(
base_url=WEIXIN_API_BASE,
timeout=httpx.Timeout(30.0),
)
self._session_manager = KFSessionManager(account)
try:
await self._refresh_token()
except Exception:
logger.exception("WeChat KF failed to get access_token")
await self.stop(ctx)
raise
kf_ids = account.kf_account_ids if account.kf_account_ids else ["default"]
for kf_id in kf_ids:
task = asyncio.create_task(self._sync_loop(kf_id))
self._sync_tasks.append(task)
self._running = True
logger.info(
"WeChat KF gateway started for account %s, kf_ids=%s",
account.account_id,
kf_ids,
)
return {"status": "started", "kf_accounts": kf_ids}
async def stop(self, ctx) -> None:
self._running = False
self._cancel_event.set()
for task in self._sync_tasks:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._sync_tasks.clear()
if self._http:
await self._http.aclose()
self._http = None
logger.info("WeChat KF gateway stopped")
@property
def access_token(self) -> str | None:
if self._access_token and time.time() < self._token_expires_at:
return self._access_token
return None
async def get_access_token(self) -> str:
if self._access_token and time.time() < self._token_expires_at - 300:
return self._access_token
async with self._token_lock:
if self._access_token and time.time() < self._token_expires_at - 300:
return self._access_token
await self._refresh_token()
if not self._access_token:
raise RuntimeError("无法获取微信客服 access_token")
return self._access_token
async def _refresh_token(self):
if not self._http or not self._account:
return
resp = await self._http.get(
"/cgi-bin/gettoken",
params={
"corpid": self._account.corp_id,
"corpsecret": self._account.corp_secret,
},
)
data = resp.json()
if data.get("errcode") != 0:
raise RuntimeError(f"获取 access_token 失败: {data}")
self._access_token = data["access_token"]
self._token_expires_at = time.time() + data.get("expires_in", 7200)
logger.info("WeChat KF access_token refreshed, expires_in=%s", data.get("expires_in"))
async def _sync_loop(self, kf_id: str):
while not self._cancel_event.is_set():
try:
cursor = self._cursors.get(kf_id)
cursor_str = cursor.next_cursor if cursor else ""
result = await sync_messages(
http=self._http,
access_token=await self.get_access_token(),
open_kfid=kf_id,
cursor=cursor_str,
limit=self._account.sync_limit if self._account else 1000,
)
if result and result.get("errcode") == 0:
self._cursors[kf_id] = SyncCursor(
open_kfid=kf_id,
next_cursor=result.get("next_cursor", ""),
updated_at=time.time(),
has_more=bool(result.get("has_more", 0)),
)
msg_list = result.get("msg_list", [])
for msg in msg_list:
await self._handle_message(msg)
except asyncio.CancelledError:
break
except Exception:
logger.exception("WeChat KF sync_loop error for kf_id=%s", kf_id)
try:
interval = self._account.sync_interval if self._account else 1.0
await asyncio.wait_for(self._cancel_event.wait(), timeout=interval)
break
except TimeoutError:
pass
async def _handle_message(self, raw_msg: dict):
msg_id = raw_msg.get("msgid", "")
if not msg_id or self._deduplicator.is_duplicate(msg_id):
return
inbound = self._extract_inbound(raw_msg)
event_type = inbound.event_type
open_kfid = inbound.open_kfid
external_user_id = inbound.external_user_id
if self._session_manager:
if event_type == "enter_session":
welcome_code = inbound.event_data.get("welcome_code", "")
session = self._session_manager.create_session(
open_kfid, external_user_id, raw_msg.get("session_id", "")
)
session.welcome_code = welcome_code
if welcome_code and not session.welcome_sent:
await self._trigger_welcome(open_kfid, external_user_id, welcome_code)
session.welcome_sent = True
elif event_type == "session_status_change":
change_type = inbound.event_data.get("change_type", "")
msg_code = inbound.event_data.get("msg_code", "")
if change_type == "end" and msg_code:
await self._trigger_session_end(open_kfid, external_user_id, msg_code)
self._session_manager.close_session(open_kfid, external_user_id)
elif event_type == "session_quit":
self._session_manager.close_session(open_kfid, external_user_id)
elif event_type == "msg_send_fail":
fail_msgid = inbound.event_data.get("fail_msgid", "")
fail_type = inbound.event_data.get("fail_type", "")
logger.error(
"WeChat KF msg send failed: msgid=%s, fail_type=%s, user=%s",
fail_msgid,
fail_type,
external_user_id,
)
inbound.fail_msgid = fail_msgid
inbound.fail_type = fail_type
await self._dispatch_system_event(inbound)
elif event_type == "user_recall_msg":
recall_msgid = inbound.event_data.get("recall_msgid", "")
logger.info(
"User recalled msg: recall_msgid=%s, user=%s",
recall_msgid,
external_user_id,
)
inbound.recall_msgid = recall_msgid
elif event_type == "servicer_status_change":
servicer_userid = inbound.event_data.get("servicer_userid", "")
status = inbound.event_data.get("status", "")
logger.info(
"Servicer status changed: userid=%s, status=%s",
servicer_userid,
status,
)
elif event_type == "servicer_recall_msg":
recall_msgid = inbound.event_data.get("recall_msgid", "")
logger.info("Servicer recalled msg: recall_msgid=%s", recall_msgid)
else:
self._session_manager.mark_user_message(open_kfid, external_user_id)
if event_type in ("msg_send_fail", "servicer_status_change", "servicer_recall_msg"):
return
try:
await self._dispatch_to_agent(inbound)
except Exception:
logger.exception("WeChat KF dispatch failed for msg_id=%s", msg_id)
def _extract_inbound(self, raw_msg: dict):
msg_type = raw_msg.get("msgtype", "")
extractors = {
"image": lambda m: (
m.get("image", {}).get("media_id", ""),
m.get("image", {}).get("pic_url", ""),
),
"voice": lambda m: (m.get("voice", {}).get("media_id", ""), ""),
"video": lambda m: (m.get("video", {}).get("media_id", ""), ""),
"file": lambda m: (m.get("file", {}).get("media_id", ""), ""),
"link": lambda m: ("", m.get("link", {}).get("pic_url", "")),
}
extractor = extractors.get(msg_type)
media_id, media_url = extractor(raw_msg) if extractor else ("", "")
return InboundKFMessage(
msg_id=raw_msg.get("msgid", ""),
msg_type=msg_type,
open_kfid=raw_msg.get("open_kfid", ""),
external_user_id=raw_msg.get("external_user_id", ""),
send_time=raw_msg.get("send_time", 0),
origin=raw_msg.get("origin", 0),
content=raw_msg.get("text", {}).get("content", ""),
media_id=media_id,
media_url=media_url,
event_type=raw_msg.get("event", {}).get("event_type", "") if msg_type == "event" else "",
event_data=raw_msg.get("event", {}),
raw_xml=raw_msg.get("_raw_xml", ""),
)
async def _trigger_welcome(self, open_kfid: str, external_user_id: str, welcome_code: str):
outbound = WeChatKFOutbound(self)
try:
result = await outbound.send_welcome(
open_kfid, welcome_code, "您好,我是您的智能客服助手,请问有什么可以帮您?"
)
if result.success:
logger.info("Welcome sent to %s", external_user_id)
else:
logger.warning("Failed to send welcome: %s", result.error)
except Exception:
logger.exception("Error sending welcome to %s", external_user_id)
async def _trigger_session_end(self, open_kfid: str, external_user_id: str, msg_code: str):
outbound = WeChatKFOutbound(self)
try:
result = await outbound.send_session_end_msg(
open_kfid, msg_code, "会话已结束,如有其他问题欢迎再次咨询,祝您生活愉快!"
)
if result.success:
logger.info("Session end msg sent to %s", external_user_id)
else:
logger.warning("Failed to send session end msg: %s", result.error)
except Exception:
logger.exception("Error sending session end msg to %s", external_user_id)
async def _dispatch_to_agent(self, inbound):
processor = gateway._processor
if processor is None:
logger.warning("Message processor not available for WeChat KF dispatch")
return
msg_type = MessageType.TEXT
content = inbound.content
if inbound.msg_type == "image":
msg_type = MessageType.IMAGE
content = content or f"[图片: {inbound.media_url or inbound.media_id}]"
elif inbound.msg_type == "voice":
msg_type = MessageType.VOICE
elif inbound.msg_type == "video":
content = content or f"[视频: {inbound.media_id}]"
elif inbound.msg_type == "file":
content = content or f"[文件: {inbound.media_id}]"
elif inbound.msg_type == "location":
loc = inbound.event_data if inbound.event_data else {}
content = (
f"[位置] 纬度:{loc.get('latitude')} 经度:{loc.get('longitude')}"
f" 名称:{loc.get('name', '')} 地址:{loc.get('address', '')}"
)
elif inbound.msg_type == "link":
link_data = inbound.event_data if inbound.event_data else {}
content = f"[链接] {link_data.get('title', '')}: {link_data.get('url', '')}"
elif inbound.msg_type == "business_card":
card_data = inbound.event_data if inbound.event_data else {}
content = f"[名片] userid={card_data.get('userid', '')}"
elif inbound.msg_type == "miniprogram":
mp_data = inbound.event_data if inbound.event_data else {}
content = (
f"[小程序] {mp_data.get('title', '')}"
f" appid={mp_data.get('appid', '')}"
f" pagepath={mp_data.get('pagepath', '')}"
)
elif inbound.msg_type == "msgmenu":
menu_data = inbound.event_data if inbound.event_data else {}
content = f"[菜单选择] id={menu_data.get('id', '')} content={menu_data.get('content', '')}"
elif inbound.msg_type == "channels_shop_product":
product = inbound.event_data if inbound.event_data else {}
content = f"[视频号商品] {product.get('title', '')}"
elif inbound.msg_type == "channels_shop_order":
order = inbound.event_data if inbound.event_data else {}
content = f"[视频号订单] {order.get('order_id', '')}"
elif inbound.msg_type == "merged_msg":
merged = inbound.event_data if inbound.event_data else {}
content = f"[聊天记录] {merged.get('title', '')}"
elif inbound.msg_type == "channels":
channels_data = inbound.event_data if inbound.event_data else {}
content = f"[视频号] {channels_data.get('nickname', '')}"
elif inbound.msg_type == "note":
note_data = inbound.event_data if inbound.event_data else {}
content = f"[笔记] {note_data.get('title', '')}"
elif inbound.msg_type == "meeting":
content = "[会议消息]"
elif inbound.msg_type == "calendar":
content = "[日程消息]"
unified = UnifiedMessage(
msg_id=inbound.msg_id,
channel_type="wechat-kf",
account_id="default",
content=content,
message_type=msg_type,
sender=PeerInfo(
id=inbound.external_user_id,
kind=PeerKind.DIRECT,
display_name=inbound.external_user_id,
),
timestamp=(datetime.fromtimestamp(inbound.send_time, tz=UTC) if inbound.send_time else None),
raw_payload=inbound.raw_xml or inbound.event_data,
body_for_agent=content,
metadata={
"OpenKfId": inbound.open_kfid,
"ExternalUserId": inbound.external_user_id,
"MsgType": inbound.msg_type,
"EventType": inbound.event_type,
},
)
asyncio.create_task(
self._process_message(processor, unified),
name=f"kf-dispatch-{inbound.external_user_id}",
)
async def _dispatch_system_event(self, inbound):
logger.info("Dispatching system event: %s", inbound.event_type)
async def _process_message(self, processor, msg) -> None:
try:
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except TimeoutError:
logger.error("Agent response timeout for KF user %s", msg.sender.id)
except Exception:
logger.exception("Failed to process KF message for user %s", msg.sender.id)
async def get_service_state(self, open_kfid: str, external_user_id: str) -> dict:
token = await self.get_access_token()
resp = await self._http.post(
SERVICE_STATE_GET_URL,
params={"access_token": token},
json={"open_kfid": open_kfid, "external_user_id": external_user_id},
)
return resp.json()
async def transfer_to_servicer(
self,
open_kfid: str,
external_user_id: str,
servicer_userid: str | None = None,
) -> dict:
payload = {
"open_kfid": open_kfid,
"external_user_id": external_user_id,
"service_state": 3 if servicer_userid else 2,
}
if servicer_userid:
payload["servicer_userid"] = servicer_userid
token = await self.get_access_token()
resp = await self._http.post(
SERVICE_STATE_TRANS_URL,
params={"access_token": token},
json=payload,
)
result = resp.json()
if result.get("errcode") == 0 and self._session_manager:
self._session_manager.close_session(open_kfid, external_user_id)
return result
async def add_account(self, open_kfid: str):
task = asyncio.create_task(self._sync_loop(open_kfid))
self._sync_tasks.append(task)
logger.info("WeChat KF sync loop added for %s", open_kfid)
@staticmethod
def _resolve_account(ctx):
if isinstance(ctx, dict):
account_id = ctx.get("account_id", "default")
else:
account_id = "default"
config = WeChatKFConfig()
return config.resolve_account(account_id)