baodan/api/insurance/wecom/service.py
2026-07-12 14:17:18 +08:00

727 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""企微服务消息加解密、异步处理、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)
api_key = _get_wecom_config("BAODAN_CHAT_API_KEY")
base_url = _get_wecom_config("BAODAN_API_URL", "http://baodan-api:5001")
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