739 lines
31 KiB
Python
739 lines
31 KiB
Python
"""企微服务:消息加解密、异步处理、API 调用。"""
|
||
import hashlib
|
||
import xml.etree.ElementTree as ET
|
||
from threading import Thread, Lock
|
||
import base64
|
||
import struct
|
||
import time
|
||
import requests
|
||
from datetime import datetime
|
||
from sqlalchemy import text
|
||
from insurance.config import get_config as _get_wecom_config
|
||
from insurance.db.compat import db
|
||
from insurance.models.wecom_group import WeComGroupConfig
|
||
|
||
|
||
class WeComService:
|
||
"""企微机器人业务逻辑。"""
|
||
|
||
# access_token缓存(加锁防竞态)
|
||
_access_token = None
|
||
_access_token_expire = 0
|
||
_token_lock = Lock()
|
||
_cache_lock = Lock() # 缓存操作锁
|
||
|
||
# 消息排重缓存:{dedup_key: expire_timestamp}
|
||
_processed_msgs = {}
|
||
_DEDUP_TTL = 300 # 5分钟过期(企微重试3次,间隔较短)
|
||
|
||
# 回调IP白名单缓存
|
||
_callback_ips = None
|
||
_callback_ips_expire = 0
|
||
_IP_CACHE_TTL = 3600 # 1小时刷新一次
|
||
|
||
def _get_aes_key(self) -> str:
|
||
"""兼容不同文档中的企微 EncodingAESKey 配置名。"""
|
||
return _get_wecom_config("WECOM_AES_KEY") or _get_wecom_config("WECOM_ENCODING_AES_KEY")
|
||
|
||
def _get_access_token(self) -> str:
|
||
"""获取企微access_token(带缓存+锁)。"""
|
||
# 快速路径:缓存有效直接返回
|
||
if self._access_token and time.time() < self._access_token_expire:
|
||
return self._access_token
|
||
|
||
with self._token_lock:
|
||
# 双重检查:等锁期间可能已被其他线程刷新
|
||
if self._access_token and time.time() < self._access_token_expire:
|
||
return self._access_token
|
||
|
||
corp_id = _get_wecom_config("WECOM_CORP_ID")
|
||
secret = _get_wecom_config("WECOM_SECRET")
|
||
|
||
token_resp = requests.get(
|
||
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
|
||
params={"corpid": corp_id, "corpsecret": secret},
|
||
timeout=5,
|
||
)
|
||
|
||
resp_data = token_resp.json()
|
||
access_token = resp_data.get("access_token", "")
|
||
expires_in = resp_data.get("expires_in", 7200)
|
||
|
||
if not access_token:
|
||
print(f"[WECOM ERROR] 获取access_token失败: {resp_data}", flush=True)
|
||
return ""
|
||
|
||
# 缓存access_token(提前5分钟过期)
|
||
self._access_token = access_token
|
||
self._access_token_expire = time.time() + expires_in - 300
|
||
|
||
print(f"[WECOM DEBUG] 获取access_token成功,有效期: {expires_in}秒", flush=True)
|
||
return access_token
|
||
|
||
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
|
||
"""企微服务器 URL 验证:SHA1 校验 + 解密 echostr。"""
|
||
token = _get_wecom_config("WECOM_TOKEN")
|
||
aes_key = self._get_aes_key()
|
||
corp_id = _get_wecom_config("WECOM_CORP_ID")
|
||
|
||
# 使用print确保输出到stdout
|
||
print(f"[WECOM DEBUG] msg_signature={msg_signature}", flush=True)
|
||
print(f"[WECOM DEBUG] timestamp={timestamp}, nonce={nonce}", flush=True)
|
||
print(f"[WECOM DEBUG] CorpID={corp_id}", flush=True)
|
||
|
||
# 1. 字典序排序拼接
|
||
sort_list = sorted([token, timestamp, nonce, echostr])
|
||
sha1 = hashlib.sha1("".join(sort_list).encode()).hexdigest()
|
||
print(f"[WECOM DEBUG] 计算签名: {sha1}", flush=True)
|
||
print(f"[WECOM DEBUG] 收到签名: {msg_signature}", flush=True)
|
||
|
||
# 2. 校验签名
|
||
if sha1 != msg_signature:
|
||
print(f"[WECOM ERROR] 签名验证失败!", flush=True)
|
||
return "Invalid signature"
|
||
|
||
print(f"[WECOM DEBUG] 签名验证成功", flush=True)
|
||
|
||
# 3. 解密 echostr
|
||
try:
|
||
decrypted = self._aes_decrypt(aes_key, echostr)
|
||
print(f"[WECOM DEBUG] 解密成功: {decrypted[:100]}", flush=True)
|
||
|
||
# 企微URL验证返回的是明文,直接返回
|
||
print(f"[WECOM DEBUG] 返回echostr: {decrypted}", flush=True)
|
||
return decrypted
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 解密失败: {e}", flush=True)
|
||
return "Invalid echostr"
|
||
|
||
def _is_duplicate(self, dedup_key: str) -> bool:
|
||
"""检查消息是否已处理过(排重,线程安全)。"""
|
||
now = time.time()
|
||
with self._cache_lock:
|
||
# 清理过期条目
|
||
expired = [k for k, v in self._processed_msgs.items() if v < now]
|
||
for k in expired:
|
||
del self._processed_msgs[k]
|
||
# 检查是否重复
|
||
if dedup_key in self._processed_msgs:
|
||
return True
|
||
self._processed_msgs[dedup_key] = now + self._DEDUP_TTL
|
||
return False
|
||
|
||
def _get_callback_ips(self) -> list:
|
||
"""获取企微回调IP白名单(带缓存,线程安全)。"""
|
||
now = time.time()
|
||
with self._cache_lock:
|
||
if self._callback_ips is not None and now < self._callback_ips_expire:
|
||
return self._callback_ips
|
||
|
||
access_token = self._get_access_token()
|
||
if not access_token:
|
||
return self._callback_ips or []
|
||
|
||
try:
|
||
resp = requests.get(
|
||
"https://qyapi.weixin.qq.com/cgi-bin/getcallbackip",
|
||
params={"access_token": access_token},
|
||
timeout=5,
|
||
)
|
||
data = resp.json()
|
||
if data.get("errcode") == 0:
|
||
with self._cache_lock:
|
||
self._callback_ips = data.get("ip_list", [])
|
||
self._callback_ips_expire = now + self._IP_CACHE_TTL
|
||
print(f"[WECOM DEBUG] 获取回调IP白名单成功: {len(self._callback_ips)}条", flush=True)
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 获取回调IP白名单失败: {e}", flush=True)
|
||
|
||
return self._callback_ips or []
|
||
|
||
def is_callback_ip(self, client_ip: str) -> bool:
|
||
"""检查请求IP是否在企微回调白名单中。"""
|
||
ip_list = self._get_callback_ips()
|
||
if not ip_list:
|
||
# 白名单为空(获取失败),放行以免阻断所有请求
|
||
return True
|
||
|
||
for pattern in ip_list:
|
||
# 企微返回格式如 "101.226.103.*",按前缀匹配
|
||
prefix = pattern.rstrip("*").rstrip(".")
|
||
if client_ip.startswith(prefix):
|
||
return True
|
||
|
||
return False
|
||
|
||
def handle_message_for_group(self, msg_signature: str, timestamp: str, nonce: str, encrypted_data: bytes) -> bool:
|
||
"""判断是否为群聊消息,若是则启动异步处理线程。
|
||
|
||
Returns:
|
||
True 表示是群聊(路由应返回 "success" 并异步处理)。
|
||
"""
|
||
try:
|
||
decrypted_str = self._decrypt_message(encrypted_data, msg_signature, timestamp, nonce)
|
||
msg_data = self._parse_message(decrypted_str)
|
||
|
||
if not msg_data or msg_data.get("chattype") != "group":
|
||
return False
|
||
|
||
# 群聊:启动异步处理线程
|
||
print(f"[WECOM DEBUG] 检测到群聊,启动异步处理线程", flush=True)
|
||
from flask import current_app
|
||
flask_app = current_app._get_current_object()
|
||
|
||
def _process_group():
|
||
with flask_app.app_context():
|
||
self._handle_group_message(msg_data)
|
||
|
||
t = Thread(target=_process_group, daemon=True)
|
||
t.start()
|
||
print(f"[WECOM DEBUG] 异步处理线程已启动, is_alive={t.is_alive()}", flush=True)
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 群聊检测失败: {e}", flush=True)
|
||
return False
|
||
|
||
def handle_message(self, msg_signature: str, timestamp: str, nonce: str, encrypted_data: bytes) -> str:
|
||
"""同步处理私聊消息,返回被动回复 XML。"""
|
||
try:
|
||
decrypted_str = self._decrypt_message(encrypted_data, msg_signature, timestamp, nonce)
|
||
msg_data = self._parse_message(decrypted_str)
|
||
if not msg_data:
|
||
return ""
|
||
reply_text = self._call_baodan(msg_data)
|
||
if reply_text:
|
||
return self._build_passive_reply(reply_text, timestamp, nonce)
|
||
return ""
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 消息处理异常: {e}", flush=True)
|
||
return ""
|
||
|
||
def handle_message_for_private(self, msg_signature: str, timestamp: str, nonce: str, encrypted_data: bytes) -> bool:
|
||
"""判断是否为私聊消息,若是则启动异步处理线程。"""
|
||
if _get_wecom_config("WECOM_PRIVATE_ASYNC_REPLY", "true").lower() != "true":
|
||
return False
|
||
|
||
try:
|
||
decrypted_str = self._decrypt_message(encrypted_data, msg_signature, timestamp, nonce)
|
||
msg_data = self._parse_message(decrypted_str)
|
||
|
||
if not msg_data or msg_data.get("chattype") == "group":
|
||
return False
|
||
|
||
print(f"[WECOM DEBUG] 检测到私聊,启动异步处理线程", flush=True)
|
||
from flask import current_app
|
||
flask_app = current_app._get_current_object()
|
||
|
||
def _process_private():
|
||
with flask_app.app_context():
|
||
self._handle_private_message(msg_data)
|
||
|
||
t = Thread(target=_process_private, daemon=True)
|
||
t.start()
|
||
print(f"[WECOM DEBUG] 私聊异步处理线程已启动, is_alive={t.is_alive()}", flush=True)
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 私聊检测失败: {e}", flush=True)
|
||
return False
|
||
|
||
def _parse_message(self, decrypted_str: str) -> dict | None:
|
||
"""解析解密后的消息(JSON 或 XML),返回统一格式 dict。"""
|
||
print(f"[WECOM DEBUG] 解密后消息: {decrypted_str[:200]}", flush=True)
|
||
|
||
try:
|
||
import json
|
||
msg_data = json.loads(decrypted_str)
|
||
msg_type = msg_data.get("msgtype", "")
|
||
from_user = msg_data.get("from", {}).get("userid", "")
|
||
chat_type = msg_data.get("chattype", "")
|
||
chat_id = msg_data.get("chatid", "")
|
||
aibot_id = msg_data.get("aibotid", "")
|
||
response_url = msg_data.get("response_url", "")
|
||
content = msg_data.get("text", {}).get("content", "") if msg_type == "text" else ""
|
||
dedup_key = msg_data.get("msgid", "") or msg_data.get("msg_id", "")
|
||
print(f"[WECOM DEBUG] JSON格式: type={msg_type}, user={from_user}, chat_type={chat_type}, content={content[:50]}", flush=True)
|
||
except (json.JSONDecodeError, AttributeError):
|
||
root = ET.fromstring(decrypted_str)
|
||
msg_type = root.findtext("MsgType", "")
|
||
from_user = root.findtext("FromUserName", "")
|
||
content = root.findtext("Content", "")
|
||
chat_type = ""
|
||
chat_id = ""
|
||
aibot_id = ""
|
||
response_url = ""
|
||
dedup_key = root.findtext("MsgId", "")
|
||
print(f"[WECOM DEBUG] XML格式: type={msg_type}, user={from_user}, content={content[:50]}", flush=True)
|
||
|
||
return {
|
||
"msgtype": msg_type,
|
||
"from_user": from_user,
|
||
"chattype": chat_type,
|
||
"chatid": chat_id,
|
||
"aibotid": aibot_id,
|
||
"response_url": response_url,
|
||
"content": content,
|
||
"dedup_key": dedup_key,
|
||
}
|
||
|
||
def _upsert_group_config(self, msg_data: dict):
|
||
"""自动登记/更新企微群聊配置。"""
|
||
chat_id = msg_data.get("chatid", "")
|
||
if not chat_id:
|
||
return
|
||
if not self._ensure_group_config_table():
|
||
return
|
||
|
||
try:
|
||
group = db.session.query(WeComGroupConfig).filter_by(chatid=chat_id).first()
|
||
now = datetime.utcnow()
|
||
if group:
|
||
group.aibotid = msg_data.get("aibotid") or group.aibotid
|
||
group.last_seen_at = now
|
||
group.updated_at = now
|
||
else:
|
||
group = WeComGroupConfig(
|
||
chatid=chat_id,
|
||
aibotid=msg_data.get("aibotid", ""),
|
||
status="active",
|
||
auto_discovered=True,
|
||
last_seen_at=now,
|
||
)
|
||
db.session.add(group)
|
||
print(f"[WECOM DEBUG] 自动发现新群聊: chatid={chat_id}", flush=True)
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"[WECOM ERROR] 自动登记群聊失败: {e}", flush=True)
|
||
|
||
def _call_baodan(self, msg_data: dict) -> str:
|
||
"""调 BaoDan Chat API 获取回答。"""
|
||
msg_type = msg_data["msgtype"]
|
||
from_user = msg_data["from_user"]
|
||
content = msg_data["content"]
|
||
dedup_key = msg_data["dedup_key"]
|
||
|
||
if dedup_key and self._is_duplicate(dedup_key):
|
||
print(f"[WECOM DEBUG] 重复消息,跳过: {dedup_key}", flush=True)
|
||
return ""
|
||
|
||
if msg_type != "text":
|
||
print(f"[WECOM DEBUG] 非文本消息,回复暂不支持", flush=True)
|
||
return "暂不支持该消息类型,请发送文字消息。"
|
||
|
||
# 去掉群聊中"@机器人"前缀
|
||
if content.startswith("@"):
|
||
content = content.split(" ", 1)[-1] if " " in content else content
|
||
|
||
print(f"[WECOM DEBUG] 处理文本消息: {content}", flush=True)
|
||
|
||
# 与网页端共用同一个 Dify 对话应用。独立部署的标准环境变量是
|
||
# DIFY_CHAT_APP_API_KEY / DIFY_BASE_URL;BAODAN_* 仅用于兼容旧部署。
|
||
api_key = (
|
||
_get_wecom_config("DIFY_CHAT_APP_API_KEY")
|
||
or _get_wecom_config("BAODAN_CHAT_API_KEY")
|
||
)
|
||
base_url = (
|
||
_get_wecom_config("DIFY_BASE_URL")
|
||
or _get_wecom_config("BAODAN_API_URL", "http://baodan-api:5001")
|
||
).rstrip("/")
|
||
|
||
if not api_key:
|
||
print("[WECOM ERROR] 未配置 Dify 对话应用 API Key", flush=True)
|
||
return "抱歉,AI 服务尚未完成配置,请联系管理员。"
|
||
|
||
print(f"[WECOM DEBUG] 调用API: {base_url}/v1/chat-messages", flush=True)
|
||
|
||
user_key = f"wecom_{from_user}"
|
||
if msg_data.get("chattype") == "group" and msg_data.get("chatid"):
|
||
user_key = f"wecom_group_{msg_data['chatid']}_{from_user}"
|
||
|
||
resp = requests.post(
|
||
f"{base_url}/v1/chat-messages",
|
||
json={
|
||
"inputs": {},
|
||
"query": content,
|
||
"response_mode": "blocking",
|
||
"user": user_key,
|
||
"conversation_id": "",
|
||
},
|
||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||
timeout=30,
|
||
)
|
||
|
||
if resp.status_code >= 400:
|
||
print(f"[WECOM ERROR] BaoDan API 调用失败: status={resp.status_code}, body={resp.text[:300]}", flush=True)
|
||
return "抱歉,AI 服务暂时不可用,请稍后再试。"
|
||
|
||
answer = resp.json().get("answer", "抱歉,暂时无法回答您的问题。")
|
||
print(f"[WECOM DEBUG] 回答: {answer[:100]}", flush=True)
|
||
return answer
|
||
|
||
def _handle_group_message(self, msg_data: dict):
|
||
"""异步处理群聊消息:调 BaoDan -> 主动发送到群。"""
|
||
print(f"[WECOM DEBUG] 群聊异步处理开始: user={msg_data.get('from_user')}, chatid={msg_data.get('chatid')}", flush=True)
|
||
try:
|
||
self._upsert_group_config(msg_data)
|
||
answer = self._call_baodan(msg_data)
|
||
print(f"[WECOM DEBUG] 群聊异步处理, 拿到回答: {answer[:100] if answer else '空'}", flush=True)
|
||
if answer:
|
||
chat_id = msg_data.get("chatid", "")
|
||
if chat_id:
|
||
self._send_text_to_group(chat_id, answer, msg_data)
|
||
else:
|
||
print(f"[WECOM ERROR] 群聊异步处理: chatid 为空", flush=True)
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 群聊消息处理失败: {e}", flush=True)
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _handle_private_message(self, msg_data: dict):
|
||
"""异步处理私聊消息:调 BaoDan -> 主动发送给用户。"""
|
||
print(f"[WECOM DEBUG] 私聊异步处理开始: user={msg_data.get('from_user')}", flush=True)
|
||
try:
|
||
answer = self._call_baodan(msg_data)
|
||
print(f"[WECOM DEBUG] 私聊异步处理, 拿到回答: {answer[:100] if answer else '空'}", flush=True)
|
||
if answer:
|
||
response_url = msg_data.get("response_url", "")
|
||
if response_url and self._send_text_to_response_url(response_url, answer, msg_data):
|
||
return
|
||
to_user = msg_data.get("from_user", "")
|
||
if to_user:
|
||
if not self._send_text(to_user, answer):
|
||
print(f"[WECOM ERROR] 私聊主动发送失败: to_user={to_user}", flush=True)
|
||
else:
|
||
print(f"[WECOM ERROR] 私聊异步处理: from_user 为空", flush=True)
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] 私聊消息处理失败: {e}", flush=True)
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _decrypt_message(self, encrypted_data: bytes, msg_signature: str, timestamp: str, nonce: str) -> str:
|
||
"""解密企微消息。"""
|
||
aes_key = self._get_aes_key()
|
||
token = _get_wecom_config("WECOM_TOKEN")
|
||
|
||
# 调试:查看原始数据
|
||
raw_data = encrypted_data.decode("utf-8", errors="replace")
|
||
print(f"[WECOM DEBUG] 原始数据: {raw_data[:200]}", flush=True)
|
||
|
||
# 1. 提取加密的消息体(支持JSON和XML格式)
|
||
encrypt = ""
|
||
try:
|
||
# 尝试JSON格式(企微新版)
|
||
import json
|
||
data = json.loads(raw_data)
|
||
encrypt = data.get("encrypt", "")
|
||
print(f"[WECOM DEBUG] JSON格式,提取到encrypt: {encrypt[:50] if encrypt else 'None'}", flush=True)
|
||
except (json.JSONDecodeError, AttributeError):
|
||
try:
|
||
# 尝试XML格式(企微旧版)
|
||
root = ET.fromstring(raw_data)
|
||
encrypt = root.findtext("Encrypt", "")
|
||
print(f"[WECOM DEBUG] XML格式,提取到Encrypt: {encrypt[:50] if encrypt else 'None'}", flush=True)
|
||
except ET.ParseError as e:
|
||
print(f"[WECOM ERROR] 解析失败: {e}", flush=True)
|
||
# 尝试将整个数据作为加密消息体处理
|
||
encrypt = raw_data
|
||
|
||
if not encrypt:
|
||
raise ValueError("No Encrypt field found")
|
||
|
||
# 2. 验证签名(使用加密的消息体)
|
||
sort_list = sorted([token, timestamp, nonce, encrypt])
|
||
sha1 = hashlib.sha1("".join(sort_list).encode()).hexdigest()
|
||
print(f"[WECOM DEBUG] 签名验证: 计算={sha1}, 收到={msg_signature}", flush=True)
|
||
|
||
if sha1 != msg_signature:
|
||
raise ValueError("Invalid signature")
|
||
|
||
# 3. 解密消息
|
||
decrypted = self._aes_decrypt(aes_key, encrypt)
|
||
print(f"[WECOM DEBUG] 解密后消息: {decrypted[:200]}", flush=True)
|
||
return decrypted
|
||
|
||
def _aes_decrypt(self, key: str, encrypted: str) -> str:
|
||
"""AES-256-CBC 解密企微消息(企微专用格式)。"""
|
||
# 1. Base64 解码密钥(企微密钥是43位,需要补=号)
|
||
aes_key = base64.b64decode(key + "=")
|
||
|
||
# 2. Base64 解码密文
|
||
cipher_text = base64.b64decode(encrypted)
|
||
|
||
# 3. AES-256-CBC 解密(IV = 密钥前16字节)
|
||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||
from cryptography.hazmat.backends import default_backend
|
||
|
||
iv = aes_key[:16]
|
||
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), backend=default_backend())
|
||
decryptor = cipher.decryptor()
|
||
decrypted = decryptor.update(cipher_text) + decryptor.finalize()
|
||
|
||
# 4. 去除 PKCS7 填充
|
||
pad_len = decrypted[-1]
|
||
if isinstance(pad_len, int) and 1 <= pad_len <= 32:
|
||
decrypted = decrypted[:-pad_len]
|
||
|
||
# 5. 提取消息内容(企微格式:随机16字节 + 消息长度4字节 + 消息内容 + CorpID)
|
||
# 消息长度在第16-20字节(大端序)
|
||
msg_len = struct.unpack("!I", decrypted[16:20])[0]
|
||
msg_content = decrypted[20:20 + msg_len].decode("utf-8")
|
||
|
||
return msg_content
|
||
|
||
def _aes_encrypt(self, key: str, reply_msg: str) -> str:
|
||
"""AES-256-CBC 加密企微被动回复消息。"""
|
||
import os
|
||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||
from cryptography.hazmat.backends import default_backend
|
||
|
||
aes_key = base64.b64decode(key + "=")
|
||
iv = aes_key[:16]
|
||
|
||
# 构造明文:随机16字节 + 消息长度4字节 + 消息内容 + CorpID
|
||
corp_id = _get_wecom_config("WECOM_CORP_ID")
|
||
msg_bytes = reply_msg.encode("utf-8")
|
||
corp_id_bytes = corp_id.encode("utf-8")
|
||
random_bytes = os.urandom(16)
|
||
msg_len = struct.pack("!I", len(msg_bytes))
|
||
plain = random_bytes + msg_len + msg_bytes + corp_id_bytes
|
||
|
||
# PKCS7 填充
|
||
block_size = 32
|
||
pad_len = block_size - (len(plain) % block_size)
|
||
plain += bytes([pad_len] * pad_len)
|
||
|
||
# AES-256-CBC 加密
|
||
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), backend=default_backend())
|
||
encryptor = cipher.encryptor()
|
||
encrypted = encryptor.update(plain) + encryptor.finalize()
|
||
|
||
return base64.b64encode(encrypted).decode("utf-8")
|
||
|
||
def _build_passive_reply(self, reply_msg: str, timestamp: str, nonce: str) -> str:
|
||
"""构建企微被动回复的加密 XML 响应。"""
|
||
aes_key = self._get_aes_key()
|
||
token = _get_wecom_config("WECOM_TOKEN")
|
||
|
||
# 1. 加密回复消息
|
||
encrypt = self._aes_encrypt(aes_key, reply_msg)
|
||
|
||
# 2. 生成签名
|
||
sort_list = sorted([token, timestamp, nonce, encrypt])
|
||
msg_signature = hashlib.sha1("".join(sort_list).encode()).hexdigest()
|
||
|
||
# 3. 构造 XML 响应(CreateTime 为必填字段,缺少会导致 WeChat 静默丢弃消息)
|
||
create_time = int(time.time())
|
||
reply_xml = f"""<xml>
|
||
<Encrypt><![CDATA[{encrypt}]]></Encrypt>
|
||
<MsgSignature><![CDATA[{msg_signature}]]></MsgSignature>
|
||
<CreateTime>{create_time}</CreateTime>
|
||
<Nonce><![CDATA[{nonce}]]></Nonce>
|
||
</xml>"""
|
||
return reply_xml
|
||
|
||
def _send_text(self, to_user: str, content: str) -> bool:
|
||
"""通过企微 API 发送文本消息给个人。"""
|
||
import requests
|
||
|
||
access_token = self._get_access_token()
|
||
if not access_token:
|
||
return False
|
||
|
||
agent_id = _get_wecom_config("WECOM_AGENT_ID", "1000002")
|
||
|
||
# 企微消息长度限制 2048 字符
|
||
MAX_LEN = 2000
|
||
chunks = [content[i:i + MAX_LEN] for i in range(0, len(content), MAX_LEN)]
|
||
ok = True
|
||
|
||
for chunk in chunks:
|
||
resp = requests.post(
|
||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}",
|
||
json={
|
||
"touser": to_user,
|
||
"msgtype": "text",
|
||
"agentid": int(agent_id),
|
||
"text": {"content": chunk},
|
||
},
|
||
timeout=10,
|
||
)
|
||
resp_data = resp.json()
|
||
if resp_data.get("errcode") != 0:
|
||
print(f"[WECOM ERROR] 发送消息失败: {resp_data}", flush=True)
|
||
ok = False
|
||
else:
|
||
print(f"[WECOM DEBUG] 发送消息给{to_user}成功", flush=True)
|
||
return ok
|
||
|
||
def _send_text_to_group(self, chat_id: str, content: str, msg_data: dict | None = None):
|
||
"""通过企微应用消息接口发送文本消息到群聊。
|
||
|
||
注意:企微的应用消息接口 /cgi-bin/appchat/send 发送群聊消息时,
|
||
需要在企微后台开启"群聊会话"功能,并配置接收消息的群聊。
|
||
单个群机器人 Webhook 只适合一个固定群,不能用于多群动态回复。
|
||
"""
|
||
response_url = (msg_data or {}).get("response_url", "")
|
||
if response_url and self._send_text_to_response_url(response_url, content, msg_data or {}):
|
||
return
|
||
|
||
webhook_url = self._get_group_webhook(chat_id)
|
||
if webhook_url:
|
||
if self._send_text_to_group_webhook(webhook_url, content):
|
||
return
|
||
print("[WECOM WARN] 群机器人 Webhook 发送失败,尝试 appchat/send", flush=True)
|
||
|
||
if self._send_text_to_group_appchat(chat_id, content):
|
||
return
|
||
|
||
fallback_webhook = self._get_legacy_group_webhook()
|
||
if fallback_webhook:
|
||
print("[WECOM WARN] 使用单群 WECOM_GROUP_WEBHOOK 兜底,可能无法精准回复到当前群", flush=True)
|
||
if self._send_text_to_group_webhook(fallback_webhook, content):
|
||
return
|
||
|
||
print(f"[WECOM ERROR] 群聊消息发送失败: chat_id={chat_id}", flush=True)
|
||
|
||
def _send_text_to_response_url(self, response_url: str, content: str, msg_data: dict | None = None) -> bool:
|
||
"""通过企微 AI 机器人回调消息中的 response_url 回复。"""
|
||
content = self._limit_utf8_bytes(content, 19000)
|
||
payload = {
|
||
"msgtype": "markdown",
|
||
"markdown": {"content": content},
|
||
}
|
||
|
||
try:
|
||
resp = requests.post(response_url, json=payload, timeout=10)
|
||
resp_data = resp.json()
|
||
if resp_data.get("errcode") == 0:
|
||
print("[WECOM DEBUG] response_url 发送成功: msgtype=markdown", flush=True)
|
||
return True
|
||
print(f"[WECOM ERROR] response_url 发送失败: {resp_data}", flush=True)
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] response_url 发送异常: {e}", flush=True)
|
||
return False
|
||
|
||
def _limit_utf8_bytes(self, text: str, max_bytes: int) -> str:
|
||
"""按 UTF-8 字节数截断,避免企微回复超限。"""
|
||
raw = text.encode("utf-8")
|
||
if len(raw) <= max_bytes:
|
||
return text
|
||
suffix = "\n\n(内容较长,已截断)"
|
||
allowed = max_bytes - len(suffix.encode("utf-8"))
|
||
return raw[:allowed].decode("utf-8", errors="ignore") + suffix
|
||
|
||
def _send_text_to_group_appchat(self, chat_id: str, content: str) -> bool:
|
||
"""通过企微 appchat/send 按 chatid 精准发送群聊消息。"""
|
||
access_token = self._get_access_token()
|
||
if not access_token:
|
||
return False
|
||
|
||
print(f"[WECOM DEBUG] 发送群聊消息: chat_id={chat_id}", flush=True)
|
||
|
||
# 企微消息长度限制 2048 字符
|
||
MAX_LEN = 2000
|
||
chunks = [content[i:i + MAX_LEN] for i in range(0, len(content), MAX_LEN)]
|
||
|
||
for chunk in chunks:
|
||
resp = requests.post(
|
||
f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={access_token}",
|
||
json={
|
||
"chatid": chat_id,
|
||
"msgtype": "text",
|
||
"text": {"content": chunk},
|
||
},
|
||
timeout=10,
|
||
)
|
||
resp_data = resp.json()
|
||
print(f"[WECOM DEBUG] 群聊消息发送响应: {resp_data}", flush=True)
|
||
if resp_data.get("errcode") != 0:
|
||
print(f"[WECOM ERROR] appchat/send 发送消息到群失败: {resp_data}", flush=True)
|
||
return False
|
||
else:
|
||
print(f"[WECOM DEBUG] 发送消息到群{chat_id}成功", flush=True)
|
||
return True
|
||
|
||
def _get_group_webhook(self, chat_id: str) -> str:
|
||
"""按群聊 ID 获取精准 Webhook。"""
|
||
if not self._ensure_group_config_table():
|
||
return ""
|
||
try:
|
||
group = db.session.query(WeComGroupConfig).filter_by(chatid=chat_id, status="active").first()
|
||
if group and group.webhook_url:
|
||
return group.webhook_url
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"[WECOM ERROR] 查询群聊配置失败: {e}", flush=True)
|
||
|
||
webhook_map = _get_wecom_config("WECOM_GROUP_WEBHOOKS")
|
||
if webhook_map:
|
||
try:
|
||
import json
|
||
webhooks = json.loads(webhook_map)
|
||
webhook_url = webhooks.get(chat_id, "")
|
||
if webhook_url:
|
||
return webhook_url
|
||
except Exception as e:
|
||
print(f"[WECOM ERROR] WECOM_GROUP_WEBHOOKS 解析失败: {e}", flush=True)
|
||
|
||
return ""
|
||
|
||
def _ensure_group_config_table(self) -> bool:
|
||
"""确保企微群配置表存在,兼容未运行自研迁移的部署包。"""
|
||
try:
|
||
db.session.execute(text("""
|
||
CREATE TABLE IF NOT EXISTS wecom_group_configs (
|
||
id SERIAL PRIMARY KEY,
|
||
chatid VARCHAR(128) NOT NULL UNIQUE,
|
||
aibotid VARCHAR(128),
|
||
name VARCHAR(128),
|
||
webhook_url TEXT,
|
||
status VARCHAR(16) DEFAULT 'active',
|
||
auto_discovered BOOLEAN DEFAULT TRUE,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
last_seen_at TIMESTAMP NULL
|
||
)
|
||
"""))
|
||
db.session.execute(text(
|
||
"CREATE INDEX IF NOT EXISTS idx_wecom_group_configs_chatid "
|
||
"ON wecom_group_configs (chatid)"
|
||
))
|
||
db.session.commit()
|
||
return True
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"[WECOM ERROR] 确保群聊配置表失败: {e}", flush=True)
|
||
return False
|
||
|
||
def _get_legacy_group_webhook(self) -> str:
|
||
"""单群 Webhook 兜底,默认关闭以避免多群误发。"""
|
||
if _get_wecom_config("WECOM_GROUP_WEBHOOK_FALLBACK", "false").lower() != "true":
|
||
return ""
|
||
return _get_wecom_config("WECOM_GROUP_WEBHOOK")
|
||
|
||
def _send_text_to_group_webhook(self, webhook_url: str, content: str) -> bool:
|
||
"""通过群机器人 Webhook 发送文本消息到群聊。"""
|
||
# 企微消息长度限制 2048 字符
|
||
MAX_LEN = 2000
|
||
chunks = [content[i:i + MAX_LEN] for i in range(0, len(content), MAX_LEN)]
|
||
ok = True
|
||
|
||
for chunk in chunks:
|
||
resp = requests.post(
|
||
webhook_url,
|
||
json={
|
||
"msgtype": "text",
|
||
"text": {"content": chunk},
|
||
},
|
||
timeout=10,
|
||
)
|
||
resp_data = resp.json()
|
||
if resp_data.get("errcode") != 0:
|
||
print(f"[WECOM ERROR] 群机器人发送失败: {resp_data}", flush=True)
|
||
ok = False
|
||
else:
|
||
print(f"[WECOM DEBUG] 群机器人发送成功", flush=True)
|
||
return ok
|