"""Read-only identity and existing private-WeChat recipients for review reminders. This module does not initialize/decrypt databases, send messages, or choose an account/contact by display name. UI refreshes run it on a background worker. """ from __future__ import annotations from collections import OrderedDict from datetime import datetime import math from pathlib import Path import re import sqlite3 import threading from runtime_paths import application_data_dir _ACCOUNT = re.compile(r"[0-9]{1,24}\Z") _PRIVATE = re.compile(r"M:[0-9]{1,24}\Z") _LOCK = threading.RLock() _CACHE = OrderedDict() def active_context() -> dict: try: from wecom_native_sender import discover, readonly_build_validation_scope except ImportError: return {"account_id": "", "account_name": "", "ready": False, "reason": "当前版本不支持医疗助理审核提醒"} try: with readonly_build_validation_scope(): identity = discover() account = str(identity.get("accountId") or "") if not _ACCOUNT.fullmatch(account): raise ValueError("未取得可核验的当前企微账号") return {"account_id": account, "account_name": account, "ready": True, "reason": "", "pid": int(identity.get("pid") or 0)} except Exception as exc: return {"account_id": "", "account_name": "", "ready": False, "reason": f"当前企微账号尚未核验:{str(exc)[:240]}"} def _roots(): data, source = application_data_dir(), Path(__file__).resolve().parent return [data / "archive_auto_backup" / "decrypted", data / "wxwork_decrypted", source / "archive_auto_backup" / "decrypted", source / "wxwork_decrypted"] def _databases(account, cache_roots=None): if not _ACCOUNT.fullmatch(str(account)): raise ValueError("企微账号格式无效") found = {} for root in cache_roots if cache_roots is not None else _roots(): directory = Path(root) / account # Reject a linked directory belonging to a different account. if directory.resolve().name != account: continue for name in ("message.db", "session.db", "user.db"): path = directory / name if not path.is_file() or path.resolve().parent != directory.resolve(): continue try: if name not in found or path.stat().st_mtime_ns > found[name].stat().st_mtime_ns: found[name] = path except OSError: continue if "message.db" not in found: raise ValueError("当前企微账号的本机消息库尚不可读,请先在微信消息库中完成读取") return found def _revision(databases): result = [] for name, path in sorted(databases.items()): for candidate in (path, Path(str(path) + "-wal")): try: stat = candidate.stat() result.append((name, str(candidate.resolve()), stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_ino)) except OSError: result.append((name, str(candidate), None)) return tuple(result) def _open(path): connection = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) connection.execute("PRAGMA query_only=ON") return connection def _names(path, table, wanted): if path is None: return [] connection = _open(path) try: columns = {str(row[1]) for row in connection.execute(f'PRAGMA table_info("{table}")')} fields = [field for field in wanted if field in columns] if "id" not in fields: return [] return [dict(zip(fields, row)) for row in connection.execute(f"SELECT {','.join(fields)} FROM {table}")] finally: connection.close() def _contacts(account, cache_roots=None): databases = _databases(account, cache_roots) revision = _revision(databases) key = (account, revision) with _LOCK: if key in _CACHE: _CACHE.move_to_end(key) return _CACHE[key] users = {} for row in _names(databases.get("user.db"), "user_table", ("id", "name", "real_name", "account")): users[str(row["id"])] = str(row.get("name") or row.get("real_name") or row.get("account") or row["id"]) names = {} for row in _names(databases.get("session.db"), "conversation_table", ("id", "name", "roomname_remark", "session_id")): names[str(row["id"])] = str(row.get("roomname_remark") or row.get("name") or "") connection = _open(databases["message.db"]) try: columns = {str(row[1]) for row in connection.execute('PRAGMA table_info("message_table")')} if "conversation_id" not in columns: raise ValueError("当前账号消息库缺少会话标识") timestamp = "MAX(CAST(send_time AS REAL))" if "send_time" in columns else "0" # Never read message bodies or use a preview to identify recipients. rows = connection.execute( f"SELECT conversation_id,{timestamp} FROM message_table " "WHERE conversation_id LIKE 'M:%' GROUP BY conversation_id").fetchall() finally: connection.close() items = [] for conv_id, stamp in rows: conv_id = str(conv_id or "") if not _PRIVATE.fullmatch(conv_id): continue name = names.get(conv_id) or users.get(conv_id[2:]) or "微信联系人" try: stamp = float(stamp or 0) if not math.isfinite(stamp) or stamp < 0: stamp = 0 if stamp > 10 ** 12: stamp /= 1000 last_time = datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M") if stamp else "" except (ValueError, TypeError, OSError, OverflowError): stamp, last_time = 0, "" items.append({"id": conv_id, "name": name, "kind": "微信客户", "lastTimestamp": stamp, "lastTime": last_time, "identityLabel": f"微信联系人 · 联系编号 {conv_id[2:]}"}) items.sort(key=lambda row: (-row["lastTimestamp"], row["id"])) result = (users.get(account) or account, items) _CACHE[key] = result while len(_CACHE) > 8: _CACHE.popitem(last=False) return result def list_recipients(account: str, query="", limit=50, offset=0, *, cache_roots=None) -> dict: context = active_context() if not context["ready"] or str(context["account_id"]) != str(account): return {**context, "ready": False, "items": [], "total": 0, "has_more": False, "reason": context["reason"] or "当前企业微信账号已变化,请重新读取账号"} try: name, contacts = _contacts(str(account), cache_roots) current = active_context() if not current["ready"] or str(current["account_id"]) != str(account): raise ValueError("读取期间企业微信账号已变化,请重新读取账号") needle = str(query or "").strip().casefold()[:100] matches = [item for item in contacts if not needle or needle in f"{item['name']} {item['id']}".casefold()] offset, limit = max(0, int(offset)), max(1, min(100, int(limit))) return {**context, "account_name": name, "items": [dict(row) for row in matches[offset:offset + limit]], "total": len(matches), "offset": offset, "has_more": offset + limit < len(matches)} except (ValueError, OSError, sqlite3.Error) as exc: return {**context, "ready": False, "items": [], "total": 0, "has_more": False, "reason": str(exc)[:300]} def validate_recipient(account: str, conv_id: str, *, cache_roots=None) -> dict: if not _PRIVATE.fullmatch(str(conv_id or "")): return {"ok": False, "reason": "请选择已有的私人微信单聊联系人", "recipient": None} context = active_context() if not context["ready"] or context["account_id"] != str(account): return {"ok": False, "reason": context["reason"] or "当前企业微信账号已变化,请重新选择", "recipient": None} try: name, contacts = _contacts(str(account), cache_roots) recipient = next((dict(item) for item in contacts if item["id"] == conv_id), None) if recipient is None: raise ValueError("所选联系人不在当前账号的已有私人微信单聊中,请刷新后重新选择") current = active_context() if not current["ready"] or current["account_id"] != str(account): raise ValueError("核对期间企业微信账号已变化,请重新选择") return {"ok": True, "reason": "", "account_id": str(account), "account_name": name, "recipient": recipient} except (ValueError, OSError, sqlite3.Error) as exc: return {"ok": False, "reason": str(exc)[:300], "recipient": None} def notification_preview(account="当前企微账号", recipient=None) -> str: return ("【客户待审核提醒】\n" f"托管企微账号:{account}\n" "客户:示例客户(实际提醒包含会话标识)\n" "客户咨询:这里显示本轮待处理的客户消息\n" "待发送回复:这里显示 AI 生成、尚未发送的回复\n" "审核原因:这里显示命中的审核原因\n" "请用同一企业微信账号直接回复该客户,结束本次审核。后续自动回复仍逐条风控。\n" "——以上仅为内容示例,不会发送消息。")