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