"""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 from functools import cmp_to_key import unicodedata 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 / "wxwork_reply_cache", data / "archive_auto_backup" / "decrypted", data / "wxwork_decrypted", source / "wxwork_reply_cache", source / "archive_auto_backup" / "decrypted", source / "wxwork_decrypted"] def _modified(path): # An active SQLite WAL can be newer than the main database file. stamps = [path.stat().st_mtime_ns] try: stamps.append(Path(str(path) + "-wal").stat().st_mtime_ns) except OSError: pass return max(stamps) 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 _modified(path) > _modified(found[name]): 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, key="id"): 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 key not in fields: return [] return [dict(zip(fields, row)) for row in connection.execute(f"SELECT {','.join(fields)} FROM {table}")] finally: connection.close() def _text(value): return str(value or "").strip() def recipient_display(name, identity, name_known=None): """Presentation only; never supplies or changes a recipient address.""" name, identity = _text(name), _text(identity) known = (name not in {"", "微信联系人", "未命名微信联系人"}) if not isinstance(name_known, bool) else name_known known = bool(known and name) number = identity.split(":", 1)[-1] return {"nameKnown": known, "displayName": name if known else (f"未读取昵称 · 尾号 {number[-6:]}" if number else "尚未选择")} def _sort_contacts(items, order): # Use the same Chinese collation in both source and packaged Qt clients. # Normalize leading emoji/punctuation so decorative nicknames do not crowd # the first page. Unknown names remain after named contacts in name order. try: from PySide6.QtCore import QCollator, QLocale, Qt collator = QCollator(QLocale("zh_CN")) collator.setNumericMode(True) collator.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) compare_names = collator.compare except ImportError: compare_names = lambda left, right: (left > right) - (left < right) def normalized(row): text = unicodedata.normalize("NFKC", _text(row.get("name"))).casefold() return "".join(ch for ch in text if ch.isalnum()) or text def compare(left, right): # Keep sendable and directory entries together even in the all view. a, b = not left.get("selectable", True), not right.get("selectable", True) if a != b: return (a > b) - (a < b) if order == "recent" and not a: x, y = left.get("lastTimestamp", 0), right.get("lastTimestamp", 0) if x != y: return (y > x) - (y < x) x, y = not left.get("nameKnown", True), not right.get("nameKnown", True) if x != y: return (x > y) - (x < y) value = compare_names(normalized(left), normalized(right)) if value: return value return (left["id"] > right["id"]) - (left["id"] < right["id"]) return sorted(items, key=cmp_to_key(compare)) def _contact_time(value): try: stamp = float(value or 0) if not math.isfinite(stamp) or stamp < 0: stamp = 0 if stamp > 10 ** 12: stamp /= 1000 return stamp, datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M") if stamp else "" except (ValueError, TypeError, OSError, OverflowError): return 0, "" 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, aliases, remarks, friends, addressbook = {}, {}, {}, {}, {} user_db = databases.get("user.db") for row in _names(user_db, "user_table", ("id", "name", "real_name", "account")): uid = str(row["id"]) values = [_text(row.get(field)) for field in ("name", "real_name", "account")] users[uid] = next((value for value in values if value), "") aliases[uid] = values for row in _names(user_db, "external_user_relation_v3", ("user_id", "remarks", "real_remarks"), key="user_id"): uid = str(row["user_id"]) values = [_text(row.get(field)) for field in ("real_remarks", "remarks")] remarks[uid] = next((value for value in values if value), "") aliases.setdefault(uid, []).extend(values) # Only an explicit UID <-> wx_id relation may link address-book names # to a sendable M: conversation. Never derive a UID from gene or a name. for row in _names(user_db, "wx_friend", ("user_id", "nick_name", "wx_id"), key="user_id"): uid, wxid = str(row["user_id"]), _text(row.get("wx_id")) if _ACCOUNT.fullmatch(uid): friends.setdefault(uid, []).append((wxid, _text(row.get("nick_name")))) for row in _names(user_db, "wechat_contactV1", ("wxid", "name"), key="wxid"): wxid = _text(row.get("wxid")) if wxid: addressbook[wxid] = _text(row.get("name")) or "未命名微信联系人" names = {} for row in _names(databases.get("session.db"), "conversation_table", ("id", "name", "roomname_remark", "session_id")): names[str(row["id"])] = _text(row.get("roomname_remark")) or _text(row.get("name")) 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, linked = [], set() # Conflicting relation rows must not silently merge distinct identities. owners = {} for uid, relations in friends.items(): for wxid, _nickname in relations: if wxid: owners.setdefault(wxid, set()).add(uid) for conv_id, stamp in rows: conv_id = str(conv_id or "") if not _PRIVATE.fullmatch(conv_id): continue uid = conv_id[2:] extra = list(aliases.get(uid, [])) display_names = [] distinct_wxids = {value for value, _ in friends.get(uid, []) if value} for wxid, nickname in friends.get(uid, []): if not wxid or owners.get(wxid) != {uid} or len(distinct_wxids) != 1: continue display_names.extend([nickname, addressbook.get(wxid, "")]) extra.extend([nickname, addressbook.get(wxid, ""), wxid]) linked.add(wxid) name = names.get(conv_id) or remarks.get(uid) or users.get(uid) or next( (value for value in display_names if value), "微信联系人") stamp, last_time = _contact_time(stamp) items.append({"id": conv_id, "name": name, "kind": "微信客户", "lastTimestamp": stamp, "lastTime": last_time, "identityLabel": f"微信联系人 · 联系编号 {uid}", "selectable": True, "unavailableReason": "", "searchText": " ".join([name, conv_id, *extra])}) for wxid, name in addressbook.items(): if wxid in linked: continue items.append({"id": "contact:" + wxid, "name": name, "kind": "微信通讯录", "lastTimestamp": 0, "lastTime": "", "selectable": False, "identityLabel": f"微信通讯录 · 联系编号 {wxid}", "unavailableReason": "尚无可核验的私人微信会话。请在当前托管企微与此联系人建立单聊并产生一条消息,再刷新消息库。", "searchText": name + " " + wxid}) for item in items: item.update(recipient_display(item["name"], item["id"])) items = _sort_contacts(items, "recent") 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, status="all", order="recent") -> 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: if status not in {"all", "sendable", "directory"} or order not in {"recent", "name"}: raise ValueError("联系人筛选或排序方式无效") 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 item.get("searchText", f"{item['name']} {item['id']}").casefold()] all_total = len(matches) all_selectable = sum(bool(item.get("selectable", True)) for item in matches) if status != "all": matches = [item for item in matches if bool(item.get("selectable", True)) == (status == "sendable")] matches = _sort_contacts(matches, order) 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), "selectable_total": sum(bool(item.get("selectable", True)) for item in matches), "contact_total": sum(not item.get("selectable", True) for item in matches), "all_total": all_total, "all_selectable_total": all_selectable, "all_contact_total": all_total - all_selectable, "status": status, "order": order} 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 or not recipient.get("selectable", True): 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" "——以上仅为内容示例,不会发送消息。")