新增微信客服、微信公众号、微信支付通知三个渠道扩展。 微信客服渠道扩展功能模块: - 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: 回调补偿
441 lines
18 KiB
Python
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)
|