"""企微服务:消息加解密、异步处理、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""" {create_time} """ 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