625 lines
24 KiB
Python
625 lines
24 KiB
Python
import requests
|
|
import json
|
|
import xml.etree.ElementTree as ET
|
|
import hashlib
|
|
import time
|
|
import random
|
|
import string
|
|
from config import Config
|
|
import logging
|
|
from Crypto.Cipher import AES
|
|
import base64
|
|
import socket
|
|
import struct
|
|
import urllib.parse
|
|
import redis
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class WeChatService:
|
|
def __init__(self):
|
|
self.corpid = Config.WECHAT_CORPID
|
|
self.corpsecret = Config.WECHAT_CORPSECRET
|
|
self.agentid = Config.WECHAT_AGENTID
|
|
self.token = Config.WECHAT_TOKEN
|
|
self.encoding_aes_key = Config.WECHAT_ENCODING_AES_KEY
|
|
self.access_token = None
|
|
self.token_expires_at = 0
|
|
# 添加代理API配置
|
|
self.use_proxy = getattr(Config, 'USE_WECHAT_PROXY', False)
|
|
self.proxy_api_url = getattr(Config, 'WECHAT_PROXY_API_URL', 'https://api.v6ole.top')
|
|
|
|
logger.info("WeChatService初始化完成")
|
|
|
|
def _get_redis_connection(self):
|
|
"""获取Redis连接"""
|
|
try:
|
|
redis_conn = redis.Redis(
|
|
host=Config.REDIS_HOST,
|
|
port=Config.REDIS_PORT,
|
|
db=Config.REDIS_DB,
|
|
password=Config.REDIS_PASSWORD,
|
|
decode_responses=True, # 自动将响应解码为字符串
|
|
socket_timeout=5, # 设置超时时间
|
|
socket_connect_timeout=5
|
|
)
|
|
# 测试连接
|
|
redis_conn.ping()
|
|
return redis_conn
|
|
except Exception as e:
|
|
logger.error(f"Redis连接失败: {str(e)}")
|
|
raise
|
|
|
|
def get_access_token(self):
|
|
"""获取access_token"""
|
|
current_time = time.time()
|
|
|
|
# 如果access_token未过期,直接返回
|
|
if self.access_token and current_time < self.token_expires_at:
|
|
return self.access_token
|
|
|
|
try:
|
|
# 使用代理API获取access_token
|
|
if self.use_proxy:
|
|
url = f"{self.proxy_api_url}/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
|
logger.info(f"使用代理API获取access_token: {url}")
|
|
else:
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
|
|
|
response = requests.get(url)
|
|
result = response.json()
|
|
|
|
if result.get("errcode") == 0:
|
|
self.access_token = result.get("access_token")
|
|
# 设置过期时间,提前5分钟过期
|
|
expires_in = result.get("expires_in", 7200) - 300
|
|
self.token_expires_at = current_time + expires_in
|
|
|
|
# 转换过期时间为可读格式
|
|
expiry_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.token_expires_at))
|
|
logger.info(f"成功获取access_token,过期时间: {expiry_time}")
|
|
return self.access_token
|
|
elif result.get("errcode") == 60020 and not self.use_proxy:
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
logger.info("IP受限,尝试使用代理API获取access_token")
|
|
self.use_proxy = True
|
|
return self.get_access_token()
|
|
else:
|
|
logger.error(f"获取access_token失败: {result}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"获取access_token异常: {str(e)}")
|
|
return None
|
|
|
|
def verify_url(self, signature, timestamp, nonce, echostr):
|
|
"""验证URL有效性"""
|
|
try:
|
|
# URL解码echostr
|
|
echostr = urllib.parse.unquote(echostr)
|
|
|
|
# 1. 将token、timestamp、nonce、echostr四个参数进行字典序排序
|
|
temp_list = [self.token, timestamp, nonce, echostr]
|
|
temp_list.sort()
|
|
|
|
# 2. 将四个参数字符串拼接成一个字符串进行sha1加密
|
|
temp_str = ''.join(temp_list)
|
|
hash_obj = hashlib.sha1(temp_str.encode('utf-8'))
|
|
hash_str = hash_obj.hexdigest()
|
|
|
|
# 3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
|
|
if hash_str == signature:
|
|
# 如果验证成功,需要解密echostr
|
|
if self.encoding_aes_key:
|
|
return self.decrypt_echostr(echostr)
|
|
return echostr
|
|
else:
|
|
logger.error(f"URL验证失败: signature={signature}, hash_str={hash_str}")
|
|
return "URL验证失败"
|
|
except Exception as e:
|
|
logger.error(f"URL验证异常: {str(e)}")
|
|
return "URL验证异常"
|
|
|
|
def decrypt_echostr(self, echostr):
|
|
"""解密echostr"""
|
|
try:
|
|
# 1. 对密文进行base64解码
|
|
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
|
encrypted = base64.b64decode(echostr)
|
|
|
|
# 2. 使用AES解密
|
|
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
|
decrypted = cipher.decrypt(encrypted)
|
|
|
|
# 3. 去除补位字符
|
|
unpad = lambda s: s[:-ord(s[len(s)-1:])]
|
|
decrypted = unpad(decrypted)
|
|
|
|
# 4. 去除16位随机字符串
|
|
content = decrypted[16:]
|
|
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
|
xml_content = content[4:xml_len+4]
|
|
|
|
# 5. 验证企业ID
|
|
received_id = content[xml_len+4:].decode('utf-8')
|
|
if received_id != self.corpid:
|
|
logger.error(f"企业ID验证失败: received={received_id}, expected={self.corpid}")
|
|
return "企业ID验证失败"
|
|
|
|
return xml_content.decode('utf-8')
|
|
except Exception as e:
|
|
logger.error(f"解密echostr失败: {str(e)}")
|
|
return "解密失败"
|
|
|
|
def parse_message(self, xml_data):
|
|
"""解析接收到的XML消息"""
|
|
try:
|
|
root = ET.fromstring(xml_data)
|
|
msg = {}
|
|
for child in root:
|
|
msg[child.tag] = child.text
|
|
|
|
# 如果消息是加密的,需要解密
|
|
if 'Encrypt' in msg:
|
|
logger.info("消息已加密,开始解密")
|
|
decrypted = self.decrypt_message(msg['Encrypt'])
|
|
logger.info(f"解密后的消息: {decrypted}")
|
|
# 解析解密后的XML
|
|
decrypted_root = ET.fromstring(decrypted)
|
|
msg = {}
|
|
for child in decrypted_root:
|
|
msg[child.tag] = child.text
|
|
|
|
logger.info(f"最终解析的消息: {msg}")
|
|
return msg
|
|
except Exception as e:
|
|
logger.error(f"解析消息失败: {str(e)}")
|
|
return None
|
|
|
|
def decrypt_message(self, encrypted_msg):
|
|
"""解密消息"""
|
|
try:
|
|
# 1. 对密文进行base64解码
|
|
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
|
encrypted = base64.b64decode(encrypted_msg)
|
|
|
|
# 2. 使用AES解密
|
|
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
|
decrypted = cipher.decrypt(encrypted)
|
|
|
|
# 3. 去除补位字符
|
|
unpad = lambda s: s[:-ord(s[len(s)-1:])]
|
|
decrypted = unpad(decrypted)
|
|
|
|
# 4. 去除16位随机字符串
|
|
content = decrypted[16:]
|
|
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
|
xml_content = content[4:xml_len+4]
|
|
|
|
# 5. 验证企业ID
|
|
received_id = content[xml_len+4:].decode('utf-8')
|
|
if received_id != self.corpid:
|
|
logger.error(f"企业ID验证失败: received={received_id}, expected={self.corpid}")
|
|
raise Exception("企业ID验证失败")
|
|
|
|
return xml_content.decode('utf-8')
|
|
except Exception as e:
|
|
logger.error(f"解密消息失败: {str(e)}")
|
|
raise
|
|
|
|
def send_text_message(self, content, to_user='@all', to_party='', to_tag=''):
|
|
"""发送文本消息"""
|
|
max_retries = 3
|
|
retry_count = 0
|
|
|
|
while retry_count < max_retries:
|
|
try:
|
|
access_token = self.get_access_token()
|
|
|
|
# 构建消息数据
|
|
data = {
|
|
"touser": to_user,
|
|
"toparty": to_party,
|
|
"totag": to_tag,
|
|
"msgtype": "text",
|
|
"agentid": self.agentid,
|
|
"text": {
|
|
"content": content
|
|
}
|
|
}
|
|
|
|
logger.info(f"发送文本消息: {data}")
|
|
|
|
# 使用代理API发送消息
|
|
if self.use_proxy:
|
|
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
|
logger.info(f"使用代理API发送消息: {url}")
|
|
else:
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
|
|
|
response = requests.post(url, json=data)
|
|
result = response.json()
|
|
logger.info(f"发送文本消息响应: {result}")
|
|
|
|
# 如果token过期,重新获取并重试
|
|
if result.get('errcode') == 40014:
|
|
logger.info("access_token过期,重新获取")
|
|
self.access_token = None
|
|
self.token_expires_at = 0
|
|
retry_count += 1
|
|
continue
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API发送")
|
|
self.use_proxy = True
|
|
retry_count += 1
|
|
continue
|
|
|
|
# 如果是其他错误,记录并返回
|
|
if result.get('errcode') != 0:
|
|
logger.error(f"发送消息失败: {result.get('errmsg')}")
|
|
if result.get('errcode') == 301002: # 应用ID不匹配
|
|
logger.error("应用ID不匹配,请检查配置")
|
|
break
|
|
retry_count += 1
|
|
continue
|
|
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"发送文本消息失败: {str(e)}")
|
|
retry_count += 1
|
|
if retry_count >= max_retries:
|
|
raise
|
|
time.sleep(1) # 等待1秒后重试
|
|
|
|
return {"errcode": -1, "errmsg": "发送消息失败,已达到最大重试次数"}
|
|
|
|
def send_markdown_message(self, content, to_user='@all', to_party='', to_tag=''):
|
|
"""发送markdown消息"""
|
|
try:
|
|
access_token = self.get_access_token()
|
|
|
|
data = {
|
|
"touser": to_user,
|
|
"toparty": to_party,
|
|
"totag": to_tag,
|
|
"msgtype": "markdown",
|
|
"agentid": self.agentid,
|
|
"markdown": {
|
|
"content": content
|
|
}
|
|
}
|
|
|
|
logger.info(f"发送markdown消息: {data}")
|
|
|
|
# 使用代理API发送消息
|
|
if self.use_proxy:
|
|
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
|
logger.info(f"使用代理API发送消息: {url}")
|
|
else:
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
|
|
|
response = requests.post(url, json=data)
|
|
result = response.json()
|
|
logger.info(f"发送markdown消息响应: {result}")
|
|
|
|
# 如果token过期,重新获取并重试
|
|
if result.get('errcode') == 40014:
|
|
logger.info("access_token过期,重新获取")
|
|
self.access_token = None
|
|
self.token_expires_at = 0
|
|
return self.send_markdown_message(content, to_user, to_party, to_tag)
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API发送")
|
|
self.use_proxy = True
|
|
return self.send_markdown_message(content, to_user, to_party, to_tag)
|
|
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"发送markdown消息失败: {str(e)}")
|
|
raise
|
|
|
|
def get_user_info(self, userid):
|
|
"""获取用户信息"""
|
|
try:
|
|
access_token = self.get_access_token()
|
|
|
|
# 使用代理API获取用户信息
|
|
if self.use_proxy:
|
|
url = f"{self.proxy_api_url}/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
|
logger.info(f"使用代理API获取用户信息: {url}")
|
|
else:
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
|
|
|
logger.info(f"获取用户信息: {url}")
|
|
response = requests.get(url)
|
|
result = response.json()
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API获取用户信息")
|
|
self.use_proxy = True
|
|
return self.get_user_info(userid)
|
|
|
|
logger.info(f"获取用户信息响应: {result}")
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"获取用户信息失败: {str(e)}")
|
|
return {"errcode": -1, "errmsg": str(e)}
|
|
|
|
def generate_temp_token(self, user_id, user_name=None):
|
|
"""生成临时访问令牌"""
|
|
try:
|
|
# 生成随机令牌
|
|
token = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
|
|
|
|
# 存储令牌信息到Redis
|
|
token_data = {
|
|
'user_id': user_id,
|
|
'name': user_name or '微信用户',
|
|
'created_at': int(time.time())
|
|
}
|
|
|
|
# 使用Redis存储令牌,设置过期时间
|
|
key = f"temp_token:{token}"
|
|
redis_conn = self._get_redis_connection()
|
|
redis_conn.setex(
|
|
key,
|
|
Config.REDIS_TEMP_TOKEN_EXPIRE,
|
|
json.dumps(token_data)
|
|
)
|
|
|
|
# 验证令牌是否成功存储
|
|
stored_data = redis_conn.get(key)
|
|
if not stored_data:
|
|
logger.error("令牌存储失败")
|
|
return None
|
|
|
|
logger.info(f"成功生成临时令牌: {token}, 存储数据: {stored_data}")
|
|
return token
|
|
|
|
except Exception as e:
|
|
logger.error(f"生成临时令牌失败: {str(e)}")
|
|
return None
|
|
|
|
def verify_temp_token(self, token):
|
|
"""验证临时访问令牌"""
|
|
try:
|
|
# 从Redis中获取令牌信息
|
|
key = f"temp_token:{token}"
|
|
redis_conn = self._get_redis_connection()
|
|
token_info = redis_conn.get(key)
|
|
|
|
if not token_info:
|
|
logger.error(f"临时令牌不存在或已过期: {token}")
|
|
return None
|
|
|
|
# 解析令牌信息
|
|
token_data = json.loads(token_info)
|
|
user_id = token_data.get('user_id')
|
|
|
|
if not user_id:
|
|
logger.error(f"临时令牌中未找到用户ID: {token_info}")
|
|
return None
|
|
|
|
logger.info(f"临时令牌验证成功: {token}, 用户信息: {token_data}")
|
|
return token_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"验证临时令牌失败: {str(e)}")
|
|
return None
|
|
|
|
def send_card_message(self, title, description, url, to_user):
|
|
"""发送卡片消息"""
|
|
try:
|
|
# 获取access_token
|
|
access_token = self.get_access_token()
|
|
if not access_token:
|
|
logger.error("获取access_token失败")
|
|
return None
|
|
|
|
# 构建消息内容
|
|
data = {
|
|
"touser": to_user,
|
|
"toparty": "",
|
|
"totag": "",
|
|
"msgtype": "textcard",
|
|
"agentid": self.agentid,
|
|
"textcard": {
|
|
"title": title,
|
|
"description": description,
|
|
"url": url,
|
|
"btntxt": "查看详情"
|
|
}
|
|
}
|
|
|
|
logger.info(f"发送卡片消息: {data}")
|
|
|
|
# 使用代理API发送消息
|
|
if self.use_proxy:
|
|
api_url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
|
logger.info(f"使用代理API发送消息: {api_url}")
|
|
else:
|
|
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
|
|
|
# 发送消息
|
|
response = requests.post(api_url, json=data)
|
|
result = response.json()
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API发送")
|
|
self.use_proxy = True
|
|
return self.send_card_message(title, description, url, to_user)
|
|
|
|
if result.get('errcode') == 0:
|
|
logger.info(f"发送卡片消息成功: {result}")
|
|
return result
|
|
else:
|
|
logger.error(f"发送卡片消息失败: {result}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"发送卡片消息异常: {str(e)}")
|
|
return None
|
|
|
|
def create_menu(self):
|
|
"""创建应用菜单"""
|
|
try:
|
|
# 获取access_token
|
|
access_token = self.get_access_token()
|
|
if not access_token:
|
|
logger.error("获取access_token失败")
|
|
return None
|
|
|
|
# 菜单配置
|
|
menu_data = {
|
|
"button": [
|
|
{
|
|
"name": "设备查询",
|
|
"sub_button": [
|
|
{
|
|
"type": "click",
|
|
"name": "在线统计",
|
|
"key": "online"
|
|
},
|
|
{
|
|
"type": "click",
|
|
"name": "设备状态",
|
|
"key": "status"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"name": "设备管理",
|
|
"sub_button": [
|
|
{
|
|
"type": "click",
|
|
"name": "业务下发",
|
|
"key": "deploy"
|
|
},
|
|
{
|
|
"type": "view",
|
|
"name": "设备管理",
|
|
"url": f"{Config.BASE_URL}/devices"
|
|
},
|
|
{
|
|
"type": "click",
|
|
"name": "序列修复",
|
|
"key": "sequence_fix"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "click",
|
|
"name": "帮助",
|
|
"key": "help"
|
|
}
|
|
]
|
|
}
|
|
|
|
logger.info(f"创建菜单: {menu_data}")
|
|
|
|
# 使用代理API创建菜单
|
|
if self.use_proxy:
|
|
api_url = f"{self.proxy_api_url}/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
|
logger.info(f"使用代理API创建菜单: {api_url}")
|
|
else:
|
|
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
|
|
|
# 发送请求
|
|
response = requests.post(api_url, json=menu_data)
|
|
result = response.json()
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API创建菜单")
|
|
self.use_proxy = True
|
|
return self.create_menu()
|
|
|
|
if result.get('errcode') == 0:
|
|
logger.info(f"创建菜单成功: {result}")
|
|
return result
|
|
else:
|
|
logger.error(f"创建菜单失败: {result}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"创建菜单异常: {str(e)}")
|
|
return None
|
|
|
|
def delete_menu(self):
|
|
"""删除应用菜单"""
|
|
try:
|
|
# 获取access_token
|
|
access_token = self.get_access_token()
|
|
if not access_token:
|
|
logger.error("获取access_token失败")
|
|
return None
|
|
|
|
# 使用代理API删除菜单
|
|
if self.use_proxy:
|
|
api_url = f"{self.proxy_api_url}/cgi-bin/menu/delete?access_token={access_token}&agentid={self.agentid}"
|
|
logger.info(f"使用代理API删除菜单: {api_url}")
|
|
else:
|
|
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/delete?access_token={access_token}&agentid={self.agentid}"
|
|
|
|
# 发送请求
|
|
response = requests.get(api_url)
|
|
result = response.json()
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API删除菜单")
|
|
self.use_proxy = True
|
|
return self.delete_menu()
|
|
|
|
if result.get('errcode') == 0:
|
|
logger.info(f"删除菜单成功: {result}")
|
|
return result
|
|
else:
|
|
logger.error(f"删除菜单失败: {result}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"删除菜单异常: {str(e)}")
|
|
return None
|
|
|
|
def get_menu(self):
|
|
"""获取应用菜单"""
|
|
try:
|
|
# 获取access_token
|
|
access_token = self.get_access_token()
|
|
if not access_token:
|
|
logger.error("获取access_token失败")
|
|
return None
|
|
|
|
# 使用代理API获取菜单
|
|
if self.use_proxy:
|
|
api_url = f"{self.proxy_api_url}/cgi-bin/menu/get?access_token={access_token}&agentid={self.agentid}"
|
|
logger.info(f"使用代理API获取菜单: {api_url}")
|
|
else:
|
|
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/get?access_token={access_token}&agentid={self.agentid}"
|
|
|
|
# 发送请求
|
|
response = requests.get(api_url)
|
|
result = response.json()
|
|
|
|
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
|
if result.get('errcode') == 60020 and not self.use_proxy:
|
|
logger.info("IP受限,尝试使用代理API获取菜单")
|
|
self.use_proxy = True
|
|
return self.get_menu()
|
|
|
|
if result.get('errcode') == 0:
|
|
logger.info(f"获取菜单成功: {result}")
|
|
return result
|
|
else:
|
|
logger.error(f"获取菜单失败: {result}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取菜单异常: {str(e)}")
|
|
return None |