This commit is contained in:
2026-01-14 17:58:25 +08:00
commit 4620e349d9
20 changed files with 2207 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.env
.vscode
# prod file
.sqlite
fallback.log

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

0
README.md Normal file
View File

105
config.py Normal file
View File

@@ -0,0 +1,105 @@
import os
from pathlib import Path
from types import SimpleNamespace # 导入SimpleNamespace
from typing import Any, Dict
import yaml
from dotenv import load_dotenv
from uvicorn.server import logger
class EnvConfig:
def __init__(self):
# 加载环境变量
if os.path.exists(".env"):
if load_dotenv(".env"):
logger.info("[激活配置] .env 配置加载成功 ✅")
else:
# 将 exit(1) 更改为 warning避免程序直接退出
logger.warning(
"[激活配置] 环境变量加载失败,请检查 .env 文件内容是否正确"
)
else:
logger.warning("[激活配置] .env 文件不存在,将不加载环境变量")
def _parse_bool(self, value):
"""将字符串形式的布尔值转换为布尔类型"""
if isinstance(value, str):
if value.lower() == "true":
return True
elif value.lower() == "false":
return False
return value
def __getattr__(self, name):
value = os.getenv(name)
if value is not None:
return self._parse_bool(value)
default_name = f"DEFAULT_{name}"
default_value = os.getenv(default_name)
if default_value is not None:
return self._parse_bool(default_value)
return None
def get(self, name, default=None):
value = os.getenv(name)
if value is not None:
return self._parse_bool(value)
return default
class YamlConfig:
def __init__(self, yaml_file: str = "config.yaml"):
self._yaml_file = Path(yaml_file)
self._load_settings()
def _load_settings(self) -> None:
if not self._yaml_file.exists():
raise FileNotFoundError(f"[激活配置] 配置文件 {self._yaml_file} 不存在")
with open(self._yaml_file, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f) or {}
self._set_attributes(config_data)
logger.info(f"[激活配置] config.yaml 配置加载成功 ✅")
def _set_attributes(self, config_data: Dict[str, Any]) -> None:
# 使用 SimpleNamespace 更好地处理嵌套配置,使其可以通过属性访问
for key, value in config_data.items():
if isinstance(value, dict):
setattr(self, key, SimpleNamespace(**value))
else:
setattr(self, key, value)
# 移除 _from_dict 方法,因为 SimpleNamespace 可以直接从字典创建
def __repr__(self) -> str:
attrs = []
for key in sorted(self.__dict__.keys()):
if not key.startswith("_"):
value = getattr(self, key)
attrs.append(f"{key}={repr(value)}")
return f"Settings({', '.join(attrs)})"
def reload(self) -> None:
if self._yaml_file is None:
raise RuntimeError("无法重新加载,此实例是从字典创建的")
self._load_settings()
# 创建配置实例
env_config = EnvConfig()
yaml_config = None
try:
yaml_config = YamlConfig()
except FileNotFoundError:
logger.warning("[激活配置] 配置文件 config.yaml 不存在,将不加载配置")
class Setting:
def __init__(self):
self.env = env_config
self.config = yaml_config
setting = Setting()

3
config.yaml Normal file
View File

@@ -0,0 +1,3 @@
apscheduler:
webui:
enable: true

3
handler/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .exception import install
__all__ = ["install"]

28
handler/exception.py Normal file
View File

@@ -0,0 +1,28 @@
import http
from fastapi import FastAPI, HTTPException, Request
from pydantic import ValidationError
from fastapi.responses import JSONResponse
from uvicorn.server import logger
exceptions = [Exception, HTTPException, ValidationError]
async def general_exception_handler(request: Request, e: Exception):
logger.error(f"[异常处理] 发生异常: {e}")
status_code = http.HTTPStatus.INTERNAL_SERVER_ERROR
detail = str(e)
message = "发生了一些错误"
if isinstance(e, HTTPException):
status_code = e.status_code
message = e.detail
return JSONResponse(
status_code=status_code,
content={"detail": detail, "message": message},
)
def install(app: FastAPI):
logger.info("[异常处理] 开始加载异常处理器")
for exception in exceptions:
app.add_exception_handler(exception, general_exception_handler)
logger.info("[异常处理] 异常处理器加载完成")

27
lifespan.py Normal file
View File

@@ -0,0 +1,27 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from uvicorn.server import logger
def active_config():
logger.info(f"[激活配置] 加载配置 ⚙️")
from config import setting # noqa
def import_router(app: FastAPI):
logger.info(f"[导入路由] 开始导入路由 🛣️")
from router import router
app.include_router(router)
logger.info(f"[导入路由] 路由导入完成 ✅")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"[生命周期] 应用启动 🚀")
active_config()
import_router(app)
yield
logger.info(f"[生命周期] 应用关闭 🔧✅")

13
main.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import FastAPI
from handler.exception import install as exception_install
from lifespan import lifespan
app = FastAPI(lifespan=lifespan)
exception_install(app)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app=app, port=8000)

26
pyproject.toml Normal file
View File

@@ -0,0 +1,26 @@
[project]
name = "wxcom-wnzs-adapter"
version = "0.1.0"
description = "企业微信万能助手适配器"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"casbin>=1.43.0",
"fastapi[standard]>=0.116.1",
"fastapi-utils>=0.8.0",
"motor>=3.7.1",
"pyjwt>=2.10.1",
"pyminio>=0.3.1",
"pytest>=8.4.1",
"python-dotenv>=1.1.1",
"sqlmodel>=0.0.24",
"uvicorn>=0.35.0",
"apscheduler>=3.11.0",
"pydantic<2.10",
"pickledb>=1.3.2",
"wecom-sdk>=1.0.0",
"xmltodict>=1.0.2",
]
[dependency-groups]
dev = []

44
router/__init__.py Normal file
View File

@@ -0,0 +1,44 @@
import importlib
import pkgutil
from pathlib import Path
from fastapi import APIRouter
from uvicorn.server import logger
# 定义一个 APIRouter 实例
router = APIRouter()
# 初始化 __all__ 列表,用于声明模块公开的对象
__all__ = []
# 获取当前文件所在目录
current_dir = Path(__file__).parent
# 定义可能的路由变量名列表
ROUTER_VARIABLE_NAMES = ["router", "routes", "api_router"]
# 遍历当前目录下的所有 .py 文件
for module_info in pkgutil.iter_modules([str(current_dir)]):
if module_info.name != Path(__file__).stem: # 排除当前文件
module = importlib.import_module(f".{module_info.name}", package=__package__)
found_router = False
# 遍历可能的路由变量名
for var_name in ROUTER_VARIABLE_NAMES:
if hasattr(module, var_name):
module_router: APIRouter = getattr(module, var_name)
api_number = len(module_router.routes)
logger.info(
f"[导入路由] 已导入模块: {module_info.name}.{var_name} 共计{api_number}个接口 ✅"
)
# 自动创建别名,使用模块名和变量名组合作为别名
alias_name = f"{module_info.name}_{var_name}"
globals()[alias_name] = module_router
# 包含模块的 router 到主 router 中
router.include_router(module_router, tags=[f"{alias_name} api"])
# 将别名添加到 __all__ 列表中
__all__.append(alias_name)
found_router = True
if not found_router:
logger.error(
f"[导入路由] 未导入模块:{module_info.name},未找到以下任一变量: {', '.join(ROUTER_VARIABLE_NAMES)} "
)

48
router/auth.py Normal file
View File

@@ -0,0 +1,48 @@
import json
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse
from uvicorn.server import logger
from utils.wxcom import wxcpt
from utils.wxcom import (
decrypt_message,
get_request_params
)
router = APIRouter()
@router.get("/callback")
async def verify_url(msg_signature: str, timestamp: str, nonce: str, echostr: str):
"""验证URL有效性"""
try:
logger.info(
f"收到验证请求: msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}"
)
ret, sEchoStr = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret == 0:
logger.info("URL验证成功")
return PlainTextResponse(content=sEchoStr)
else:
logger.error(f"URL验证失败错误码: {ret}")
raise HTTPException(status_code=400, detail="验证失败")
except Exception as e:
logger.error(f"验证过程发生错误: {str(e)}")
raise HTTPException(status_code=500, detail="服务器内部错误")
@router.post("/callback")
async def receive_message(request: Request):
"""接收并处理企业微信消息"""
try:
# 获取请求参数并验证,返回请求体、消息签名、时间戳和随机数
body, msg_signature, timestamp, nonce = await get_request_params(request)
# 对请求体进行解密,得到解密后的消息字典
xml_dict : dict= decrypt_message(body, msg_signature, timestamp, nonce)
logger.info(f"解密后的消息字典: \n {json.dumps(xml_dict.get("xml") , ensure_ascii=False , indent=2)}")
# 处理消息
# subscription(xml_dict)
except Exception as e:
logger.error(f"处理消息时发生错误: {str(e)}")
raise HTTPException(status_code=500, detail="服务器内部错误")

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python
# -*- encoding:utf-8 -*-
"""对企业微信发送给企业后台的消息加解密示例代码.
@copyright: Copyright (c) 1998-2014 Tencent Inc.
"""
import base64
import hashlib
# ------------------------------------------------------------------------
import logging
import random
import socket
import struct
import time
import xml.etree.cElementTree as ET
from Crypto.Cipher import AES
from uvicorn.server import logger
from utils.wxcom import ierror
"""
Crypto.Cipher包已不再维护开发者可以通过以下命令下载安装最新版的加解密工具包
pip install pycryptodome
"""
class FormatException(Exception):
pass
def throw_exception(message, exception_class=FormatException):
"""my define raise exception function"""
raise exception_class(message)
class SHA1:
"""计算企业微信的消息签名接口"""
def getSHA1(self, token, timestamp, nonce, encrypt):
"""用SHA1算法生成安全签名
@param token: 票据
@param timestamp: 时间戳
@param encrypt: 密文
@param nonce: 随机字符串
@return: 安全签名
"""
try:
sortlist = [token, timestamp, nonce, encrypt]
sortlist.sort()
sha = hashlib.sha1()
sha.update("".join(sortlist).encode())
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
class XMLParse:
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
# xml消息模板
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
<TimeStamp>%(timestamp)s</TimeStamp>
<Nonce><![CDATA[%(nonce)s]]></Nonce>
</xml>"""
def extract(self, xmltext):
"""提取出xml数据包中的加密消息
@param xmltext: 待提取的xml字符串
@return: 提取出的加密消息字符串
"""
try:
xml_tree = ET.fromstring(xmltext)
encrypt = xml_tree.find("Encrypt")
return ierror.WXBizMsgCrypt_OK, encrypt.text
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return ierror.WXBizMsgCrypt_ParseXml_Error, None
def generate(self, encrypt, signature, timestamp, nonce):
"""生成xml消息
@param encrypt: 加密后的消息密文
@param signature: 安全签名
@param timestamp: 时间戳
@param nonce: 随机字符串
@return: 生成的xml字符串
"""
resp_dict = {
"msg_encrypt": encrypt,
"msg_signaturet": signature,
"timestamp": timestamp,
"nonce": nonce,
}
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
return resp_xml
class PKCS7Encoder:
"""提供基于PKCS7算法的加解密接口"""
block_size = 32
def encode(self, text):
"""对需要加密的明文进行填充补位
@param text: 需要进行填充补位操作的明文
@return: 补齐明文字符串
"""
text_length = len(text)
# 计算需要填充的位数
amount_to_pad = self.block_size - (text_length % self.block_size)
if amount_to_pad == 0:
amount_to_pad = self.block_size
# 获得补位所用的字符
pad = chr(amount_to_pad)
return text + (pad * amount_to_pad).encode()
def decode(self, decrypted):
"""删除解密后明文的补位字符
@param decrypted: 解密后的明文
@return: 删除补位字符后的明文
"""
pad = ord(decrypted[-1])
if pad < 1 or pad > 32:
pad = 0
return decrypted[:-pad]
class Prpcrypt(object):
"""提供接收和推送给企业微信消息的加解密接口"""
def __init__(self, key):
# self.key = base64.b64decode(key+"=")
self.key = key
# 设置加解密模式为AES的CBC模式
self.mode = AES.MODE_CBC
def encrypt(self, text, receiveid):
"""对明文进行加密
@param text: 需要加密的明文
@return: 加密得到的字符串
"""
# 16位随机字符串添加到明文开头
text = text.encode()
text = (
self.get_random_str()
+ struct.pack("I", socket.htonl(len(text)))
+ text
+ receiveid.encode()
)
# 使用自定义的填充方式对明文进行补位填充
pkcs7 = PKCS7Encoder()
text = pkcs7.encode(text)
# 加密
cryptor = AES.new(self.key, self.mode, self.key[:16])
try:
ciphertext = cryptor.encrypt(text)
# 使用BASE64对加密后的字符串进行编码
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
def decrypt(self, text, receiveid):
"""对解密后的明文进行补位删除
@param text: 密文
@return: 删除填充补位后的明文
"""
try:
cryptor = AES.new(self.key, self.mode, self.key[:16])
# 使用BASE64对密文进行解码然后AES-CBC解密
plain_text = cryptor.decrypt(base64.b64decode(text))
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return ierror.WXBizMsgCrypt_DecryptAES_Error, None
try:
pad = plain_text[-1]
# 去掉补位字符串
# pkcs7 = PKCS7Encoder()
# plain_text = pkcs7.encode(plain_text)
# 去除16位随机字符串
content = plain_text[16:-pad]
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
xml_content = content[4 : xml_len + 4]
from_receiveid = content[xml_len + 4 :]
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return ierror.WXBizMsgCrypt_IllegalBuffer, None
if from_receiveid.decode("utf8") != receiveid:
return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None
return 0, xml_content
def get_random_str(self):
"""随机生成16位字符串
@return: 16位字符串
"""
return str(random.randint(1000000000000000, 9999999999999999)).encode()
class WXBizMsgCrypt(object):
# 构造函数
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
try:
self.key = base64.b64decode(sEncodingAESKey + "=")
assert len(self.key) == 32
except Exception as e:
logger.error(f"EncodingAESKey unvalid ! Error: {e}")
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
self.m_sToken = sToken
self.m_sReceiveId = sReceiveId
# 验证URL
# @param sMsgSignature: 签名串对应URL参数的msg_signature
# @param sTimeStamp: 时间戳对应URL参数的timestamp
# @param sNonce: 随机串对应URL参数的nonce
# @param sEchoStr: 随机串对应URL参数的echostr
# @param sReplyEchoStr: 解密之后的echostr当return返回0时有效
# @return成功0失败返回对应的错误码
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
sha1 = SHA1()
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
if ret != 0:
return ret, None
if not signature == sMsgSignature:
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
pc = Prpcrypt(self.key)
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
return ret, sReplyEchoStr
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
# 将企业回复用户的消息加密打包
# @param sReplyMsg: 企业号待回复用户的消息xml格式的字符串
# @param sTimeStamp: 时间戳可以自己生成也可以用URL参数的timestamp,如为None则自动用当前时间
# @param sNonce: 随机串可以自己生成也可以用URL参数的nonce
# sEncryptMsg: 加密后的可以直接回复用户的密文包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
# return成功0sEncryptMsg,失败返回对应的错误码None
pc = Prpcrypt(self.key)
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
encrypt = encrypt.decode("utf8")
if ret != 0:
return ret, None
if timestamp is None:
timestamp = str(int(time.time()))
# 生成安全签名
sha1 = SHA1()
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
if ret != 0:
return ret, None
xmlParse = XMLParse()
return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
# 检验消息的真实性,并且获取解密后的明文
# @param sMsgSignature: 签名串对应URL参数的msg_signature
# @param sTimeStamp: 时间戳对应URL参数的timestamp
# @param sNonce: 随机串对应URL参数的nonce
# @param sPostData: 密文对应POST请求的数据
# xml_content: 解密后的原文当return返回0时有效
# @return: 成功0失败返回对应的错误码
# 验证安全签名
xmlParse = XMLParse()
ret, encrypt = xmlParse.extract(sPostData)
if ret != 0:
return ret, None
sha1 = SHA1()
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
if ret != 0:
return ret, None
if not signature == sMsgSignature:
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
pc = Prpcrypt(self.key)
ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)
return ret, xml_content

10
utils/wxcom/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
from .wx_com import wecom_service,wxcpt
from .wx_utils import get_request_params,decrypt_message,extract_message_content
__all__ = [
"wecom_service",
"wxcpt",
"get_request_params",
"decrypt_message",
"extract_message_content"
]

20
utils/wxcom/ierror.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#########################################################################
# Author: jonyqin
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
# File Name: ierror.py
# Description:定义错误码含义
#########################################################################
WXBizMsgCrypt_OK = 0
WXBizMsgCrypt_ValidateSignature_Error = -40001
WXBizMsgCrypt_ParseXml_Error = -40002
WXBizMsgCrypt_ComputeSignature_Error = -40003
WXBizMsgCrypt_IllegalAesKey = -40004
WXBizMsgCrypt_ValidateCorpid_Error = -40005
WXBizMsgCrypt_EncryptAES_Error = -40006
WXBizMsgCrypt_DecryptAES_Error = -40007
WXBizMsgCrypt_IllegalBuffer = -40008
WXBizMsgCrypt_EncodeBase64_Error = -40009
WXBizMsgCrypt_DecodeBase64_Error = -40010
WXBizMsgCrypt_GenReturnXml_Error = -40011

View File

@@ -0,0 +1,41 @@
from wecom_sdk.exceptions.general import SDKException
from wecom_sdk.modules.base import WecomBaseClient
from wecom_sdk.utils.requests import HttpxRequest
class WecomContactClient(WecomBaseClient):
async def get_contact_list(self , userid: str):
"""
获取联系人列表
@param userid: 用户id
@return: 联系人列表
"""
url = self.BASE_URL + "/externalcontact/list"
params = {"access_token": await self.access_token , "userid": userid}
resp = await HttpxRequest.post(url=url, params=params)
if resp.errcode == 0:
return resp.external_contact_list
else:
raise SDKException(resp.errcode, resp.errmsg)
async def get_contact_detail(self , external_userid: str , cursor : None | str = None):
"""
获取联系人详情
@param userid: 用户id
@param external_userid: 外部联系人id
@param cursor: 分页游标
@return: 联系人详情
"""
url = self.BASE_URL + "/externalcontact/get"
params = {"access_token": await self.access_token , "external_userid": external_userid }
params.update({"cursor": cursor} if cursor else {})
resp = await HttpxRequest.post(url=url, params=params)
if resp.get("errcode") == 0:
return resp.get("external_contact" , {})
else:
raise SDKException(resp.errcode, resp.errmsg)

View File

@@ -0,0 +1,66 @@
from wecom_sdk.schemas.base import BaseSchema
from typing import List, Optional
class TextAttr(BaseSchema):
value: str
class WebAttr(BaseSchema):
url: str
title: str
class MiniProgramAttr(BaseSchema):
appid: str
pagepath: str
title: str
class ExternalAttr(BaseSchema):
type: int
name: str
text: Optional[TextAttr] = None
web: Optional[WebAttr] = None
miniprogram: Optional[MiniProgramAttr] = None
class ExternalProfile(BaseSchema):
external_attr: List[ExternalAttr]
class ExternalContact(BaseSchema):
external_userid: str
name: str
position: Optional[str] = None
avatar: Optional[str] = None
corp_name: Optional[str] = None
corp_full_name: Optional[str] = None
type: int
gender: int
unionid: Optional[str] = None
external_profile: Optional[ExternalProfile] = None
class Tag(BaseSchema):
group_name: str
tag_name: str
tag_id: Optional[str] = None
type: int
class WechatChannels(BaseSchema):
nickname: str
source: int
class FollowUser(BaseSchema):
userid: str
remark: Optional[str] = None
description: Optional[str] = None
createtime: int
tags: Optional[List[Tag]] = None
remark_corp_name: Optional[str] = None
remark_mobiles: Optional[List[str]] = None
oper_userid: str
add_way: int
state: Optional[str] = None
wechat_channels: Optional[WechatChannels] = None
class ContactResponse(BaseSchema):
errcode: int
errmsg: str
external_contact: ExternalContact
follow_user: List[FollowUser]
next_cursor: Optional[str] = None

65
utils/wxcom/wx_com.py Normal file
View File

@@ -0,0 +1,65 @@
from uvicorn.server import logger
from wecom_sdk import Wecom
from utils.wxcom.modules.contact import WecomContactClient
from config import setting
from .WXBizMsgCrypt3 import WXBizMsgCrypt
class WecomPro(Wecom , WecomContactClient):
pass
def get_wxcpt():
"""
初始化并返回 WXBizMsgCrypt 实例
:param setting_env: 配置环境对象,包含企业微信相关配置
:return: WXBizMsgCrypt 实例
"""
try:
# 验证企业微信配置是否完整
required_configs = [
setting.env.WECOM_APP_TOKEN,
setting.env.WECOM_APP_ENCODING_AES_KEY,
setting.env.WECOM_CORPID
]
if not all(required_configs):
raise ValueError("企业微信配置不完整")
return WXBizMsgCrypt(
setting.env.WECOM_APP_TOKEN, # 设置的Token
setting.env.WECOM_APP_ENCODING_AES_KEY, # 设置密钥
setting.env.WECOM_CORPID # 企业ID
)
except Exception as e:
logger.error(f"初始化WXBizMsgCrypt失败: {str(e)}")
raise
def get_wecom_service():
"""
初始化并返回 Wecom 服务实例
:param setting_env: 配置环境对象,包含企业微信相关配置
:return: Wecom 服务实例
"""
try:
# 验证企业微信配置是否完整
required_configs = [
setting.env.WECOM_CORPID,
setting.env.WECOM_CORPSECRET
]
if not all(required_configs):
raise ValueError("企业微信配置不完整")
return WecomPro(
corpid=setting.env.WECOM_CORPID,
corpsecret=setting.env.WECOM_CORPSECRET
)
except Exception as e:
logger.error(f"初始化Wecom服务失败: {str(e)}")
raise
wecom_service = get_wecom_service()
wxcpt = get_wxcpt()

94
utils/wxcom/wx_utils.py Normal file
View File

@@ -0,0 +1,94 @@
import time
from typing import Dict, Tuple, Union
import xmltodict
from fastapi import HTTPException, Request
from uvicorn.server import logger
from .wx_com import wxcpt
async def get_request_params(request: Request) -> Tuple[bytes, str, str, str]:
"""获取请求参数并验证"""
body = await request.body()
msg_signature = request.query_params.get("msg_signature")
timestamp = request.query_params.get("timestamp")
nonce = request.query_params.get("nonce")
if not all([msg_signature, timestamp, nonce]):
raise HTTPException(status_code=400, detail="缺少必要的参数")
# logger.info(
# f"收到消息推送: msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}"
# )
return body, msg_signature, timestamp, nonce
def decrypt_message(body: bytes, msg_signature: str, timestamp: str, nonce: str) -> dict:
"""解密消息"""
ret, sMsg = wxcpt.DecryptMsg(body, msg_signature, timestamp, nonce)
if ret != 0:
logger.error(f"消息解密失败,错误码: {ret}")
raise HTTPException(status_code=400, detail="消息解密失败")
xml_dict = xmltodict.parse(sMsg)
# logger.info(f"解密后的消息内容: {xml_dict}")
return xml_dict
def extract_message_content(
xml_dict: Dict,
) -> Tuple[str, str, str, str, Union[Dict[str, Union[str, None]], str, None], str, str]:
"""提取消息内容,支持多种消息类型"""
xml_content = xml_dict["xml"]
to_user_name = xml_content.get("ToUserName")
from_user_name = xml_content.get("FromUserName")
create_time = xml_content.get("CreateTime")
msg_type = xml_content.get("MsgType")
msg_id = xml_content.get("MsgId")
agent_id = xml_content.get("AgentID")
message_data = {}
if msg_type == "text":
message_data["Content"] = xml_content.get("Content")
logger.info(f"收到文本消息: {message_data['Content']}")
elif msg_type == "image":
message_data["PicUrl"] = xml_content.get("PicUrl")
message_data["MediaId"] = xml_content.get("MediaId")
logger.info(f"收到图片消息媒体ID: {message_data['MediaId']}")
elif msg_type == "voice":
message_data["MediaId"] = xml_content.get("MediaId")
message_data["Format"] = xml_content.get("Format")
logger.info(f"收到语音消息媒体ID: {message_data['MediaId']}")
elif msg_type == "video":
message_data["MediaId"] = xml_content.get("MediaId")
message_data["ThumbMediaId"] = xml_content.get("ThumbMediaId")
logger.info(f"收到视频消息媒体ID: {message_data['MediaId']}")
elif msg_type == "location":
message_data["Location_X"] = xml_content.get("Location_X")
message_data["Location_Y"] = xml_content.get("Location_Y")
message_data["Scale"] = xml_content.get("Scale")
message_data["Label"] = xml_content.get("Label")
message_data["AppType"] = xml_content.get("AppType")
logger.info(
f"收到位置消息,位置: {message_data['Location_X']}, {message_data['Location_Y']}"
)
elif msg_type == "link":
message_data["Title"] = xml_content.get("Title")
message_data["Description"] = xml_content.get("Description")
message_data["Url"] = xml_content.get("Url")
message_data["PicUrl"] = xml_content.get("PicUrl")
logger.info(f"收到链接消息,标题: {message_data['Title']}")
else:
message_data = xml_content.get("Content")
logger.info(f"收到未知类型消息: {message_data}")
return {
"ToUserName": to_user_name,
"FromUserName": from_user_name,
"CreateTime": create_time,
"MsgType": msg_type,
"MsgId": msg_id,
"AgentID": agent_id,
**message_data,
}

1310
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff