414 lines
20 KiB
Python
414 lines
20 KiB
Python
"""Read-only managed-account customer 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 recipient_peer(account, conv_id) -> str:
|
|
"""Parse an exact direct-chat ID; customer membership is checked separately."""
|
|
account, conv_id = str(account or ""), str(conv_id or "")
|
|
if not _ACCOUNT.fullmatch(account):
|
|
return ""
|
|
if _PRIVATE.fullmatch(conv_id):
|
|
peer = conv_id[2:]
|
|
return peer if peer != account else ""
|
|
if not conv_id.startswith("S:"):
|
|
return ""
|
|
parts = conv_id[2:].split("_")
|
|
if len(parts) != 2 or any(not _ACCOUNT.fullmatch(part) for part in parts):
|
|
return ""
|
|
if parts.count(account) != 1:
|
|
return ""
|
|
return next(part for part in parts if part != account)
|
|
|
|
|
|
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:
|
|
wal_stat = Path(str(path) + "-wal").stat()
|
|
if wal_stat.st_size:
|
|
stamps.append(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 "user.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()
|
|
# Opening a read-only WAL database may create an empty sidecar.
|
|
# An empty WAL carries no changes and is equivalent to no WAL.
|
|
if candidate != path and not stat.st_size:
|
|
result.append((name, str(candidate), None))
|
|
continue
|
|
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, ""
|
|
|
|
|
|
# Observed customer records on supported client 5.0.11.6018 use these values.
|
|
# This is an allowlist for that verified build, not a general status-bit enum.
|
|
# Unknown values may be displayed but must never enable a recipient.
|
|
_VERIFIED_CUSTOMER_STATUSES = frozenset({2049, 2057})
|
|
_SYSTEM_CUSTOMER_NAMES = frozenset({
|
|
"企业微信团队", "微信团队", "微信支付", "腾讯客服", "腾讯新闻", "企业码会话管理",
|
|
})
|
|
|
|
|
|
def _system_customer(names, conv_id=""):
|
|
from reply_session_policy import excluded_session_reason, normalize_session_name
|
|
return any(normalize_session_name(name) in _SYSTEM_CUSTOMER_NAMES
|
|
or excluded_session_reason(name, conv_id) for name in names if name)
|
|
|
|
|
|
def _integer(value):
|
|
# Do not turn unknown encodings or floating values into verified flags.
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, str) and re.fullmatch(r"-?[0-9]+", value):
|
|
return int(value)
|
|
return None
|
|
|
|
|
|
def _customer_metadata(user_db, account):
|
|
"""Read customer identity metadata only, in one read-only SQLite snapshot."""
|
|
connection = _open(user_db)
|
|
try:
|
|
connection.execute("BEGIN")
|
|
columns = {row[1] for row in connection.execute('PRAGMA table_info("external_user_relation_v3")')}
|
|
required = {"user_id", "status", "stranger_type", "add_customer_time"}
|
|
if not required.issubset(columns):
|
|
raise ValueError("当前账号的‘我的客户’关系表缺失或不完整,请先刷新微信消息库;不会使用私人微信通讯录代替")
|
|
|
|
def rows(table, wanted, key):
|
|
fields = {row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')}
|
|
selected = [field for field in wanted if field in fields]
|
|
if key not in selected:
|
|
return []
|
|
return [dict(zip(selected, row)) for row in connection.execute(f"SELECT {','.join(selected)} FROM {table}")]
|
|
|
|
users = {str(row["id"]): row for row in rows(
|
|
"user_table", ("id", "name", "real_name", "account", "corp_id"), "id")}
|
|
deleted = {str(row["user_id"]) for row in rows("delete_external_userV1", ("user_id",), "user_id")}
|
|
blocked = {str(row["value"]) for row in rows("blacklist_external_userids", ("value",), "value")}
|
|
coworkers = {str(row["user_id"]) for row in rows("user_dept_tableV2", ("user_id",), "user_id")}
|
|
denied = deleted | blocked | coworkers
|
|
self_corp = _integer(users.get(account, {}).get("corp_id"))
|
|
customers = {}
|
|
for row in rows("external_user_relation_v3",
|
|
("user_id", "status", "stranger_type", "add_customer_time", "remarks", "real_remarks"), "user_id"):
|
|
uid = str(row["user_id"])
|
|
if not _ACCOUNT.fullmatch(uid) or uid == account or uid in denied:
|
|
continue
|
|
user = users.get(uid, {})
|
|
peer_corp = _integer(user.get("corp_id"))
|
|
if self_corp not in (None, 0) and peer_corp == self_corp:
|
|
continue
|
|
# A relation to a stranger, or an un-added profile, is not My Customers.
|
|
if _integer(row.get("stranger_type")) != 0 or (_integer(row.get("add_customer_time")) or 0) <= 0:
|
|
continue
|
|
aliases = [_text(row.get(field)) for field in ("remarks", "real_remarks")]
|
|
aliases.extend(_text(user.get(field)) for field in ("name", "real_name"))
|
|
if _system_customer(aliases):
|
|
continue
|
|
verified = _integer(row.get("status")) in _VERIFIED_CUSTOMER_STATUSES
|
|
customers[uid] = {"name": next((value for value in aliases if value), "客户联系人"),
|
|
"aliases": aliases, "verified": verified,
|
|
"reason": "" if verified else "客户关系状态尚未核验,暂不能设为医疗助理;请刷新微信消息库后重试"}
|
|
own = users.get(account, {})
|
|
account_name = _text(own.get("name")) or _text(own.get("real_name")) or account
|
|
return account_name, customers
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def _contacts(account, cache_roots=None, *, fresh=False):
|
|
databases = _databases(account, cache_roots)
|
|
revision = _revision(databases)
|
|
key = (account, revision)
|
|
with _LOCK:
|
|
if not fresh and key in _CACHE:
|
|
_CACHE.move_to_end(key)
|
|
return _CACHE[key]
|
|
account_name, customers = _customer_metadata(databases["user.db"], account)
|
|
sessions = {}
|
|
for row in _names(databases.get("session.db"), "conversation_table",
|
|
("id", "name", "roomname_remark", "is_blocked")):
|
|
sessions[str(row["id"])] = row
|
|
conversations = {}
|
|
if databases.get("message.db") is not None:
|
|
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"
|
|
# Do not read message bodies, previews, phone numbers or binary blobs.
|
|
rows = connection.execute(
|
|
f"SELECT conversation_id,{timestamp} FROM message_table "
|
|
"WHERE conversation_id LIKE 'M:%' OR conversation_id LIKE 'S:%' GROUP BY conversation_id").fetchall()
|
|
finally:
|
|
connection.close()
|
|
for conv_id, stamp in rows:
|
|
conv_id = str(conv_id or "")
|
|
peer = recipient_peer(account, conv_id)
|
|
if peer not in customers:
|
|
continue
|
|
session = sessions.get(conv_id, {})
|
|
if _integer(session.get("is_blocked")) not in (None, 0):
|
|
continue
|
|
session_names = [_text(session.get(field)) for field in ("name", "roomname_remark")]
|
|
if _system_customer(session_names, conv_id):
|
|
continue
|
|
conversations.setdefault(peer, []).append((conv_id, stamp))
|
|
items = []
|
|
for uid, customer in customers.items():
|
|
targets = conversations.get(uid, []) if customer["verified"] else []
|
|
# An actual cached conversation is required. Never synthesize M:/S:.
|
|
if not targets:
|
|
targets = [("customer:" + uid, 0)]
|
|
for conv_id, stamp in targets:
|
|
selectable = recipient_peer(account, conv_id) == uid and customer["verified"]
|
|
stamp, last_time = _contact_time(stamp)
|
|
reason = "" if selectable else customer["reason"] or "尚无可核验的客户单聊。请在当前托管企业微信中与此客户建立单聊并产生一条消息,再刷新消息库。"
|
|
kind = "wecom_customer" if conv_id.startswith("S:") else "wechat_customer" if conv_id.startswith("M:") else "customer"
|
|
name = customer["name"]
|
|
item = {"id": conv_id, "peer_id": uid, "name": name,
|
|
"recipient_kind": kind,
|
|
"kind": "企业微信客户" if kind == "wecom_customer" else "微信客户" if kind == "wechat_customer" else "我的客户",
|
|
"lastTimestamp": stamp, "lastTime": last_time,
|
|
"identityLabel": f"我的客户 · 联系编号 {uid}",
|
|
"selectable": selectable, "unavailableReason": reason,
|
|
"searchText": " ".join([name, conv_id, uid, *customer["aliases"]])}
|
|
item.update(recipient_display(name, uid))
|
|
items.append(item)
|
|
if _revision(databases) != revision:
|
|
raise ValueError("读取期间客户或会话数据已更新,请刷新联系人列表后重试")
|
|
items = _sort_contacts(items, "recent")
|
|
result = (account_name, 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 recipient_peer(account, conv_id):
|
|
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, fresh=True)
|
|
recipient = next((dict(item) for item in contacts if item["id"] == conv_id), None)
|
|
if recipient is None or not recipient.get("selectable", False):
|
|
customer = next((item for item in contacts if item.get("peer_id") == recipient_peer(account, conv_id)), {})
|
|
raise ValueError(customer.get("unavailableReason") or "所选联系人不在当前账号有效的‘我的客户’单聊中,请刷新后重新选择")
|
|
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"
|
|
"——以上仅为内容示例,不会发送消息。")
|