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