204 lines
9.5 KiB
Python
204 lines
9.5 KiB
Python
"""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"
|
|
"——以上仅为内容示例,不会发送消息。")
|