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

240 lines
8.3 KiB
Python

import asyncio
import logging
from yuxi.channel.extensions.wechat_kf.types import KFOutboundResult
logger = logging.getLogger(__name__)
SEND_MSG_URL = "/cgi-bin/kf/send_msg"
SEND_MSG_ON_EVENT_URL = "/cgi-bin/kf/send_msg_on_event"
RECALL_URL = "/cgi-bin/kf/recall_msg"
MAX_TEXT_BYTES = 2048
def split_utf8_safe(text: str, max_bytes: int = MAX_TEXT_BYTES) -> list[str]:
chunks = []
current = ""
for char in text:
candidate = current + char
if len(candidate.encode("utf-8")) > max_bytes:
if current:
chunks.append(current)
current = char
else:
chunks.append(char)
else:
current = candidate
if current:
chunks.append(current)
return chunks
class WeChatKFOutbound:
def __init__(self, gateway=None):
self._gateway = gateway
async def send_text(self, external_user_id: str, open_kfid: str, content: str) -> KFOutboundResult:
session_manager = self._gateway._session_manager if self._gateway else None
session = session_manager.get_session(open_kfid, external_user_id) if session_manager else None
if session and session.msg_count_in_round >= 5:
return KFOutboundResult(success=False, error="本轮消息数已达上限(5条)", errcode=45047)
chunks = split_utf8_safe(content)
last_result = None
for i, chunk in enumerate(chunks):
remaining = 5 - (session.msg_count_in_round if session else 0) - i
if remaining <= 0:
break
last_result = await self._send_msg(external_user_id, open_kfid, "text", {"text": {"content": chunk}})
if session:
session.msg_count_in_round += 1
if session and session_manager:
session_manager.update_session(session)
return last_result or KFOutboundResult(success=True)
async def send_image(self, external_user_id: str, open_kfid: str, media_id: str) -> KFOutboundResult:
return await self._send_msg(
external_user_id,
open_kfid,
"image",
{"image": {"media_id": media_id}},
)
async def send_voice(self, external_user_id: str, open_kfid: str, media_id: str) -> KFOutboundResult:
return await self._send_msg(
external_user_id,
open_kfid,
"voice",
{"voice": {"media_id": media_id}},
)
async def send_video(self, external_user_id: str, open_kfid: str, media: dict) -> KFOutboundResult:
return await self._send_msg(external_user_id, open_kfid, "video", {"video": media})
async def send_file(self, external_user_id: str, open_kfid: str, media_id: str) -> KFOutboundResult:
return await self._send_msg(
external_user_id,
open_kfid,
"file",
{"file": {"media_id": media_id}},
)
async def send_link(
self,
external_user_id: str,
open_kfid: str,
title: str,
desc: str,
url: str,
pic_url: str = "",
) -> KFOutboundResult:
payload = {
"link": {
"title": title,
"desc": desc,
"url": url,
"pic_url": pic_url,
}
}
return await self._send_msg(external_user_id, open_kfid, "link", payload)
async def send_miniprogram(
self,
external_user_id: str,
open_kfid: str,
title: str,
appid: str,
pagepath: str,
thumb_media_id: str,
) -> KFOutboundResult:
payload = {
"miniprogram": {
"title": title,
"appid": appid,
"pagepath": pagepath,
"thumb_media_id": thumb_media_id,
}
}
return await self._send_msg(external_user_id, open_kfid, "miniprogram", payload)
async def send_menu(
self,
external_user_id: str,
open_kfid: str,
head_content: str,
menu_list: list[dict],
tail_content: str = "",
) -> KFOutboundResult:
payload = {
"msgmenu": {
"head_content": head_content,
"list": menu_list,
"tail_content": tail_content,
}
}
return await self._send_msg(external_user_id, open_kfid, "msgmenu", payload)
async def send_location(
self,
external_user_id: str,
open_kfid: str,
latitude: float,
longitude: float,
name: str,
address: str,
) -> KFOutboundResult:
payload = {
"location": {
"latitude": str(latitude),
"longitude": str(longitude),
"name": name,
"address": address,
}
}
return await self._send_msg(external_user_id, open_kfid, "location", payload)
async def send_business_card(self, external_user_id: str, open_kfid: str, userid: str) -> KFOutboundResult:
payload = {"business_card": {"userid": userid}}
return await self._send_msg(external_user_id, open_kfid, "business_card", payload)
async def send_welcome(self, open_kfid: str, welcome_code: str, content: str) -> KFOutboundResult:
payload = {
"code": welcome_code,
"msgtype": "text",
"text": {"content": content},
}
return await self._send_event_msg(open_kfid, payload)
async def send_session_end_msg(self, open_kfid: str, msg_code: str, content: str) -> KFOutboundResult:
payload = {
"code": msg_code,
"msgtype": "text",
"text": {"content": content},
}
return await self._send_event_msg(open_kfid, payload)
async def recall(self, open_kfid: str, external_user_id: str, msgid: str) -> KFOutboundResult:
token = await self._gateway.get_access_token()
resp = await self._gateway._http.post(
RECALL_URL,
params={"access_token": token},
json={
"open_kfid": open_kfid,
"external_user_id": external_user_id,
"msgid": msgid,
},
)
data = resp.json()
if data.get("errcode") == 0:
return KFOutboundResult(success=True)
return KFOutboundResult(success=False, error=data.get("errmsg", ""), errcode=data.get("errcode", 0))
async def _send_msg(self, touser: str, open_kfid: str, msgtype: str, content: dict) -> KFOutboundResult:
payload = {"touser": touser, "open_kfid": open_kfid, "msgtype": msgtype}
payload.update(content)
for attempt in range(3):
try:
token = await self._gateway.get_access_token()
resp = await self._gateway._http.post(
SEND_MSG_URL,
params={"access_token": token},
json=payload,
)
data = resp.json()
errcode = data.get("errcode", 0)
if errcode == 0:
return KFOutboundResult(success=True, msg_id=data.get("msgid", ""))
if errcode == 42001:
await self._gateway._refresh_token()
continue
if errcode == 45047:
return KFOutboundResult(success=False, error="下行条数超限", errcode=45047)
return KFOutboundResult(
success=False,
error=data.get("errmsg", ""),
errcode=errcode,
)
except Exception as e:
if attempt < 2:
await asyncio.sleep(2**attempt)
else:
return KFOutboundResult(success=False, error=str(e))
return KFOutboundResult(success=False, error="重试耗尽")
async def _send_event_msg(self, open_kfid: str, payload: dict) -> KFOutboundResult:
token = await self._gateway.get_access_token()
resp = await self._gateway._http.post(
SEND_MSG_ON_EVENT_URL,
params={"access_token": token},
json=payload,
)
data = resp.json()
if data.get("errcode") == 0:
return KFOutboundResult(success=True, msg_id=data.get("msgid", ""))
return KFOutboundResult(success=False, error=data.get("errmsg", ""), errcode=data.get("errcode", 0))