diff --git a/app/api/endpoints/wecom.py b/app/api/endpoints/wecom.py new file mode 100644 index 0000000..6169776 --- /dev/null +++ b/app/api/endpoints/wecom.py @@ -0,0 +1,137 @@ +from fastapi import APIRouter, Request, Response, Query +from app.core.wecombot import WecomBot +from app.models.database import get_db +from app.models.community import CommunityDB +from app.core.config import settings +import xml.etree.ElementTree as ET +import logging +import hashlib +import base64 +from Crypto.Cipher import AES +import typing +import struct + +router = APIRouter() + +def decrypt_msg(msg_encrypt: str, signature: str, timestamp: str, nonce: str) -> typing.Optional[str]: + """解密企业微信消息""" + try: + token = settings.WECHAT_CORP_TOKEN + encoding_aes_key = settings.WECHAT_CORP_ENCODING_AES_KEY + corpid = settings.WECHAT_CORP_ID + + # 1. 验证签名 + sort_list = [token, timestamp, nonce, msg_encrypt] + sort_list.sort() + sha1 = hashlib.sha1() + sha1.update("".join(sort_list).encode()) + calc_signature = sha1.hexdigest() + if calc_signature != signature: + return None + + # 2. 解密消息 + aes_key = base64.b64decode(encoding_aes_key + "=") + aes = AES.new(aes_key, AES.MODE_CBC, aes_key[:16]) + + # 解密 + text = base64.b64decode(msg_encrypt) + decrypted_text = aes.decrypt(text) + + # 去除补位 + pad = decrypted_text[-1] + if not isinstance(pad, int): + pad = ord(pad) + content = decrypted_text[:-pad] + + # 验证corpid + xml_len = struct.unpack('!I', content[16:20])[0] + xml_content = content[20 : 20 + xml_len] + from_corpid = content[20 + xml_len:] + + if from_corpid.decode() != corpid: + return None + + return xml_content.decode() + + except Exception as e: + logging.exception("解密企业微信消息失败") + return None + +@router.get("/callback") +async def verify_callback( + msg_signature: str = Query(..., description="签名"), + timestamp: str = Query(..., description="时间戳"), + nonce: str = Query(..., description="随机数"), + echostr: str = Query(..., description="随机字符串") +): + """验证回调配置""" + try: + # 解密echostr + decrypted_str = decrypt_msg(echostr, msg_signature, timestamp, nonce) + if not decrypted_str: + return Response(status_code=403) + + return Response(content=decrypted_str, media_type="text/plain") + + except Exception as e: + logging.exception("验证回调配置失败") + return Response(status_code=403) + +@router.post("/callback") +async def wechat_corp_callback( + request: Request, + msg_signature: str = Query(..., description="签名"), + timestamp: str = Query(..., description="时间戳"), + nonce: str = Query(..., description="随机数") +): + """处理企业微信回调消息""" + try: + # 读取原始XML数据 + body = await request.body() + body_str = body.decode() + + # 解析XML获取加密消息 + root = ET.fromstring(body_str) + encrypt_msg = root.find('Encrypt').text + + # 解密消息 + decrypted_msg = decrypt_msg(encrypt_msg, msg_signature, timestamp, nonce) + if not decrypted_msg: + return Response(content="success", media_type="text/plain") + + # 解析解密后的XML + msg_root = ET.fromstring(decrypted_msg) + + print(f"企业微信回调消息:{decrypted_msg}") + + # 解析基本信息 + msg_type = msg_root.find('MsgType').text + + # 处理事件消息 + if msg_type == 'event': + event = msg_root.find('Event').text + + # 处理进群事件 + if event == 'change_external_chat': + chat_id = msg_root.find('ChatId').text + change_type = msg_root.find('ChangeType').text + + if change_type == 'add_member': + # 获取数据库会话 + db = next(get_db()) + + # 查找对应的小区 + community = db.query(CommunityDB).filter( + CommunityDB.webot_webhook.isnot(None) + ).first() + + if community and community.webot_webhook: + # 发送欢迎消息 + wecom_bot = WecomBot() + await wecom_bot.send_welcome_message(community.webot_webhook) + + return Response(content="success", media_type="text/plain") + + except Exception as e: + logging.exception("处理企业微信回调消息异常") + return Response(content="success", media_type="text/plain") \ No newline at end of file