新增微信客服、微信公众号、微信支付通知三个渠道扩展。 微信客服渠道扩展功能模块: - 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: 回调补偿
269 lines
10 KiB
Python
269 lines
10 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/media/upload"
|
|
DOWNLOAD_URL = "https://api.weixin.qq.com/cgi-bin/media/get"
|
|
MATERIAL_ADD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"
|
|
MATERIAL_DEL_URL = "https://api.weixin.qq.com/cgi-bin/material/del_material"
|
|
CLEAR_QUOTA_URL = "https://api.weixin.qq.com/cgi-bin/clear_quota"
|
|
MATERIAL_BATCHGET_URL = "https://api.weixin.qq.com/cgi-bin/material/batchget_material"
|
|
MATERIAL_COUNT_URL = "https://api.weixin.qq.com/cgi-bin/material/get_materialcount"
|
|
MATERIAL_ADD_NEWS_URL = "https://api.weixin.qq.com/cgi-bin/material/add_news"
|
|
|
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/jpg", "image/png"}
|
|
ALLOWED_VOICE_TYPES = {"audio/amr", "audio/mp3", "audio/speex"}
|
|
ALLOWED_VIDEO_TYPES = {"video/mp4"}
|
|
ALLOWED_THUMB_TYPES = {"image/jpeg", "image/jpg"}
|
|
|
|
ALLOWED_MEDIA_TYPE_MAP = {
|
|
"image": ALLOWED_IMAGE_TYPES,
|
|
"voice": ALLOWED_VOICE_TYPES,
|
|
"video": ALLOWED_VIDEO_TYPES,
|
|
"thumb": ALLOWED_THUMB_TYPES,
|
|
}
|
|
|
|
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
|
MAX_VOICE_BYTES = 2 * 1024 * 1024
|
|
MAX_VIDEO_BYTES = 10 * 1024 * 1024
|
|
MAX_THUMB_BYTES = 64 * 1024
|
|
|
|
MAX_BYTES_MAP = {
|
|
"image": MAX_IMAGE_BYTES,
|
|
"voice": MAX_VOICE_BYTES,
|
|
"video": MAX_VIDEO_BYTES,
|
|
"thumb": MAX_THUMB_BYTES,
|
|
}
|
|
|
|
|
|
class WeChatMedia:
|
|
|
|
def __init__(self, access_token_provider, app_id: str = "", app_secret: str = ""):
|
|
self._token_provider = access_token_provider
|
|
self._http: httpx.AsyncClient | None = None
|
|
self._app_id = app_id
|
|
self._app_secret = app_secret
|
|
self._last_clear_quota = 0.0
|
|
|
|
async def _client(self) -> httpx.AsyncClient:
|
|
if self._http is None:
|
|
self._http = httpx.AsyncClient(timeout=30.0)
|
|
return self._http
|
|
|
|
async def upload(
|
|
self,
|
|
file_data: bytes,
|
|
filename: str,
|
|
media_type: str,
|
|
content_type: str | None = None,
|
|
) -> dict:
|
|
if media_type not in ALLOWED_MEDIA_TYPE_MAP:
|
|
return {"success": False, "error": f"unknown media_type: {media_type}"}
|
|
|
|
max_bytes = MAX_BYTES_MAP.get(media_type, 0)
|
|
if len(file_data) > max_bytes:
|
|
return {"success": False, "error": f"file too large: {len(file_data)} bytes (max {max_bytes})"}
|
|
|
|
token = self._token_provider()
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
url = f"{UPLOAD_URL}?access_token={token}&type={media_type}"
|
|
files = {"media": (filename, file_data, content_type or "application/octet-stream")}
|
|
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.post(url, files=files)
|
|
data = resp.json()
|
|
|
|
if "media_id" in data:
|
|
return {"success": True, "media_id": data["media_id"], "type": data.get("type", media_type)}
|
|
if "errcode" in data:
|
|
logger.warning(
|
|
"WeChat media upload failed: errcode=%s errmsg=%s",
|
|
data.get("errcode"),
|
|
data.get("errmsg"),
|
|
)
|
|
return {"success": False, "error": data.get("errmsg", "upload failed"), "errcode": data["errcode"]}
|
|
return {"success": False, "error": "unknown response"}
|
|
|
|
except Exception as e:
|
|
logger.exception("WeChat media upload exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def download(self, media_id: str) -> dict:
|
|
token = self._token_provider()
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
url = f"{DOWNLOAD_URL}?access_token={token}&media_id={media_id}"
|
|
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.get(url)
|
|
|
|
content_type = resp.headers.get("content-type", "")
|
|
if "application/json" in content_type or resp.text.startswith("{"):
|
|
data = resp.json()
|
|
if "errcode" in data and data["errcode"] != 0:
|
|
logger.warning(
|
|
"WeChat media download failed: errcode=%s errmsg=%s",
|
|
data.get("errcode"),
|
|
data.get("errmsg"),
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": data.get("errmsg", "download failed"),
|
|
"errcode": data["errcode"],
|
|
}
|
|
return {"success": False, "error": "unexpected json response"}
|
|
|
|
return {
|
|
"success": True,
|
|
"data": resp.content,
|
|
"content_type": content_type,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.exception("WeChat media download exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def upload_permanent(self, file_data: bytes, filename: str, media_type: str) -> dict:
|
|
token = self._token_provider()
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
url = f"{MATERIAL_ADD_URL}?access_token={token}&type={media_type}"
|
|
files = {"media": (filename, file_data, "application/octet-stream")}
|
|
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.post(url, files=files)
|
|
data = resp.json()
|
|
|
|
if "media_id" in data:
|
|
return {"success": True, "media_id": data["media_id"], "url": data.get("url", "")}
|
|
if "errcode" in data:
|
|
logger.warning(
|
|
"Permanent material upload failed: errcode=%s errmsg=%s",
|
|
data.get("errcode"),
|
|
data.get("errmsg"),
|
|
)
|
|
return {"success": False, "error": data.get("errmsg", "upload failed"), "errcode": data["errcode"]}
|
|
return {"success": False, "error": "unknown response"}
|
|
|
|
except Exception as e:
|
|
logger.exception("Permanent material upload exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def delete_permanent(self, media_id: str) -> bool:
|
|
token = self._token_provider()
|
|
if not token:
|
|
return False
|
|
|
|
url = f"{MATERIAL_DEL_URL}?access_token={token}"
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.post(url, json={"media_id": media_id})
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Permanent material delete exception")
|
|
return False
|
|
|
|
async def delete_after_delay(self, media_id: str, delay: float = 10.0):
|
|
await asyncio.sleep(delay)
|
|
await self.delete_permanent(media_id)
|
|
|
|
async def clear_quota(self) -> bool:
|
|
now = time.time()
|
|
if now - self._last_clear_quota < 60:
|
|
return False
|
|
self._last_clear_quota = now
|
|
|
|
if not self._app_id or not self._app_secret:
|
|
return False
|
|
|
|
payload = {"appid": self._app_id, "appsecret": self._app_secret}
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.post(CLEAR_QUOTA_URL, json=payload)
|
|
data = resp.json()
|
|
ok = data.get("errcode") == 0
|
|
if not ok:
|
|
logger.warning("Clear quota failed: errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg"))
|
|
return ok
|
|
except Exception:
|
|
logger.exception("Clear quota exception")
|
|
return False
|
|
|
|
async def list_materials(self, media_type: str, offset: int = 0, count: int = 20) -> dict:
|
|
token = self._token_provider()
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
payload = {"type": media_type, "offset": offset, "count": min(count, 20)}
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.post(f"{MATERIAL_BATCHGET_URL}?access_token={token}", json=payload)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return {
|
|
"success": True,
|
|
"total_count": data.get("total_count", 0),
|
|
"item_count": data.get("item_count", 0),
|
|
"item": data.get("item", []),
|
|
}
|
|
return {"success": False, "error": data.get("errmsg", "list failed"), "errcode": data.get("errcode")}
|
|
except Exception as e:
|
|
logger.exception("List materials exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def get_material_count(self) -> dict:
|
|
token = self._token_provider()
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.get(f"{MATERIAL_COUNT_URL}?access_token={token}")
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return {
|
|
"success": True,
|
|
"voice_count": data.get("voice_count", 0),
|
|
"video_count": data.get("video_count", 0),
|
|
"image_count": data.get("image_count", 0),
|
|
"news_count": data.get("news_count", 0),
|
|
}
|
|
return {"success": False, "error": data.get("errmsg", "count failed"), "errcode": data.get("errcode")}
|
|
except Exception as e:
|
|
logger.exception("Get material count exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def upload_news(self, articles: list[dict]) -> dict:
|
|
token = self._token_provider()
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
payload = {"articles": articles}
|
|
try:
|
|
client = await self._client()
|
|
resp = await client.post(f"{MATERIAL_ADD_NEWS_URL}?access_token={token}", json=payload)
|
|
data = resp.json()
|
|
if "media_id" in data:
|
|
return {"success": True, "media_id": data["media_id"]}
|
|
return {"success": False, "error": data.get("errmsg", "upload news failed"), "errcode": data.get("errcode")}
|
|
except Exception as e:
|
|
logger.exception("Upload news exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def close(self):
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|