新增微信客服、微信公众号、微信支付通知三个渠道扩展。 微信客服渠道扩展功能模块: - 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: 回调补偿
287 lines
10 KiB
Python
287 lines
10 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
USER_INFO_URL = "https://api.weixin.qq.com/cgi-bin/user/info"
|
|
USER_INFO_BATCH_URL = "https://api.weixin.qq.com/cgi-bin/user/info/batchget"
|
|
USER_REMARK_URL = "https://api.weixin.qq.com/cgi-bin/user/info/updateremark"
|
|
USER_LIST_URL = "https://api.weixin.qq.com/cgi-bin/user/get"
|
|
TAGS_URL = "https://api.weixin.qq.com/cgi-bin/tags"
|
|
BLACKLIST_URL = "https://api.weixin.qq.com/cgi-bin/tags/members/getblacklist"
|
|
BLACKLIST_BATCH_URL = "https://api.weixin.qq.com/cgi-bin/tags/members/batchblacklist"
|
|
|
|
|
|
class WeChatUserInfoCache:
|
|
TTL_SECONDS = 3600
|
|
|
|
def __init__(self):
|
|
self._cache: dict[str, dict] = {}
|
|
self._timestamps: dict[str, float] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def get(self, openid: str) -> dict | None:
|
|
async with self._lock:
|
|
ts = self._timestamps.get(openid, 0)
|
|
if time.time() - ts > self.TTL_SECONDS:
|
|
self._cache.pop(openid, None)
|
|
self._timestamps.pop(openid, None)
|
|
return None
|
|
return self._cache.get(openid)
|
|
|
|
async def set(self, openid: str, info: dict) -> None:
|
|
async with self._lock:
|
|
self._cache[openid] = info
|
|
self._timestamps[openid] = time.time()
|
|
|
|
async def clear(self, openid: str) -> None:
|
|
async with self._lock:
|
|
self._cache.pop(openid, None)
|
|
self._timestamps.pop(openid, None)
|
|
|
|
|
|
_user_cache = WeChatUserInfoCache()
|
|
|
|
|
|
async def fetch_user_info(openid: str, gateway) -> dict | None:
|
|
cached = await _user_cache.get(openid)
|
|
if cached:
|
|
return cached
|
|
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return None
|
|
|
|
url = f"{USER_INFO_URL}?access_token={token}&openid={openid}&lang=zh_CN"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(url)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
info = {
|
|
"nickname": data.get("nickname", ""),
|
|
"headimgurl": data.get("headimgurl", ""),
|
|
"subscribe": data.get("subscribe", 0),
|
|
"sex": data.get("sex", 0),
|
|
"country": data.get("country", ""),
|
|
"province": data.get("province", ""),
|
|
"city": data.get("city", ""),
|
|
}
|
|
await _user_cache.set(openid, info)
|
|
return info
|
|
logger.warning("Fetch user info failed: errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg"))
|
|
except Exception:
|
|
logger.exception("Fetch user info exception for openid=%s", openid)
|
|
return None
|
|
|
|
|
|
async def batch_fetch_user_info(openids: list[str], gateway) -> list[dict]:
|
|
token = gateway.access_token if gateway else None
|
|
if not token or not openids:
|
|
return []
|
|
|
|
payload = {"user_list": [{"openid": oid, "lang": "zh_CN"} for oid in openids[:100]]}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
resp = await client.post(f"{USER_INFO_BATCH_URL}?access_token={token}", json=payload)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return data.get("user_info_list", [])
|
|
logger.warning(
|
|
"Batch fetch user info failed: errcode=%s errmsg=%s",
|
|
data.get("errcode"),
|
|
data.get("errmsg"),
|
|
)
|
|
except Exception:
|
|
logger.exception("Batch fetch user info exception")
|
|
return []
|
|
|
|
|
|
async def update_remark(openid: str, remark: str, gateway) -> bool:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return False
|
|
|
|
payload = {"openid": openid, "remark": remark}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(f"{USER_REMARK_URL}?access_token={token}", json=payload)
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Update remark exception for openid=%s", openid)
|
|
return False
|
|
|
|
|
|
async def fetch_followers(next_openid: str = "", gateway=None) -> dict:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
url = f"{USER_LIST_URL}?access_token={token}"
|
|
if next_openid:
|
|
url += f"&next_openid={next_openid}"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(url)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return {
|
|
"success": True,
|
|
"total": data.get("total", 0),
|
|
"count": data.get("count", 0),
|
|
"data": data.get("data", {}).get("openid", []),
|
|
"next_openid": data.get("next_openid", ""),
|
|
}
|
|
return {"success": False, "error": data.get("errmsg", "fetch failed"), "errcode": data.get("errcode")}
|
|
except Exception as e:
|
|
logger.exception("Fetch followers exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
async def list_tags(gateway) -> list[dict]:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return []
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(f"{TAGS_URL}/get?access_token={token}")
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return data.get("tags", [])
|
|
logger.warning("List tags failed: errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg"))
|
|
except Exception:
|
|
logger.exception("List tags exception")
|
|
return []
|
|
|
|
|
|
async def create_tag(name: str, gateway) -> int | None:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return None
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(f"{TAGS_URL}/create?access_token={token}", json={"tag": {"name": name}})
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return data.get("tag", {}).get("id")
|
|
logger.warning("Create tag failed: errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg"))
|
|
except Exception:
|
|
logger.exception("Create tag exception")
|
|
return None
|
|
|
|
|
|
async def update_tag(tag_id: int, name: str, gateway) -> bool:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return False
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(
|
|
f"{TAGS_URL}/update?access_token={token}", json={"tag": {"id": tag_id, "name": name}}
|
|
)
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Update tag exception")
|
|
return False
|
|
|
|
|
|
async def delete_tag(tag_id: int, gateway) -> bool:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return False
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(f"{TAGS_URL}/delete?access_token={token}", json={"tag": {"id": tag_id}})
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Delete tag exception")
|
|
return False
|
|
|
|
|
|
async def tag_users(openids: list[str], tag_id: int, gateway) -> bool:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return False
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(
|
|
f"{TAGS_URL}/members/batchtagging?access_token={token}",
|
|
json={"openid_list": openids[:50], "tagid": tag_id},
|
|
)
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Tag users exception")
|
|
return False
|
|
|
|
|
|
async def untag_users(openids: list[str], tag_id: int, gateway) -> bool:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return False
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(
|
|
f"{TAGS_URL}/members/batchuntagging?access_token={token}",
|
|
json={"openid_list": openids[:50], "tagid": tag_id},
|
|
)
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Untag users exception")
|
|
return False
|
|
|
|
|
|
async def get_blacklist(begin_openid: str = "", gateway=None) -> dict:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return {"success": False, "error": "no access_token"}
|
|
|
|
payload = {"begin_openid": begin_openid} if begin_openid else {}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(f"{BLACKLIST_URL}?access_token={token}", json=payload)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return {
|
|
"success": True,
|
|
"total": data.get("total", 0),
|
|
"count": data.get("count", 0),
|
|
"data": data.get("data", {}).get("openid", []),
|
|
"next_openid": data.get("next_openid", ""),
|
|
}
|
|
return {"success": False, "error": data.get("errmsg", "fetch failed"), "errcode": data.get("errcode")}
|
|
except Exception as e:
|
|
logger.exception("Get blacklist exception")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
async def batch_blacklist(openids: list[str], gateway, action: str = "blacklist") -> bool:
|
|
token = gateway.access_token if gateway else None
|
|
if not token:
|
|
return False
|
|
|
|
url = f"{BLACKLIST_BATCH_URL}?access_token={token}"
|
|
if action == "unblacklist":
|
|
url = url.replace("batchblacklist", "batchunblacklist")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(url, json={"openid_list": openids[:20]})
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("Batch blacklist exception")
|
|
return False
|