feat: add adapter for 微信公众号

This commit is contained in:
wangcham
2025-02-10 06:08:59 -05:00
parent 21e1acc4f5
commit 05c1fdaa9e
8 changed files with 500 additions and 4 deletions
+175
View File
@@ -0,0 +1,175 @@
# 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件
import time
import traceback
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
import xml.etree.ElementTree as ET
from quart import Quart,request
import hashlib
from typing import Callable, Dict, Any
from .oaevent import OAEvent
import httpx
import asyncio
import time
import xml.etree.ElementTree as ET
xml_template = """
<xml>
<ToUserName><![CDATA[{to_user}]]></ToUserName>
<FromUserName><![CDATA[{from_user}]]></FromUserName>
<CreateTime>{create_time}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[{content}]]></Content>
</xml>
"""
class OAClient():
def __init__(self,token:str,EncodingAESKey:str,AppID:str,Appsecret:str):
self.token = token
self.aes = EncodingAESKey
self.appid = AppID
self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com'
self.access_token = ''
self.app = Quart(__name__)
self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'])
self._message_handlers = {
"example":[],
}
self.access_token_expiry_time = None
self.msg_id_map = {}
async def handle_callback_request(self):
try:
# 每隔100毫秒查询是否生成ai回答
start_time = time.time()
signature = request.args.get("signature", "")
timestamp = request.args.get("timestamp", "")
nonce = request.args.get("nonce", "")
echostr = request.args.get("echostr", "")
msg_signature = request.args.get("msg_signature","")
if msg_signature is None:
raise Exception("msg_signature不在请求体中")
if request.method == 'GET':
# 校验签名
check_str = "".join(sorted([self.token, timestamp, nonce]))
check_signature = hashlib.sha1(check_str.encode("utf-8")).hexdigest()
if check_signature == signature:
return echostr # 验证成功返回echostr
else:
raise Exception("拒绝请求")
elif request.method == "POST":
encryt_msg = await request.data
wxcpt = WXBizMsgCrypt(self.token,self.aes,self.appid)
ret,xml_msg = wxcpt.DecryptMsg(encryt_msg,msg_signature,timestamp,nonce)
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
raise Exception("消息解密失败")
message_data = await self.get_message(xml_msg)
if message_data :
event = OAEvent.from_payload(message_data)
if event:
await self._handle_message(event)
root = ET.fromstring(xml_msg)
from_user = root.find("FromUserName").text # 发送者
to_user = root.find("ToUserName").text # 机器人
from pkg.platform.sources import officialaccount
timeout = 4.80
interval = 0.1
while True:
content = officialaccount.generated_content.pop(message_data["MsgId"], None)
if content:
response_xml = xml_template.format(
to_user=from_user,
from_user=to_user,
create_time=int(time.time()),
content = content
)
return response_xml
if time.time() - start_time >= timeout:
break
await asyncio.sleep(interval)
if self.msg_id_map.get(message_data["MsgId"], 1) == 3:
response_xml = xml_template.format(
to_user=from_user,
from_user=to_user,
create_time=int(time.time()),
content = "请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。"
)
return response_xml
except Exception as e:
traceback.print_exc()
async def get_message(self, xml_msg: str):
root = ET.fromstring(xml_msg)
message_data = {
"ToUserName": root.find("ToUserName").text,
"FromUserName": root.find("FromUserName").text,
"CreateTime": int(root.find("CreateTime").text),
"MsgType": root.find("MsgType").text,
"Content": root.find("Content").text if root.find("Content") is not None else None,
"MsgId": int(root.find("MsgId").text) if root.find("MsgId") is not None else None,
}
return message_data
async def run_task(self, host: str, port: int, *args, **kwargs):
"""
启动 Quart 应用。
"""
await self.app.run_task(host=host, port=port, *args, **kwargs)
def on_message(self, msg_type: str):
"""
注册消息类型处理器。
"""
def decorator(func: Callable[[OAEvent], None]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
async def _handle_message(self, event: OAEvent):
"""
处理消息事件。
"""
message_id = event.message_id
if message_id in self.msg_id_map.keys():
self.msg_id_map[message_id] += 1
return
self.msg_id_map[message_id] = 1
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
+167
View File
@@ -0,0 +1,167 @@
from typing import Dict, Any, Optional
class OAEvent(dict):
"""
封装从微信公众号收到的事件数据对象(字典),提供属性以获取其中的字段。
除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。
"""
@staticmethod
def from_payload(payload: Dict[str, Any]) -> Optional["OAEvent"]:
"""
从微信公众号事件数据构造 `WecomEvent` 对象。
Args:
payload (Dict[str, Any]): 解密后的微信事件数据。
Returns:
Optional[OAEvent]: 如果事件数据合法,则返回 OAEvent 对象;否则返回 None。
"""
try:
event = OAEvent(payload)
_ = event.type, event.detail_type # 确保必须字段存在
return event
except KeyError:
return None
@property
def type(self) -> str:
"""
事件类型,例如 "message""event""text" 等。
Returns:
str: 事件类型。
"""
return self.get("MsgType", "")
@property
def picurl(self) -> str:
"""
图片链接
"""
return self.get("PicUrl","")
@property
def detail_type(self) -> str:
"""
事件详细类型,依 `type` 的不同而不同。例如:
- 消息事件: "text", "image", "voice", 等
- 事件通知: "subscribe", "unsubscribe", "click", 等
Returns:
str: 事件详细类型。
"""
if self.type == "event":
return self.get("Event", "")
return self.type
@property
def name(self) -> str:
"""
事件名,对于消息事件是 `type.detail_type`,对于其他事件是 `event_type`。
Returns:
str: 事件名。
"""
return f"{self.type}.{self.detail_type}"
@property
def user_id(self) -> Optional[str]:
"""
发送方账号
"""
return self.get("FromUserName")
@property
def receiver_id(self) -> Optional[str]:
"""
接收者 ID,例如机器人自身的公众号微信 ID。
Returns:
Optional[str]: 接收者 ID。
"""
return self.get("ToUserName")
@property
def message_id(self) -> Optional[str]:
"""
消息 ID,仅在消息类型事件中存在。
Returns:
Optional[str]: 消息 ID。
"""
return self.get("MsgId")
@property
def message(self) -> Optional[str]:
"""
消息内容,仅在消息类型事件中存在。
Returns:
Optional[str]: 消息内容。
"""
return self.get("Content")
@property
def media_id(self) -> Optional[str]:
"""
媒体文件 ID,仅在图片、语音等消息类型中存在。
Returns:
Optional[str]: 媒体文件 ID。
"""
return self.get("MediaId")
@property
def timestamp(self) -> Optional[int]:
"""
事件发生的时间戳。
Returns:
Optional[int]: 时间戳。
"""
return self.get("CreateTime")
@property
def event_key(self) -> Optional[str]:
"""
事件的 Key 值,例如点击菜单时的 `EventKey`。
Returns:
Optional[str]: 事件 Key。
"""
return self.get("EventKey")
def __getattr__(self, key: str) -> Optional[Any]:
"""
允许通过属性访问数据中的任意字段。
Args:
key (str): 字段名。
Returns:
Optional[Any]: 字段值。
"""
return self.get(key)
def __setattr__(self, key: str, value: Any) -> None:
"""
允许通过属性设置数据中的任意字段。
Args:
key (str): 字段名。
value (Any): 字段值。
"""
self[key] = value
def __repr__(self) -> str:
"""
生成事件对象的字符串表示。
Returns:
str: 字符串表示。
"""
return f"<WecomEvent {super().__repr__()}>"
+2 -1
View File
@@ -171,6 +171,7 @@ class WecomClient():
elif request.method == "POST":
encrypt_msg = await request.data
ret, xml_msg = self.wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
# print(xml_msg)
if ret != 0:
raise Exception(f"消息解密失败,错误码: {ret}")
@@ -228,7 +229,7 @@ class WecomClient():
if message_data["MsgType"] == "image":
message_data["MediaId"] = root.find("MediaId").text if root.find("MediaId") is not None else None
message_data["PicUrl"] = root.find("PicUrl").text if root.find("PicUrl") is not None else None
return message_data
@staticmethod