Files
xieyi-weixin-/conversation_blacklist.py
2026-09-29 15:51:28 +08:00

306 lines
13 KiB
Python

"""Durable account-scoped outbound deny list shared by desktop engines.
The server-side blacklist (management backend) is the source of truth; this
local database is its cache. Engines keep reading only this file, so a blocked
conversation stays blocked while the computer is offline.
Every row carries a ``sync_state``:
pending added on this computer and not yet confirmed by the server
(also every entry that existed before the server list did)
synced present in the last server snapshot; deleted locally once a later
snapshot no longer contains it (removed from the phone console)
rejected refused by the server (malformed identifiers); still blocks here,
no longer re-uploaded
The cache is bound to the logged-in account (``sync_meta.tenant``): each ZYT
account has its own server list.
"""
from contextlib import closing
from datetime import datetime
import sqlite3
from pathlib import Path
from runtime_paths import application_data_dir
BLOCKED_REASON = '该会话已加入忽略,系统禁止发送消息'
_OPTIONAL_COLUMNS = ('name', 'account_name', 'created_at', 'reason')
def _path(root):
return (Path(root or application_data_dir()) / 'conversation_blacklist.sqlite3').resolve()
def entries(root=None):
path = _path(root)
if not path.exists():
return set()
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=2)) as db:
return {(str(a), str(c)) for a, c in db.execute('SELECT account,conversation FROM blocked')}
def list_entries(root=None):
"""Read all accounts, including entries absent from the current message list."""
path = _path(root)
if not path.exists():
return []
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=2)) as db:
db.row_factory = sqlite3.Row
columns = {row[1] for row in db.execute('PRAGMA table_info(blocked)')}
fields = ['account', 'conversation'] + [
name if name in columns else "'' AS " + name
for name in _OPTIONAL_COLUMNS
] + ['sync_state' if 'sync_state' in columns else "'pending' AS sync_state"]
return [dict(row) for row in db.execute(
'SELECT ' + ','.join(fields) + ' FROM blocked ORDER BY account,conversation')]
def _initialize(db):
db.execute('CREATE TABLE IF NOT EXISTS blocked (account TEXT NOT NULL, conversation TEXT NOT NULL, PRIMARY KEY(account,conversation))')
columns = {row[1] for row in db.execute('PRAGMA table_info(blocked)')}
for name in _OPTIONAL_COLUMNS:
if name not in columns:
db.execute("ALTER TABLE blocked ADD COLUMN " + name + " TEXT NOT NULL DEFAULT ''")
if 'sync_state' not in columns:
# Entries written by older versions have never reached the server.
db.execute("ALTER TABLE blocked ADD COLUMN sync_state TEXT NOT NULL DEFAULT 'pending'")
db.execute('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
def _connect(root):
path = _path(root)
path.parent.mkdir(parents=True, exist_ok=True)
return closing(sqlite3.connect(path, timeout=5))
def _clean(value):
return str(value or '').strip()
def set_blocked(account, conversation, blocked, *, root=None, name='', account_name='', reason=''):
account, conversation = _clean(account), _clean(conversation)
if not account or not conversation or type(blocked) is not bool:
raise ValueError('请选择有效账号和会话')
with _connect(root) as db, db:
_initialize(db)
if blocked:
db.execute("""INSERT INTO blocked (account,conversation,name,account_name,created_at,reason,sync_state)
VALUES (?,?,?,?,?,?,'pending') ON CONFLICT(account,conversation) DO UPDATE SET
name=CASE WHEN excluded.name<>'' THEN excluded.name ELSE blocked.name END,
account_name=CASE WHEN excluded.account_name<>'' THEN excluded.account_name ELSE blocked.account_name END,
reason=CASE WHEN excluded.reason<>'' THEN excluded.reason ELSE blocked.reason END""",
(account, conversation, str(name or ''), str(account_name or ''),
datetime.now().strftime('%Y-%m-%d %H:%M:%S'), str(reason or '')[:200]))
else:
db.execute('DELETE FROM blocked WHERE account=? AND conversation=?', (account, conversation))
def remove_entries(items, *, root=None, only_pending=False):
"""Validate the whole selection, then remove exact account/conversation pairs atomically.
``only_pending`` removes a row only while it has not reached the server: a sync
running in parallel may have just uploaded it, and then only the server may
remove it.
"""
pairs = _validated_pairs(items)
path = _path(root)
if not path.exists():
return 0
with closing(sqlite3.connect(path, timeout=5)) as db, db:
if only_pending:
_initialize(db)
before = db.total_changes
sql = 'DELETE FROM blocked WHERE account=? AND conversation=?'
if only_pending:
sql += " AND sync_state<>'synced'"
db.executemany(sql, sorted(pairs))
return db.total_changes - before
def _validated_pairs(items):
if not isinstance(items, list) or not items:
raise ValueError('请先勾选需要移除的会话')
pairs = set()
for item in items:
if not isinstance(item, dict):
raise ValueError('忽略选择无效,请刷新后重试')
account, conversation = item.get('account'), item.get('conversation')
if not isinstance(account, str) or not account.strip() or not isinstance(conversation, str) or not conversation.strip():
raise ValueError('忽略选择无效,请刷新后重试')
pairs.add((account.strip(), conversation.strip()))
return pairs
def local_states(root=None):
"""{(account, conversation): 'pending' | 'synced'} for every local entry."""
path = _path(root)
if not path.exists():
return {}
with _connect(root) as db, db:
_initialize(db)
return {(str(a), str(c)): str(s or 'pending')
for a, c, s in db.execute('SELECT account,conversation,sync_state FROM blocked')}
def pending_entries(root=None):
"""Entries that still have to be uploaded to the server."""
path = _path(root)
if not path.exists():
return []
with _connect(root) as db, db:
_initialize(db)
db.row_factory = sqlite3.Row
return [
{'account': row['account'], 'conversation': row['conversation'], 'name': row['name'],
'account_name': row['account_name'], 'reason': row['reason']}
for row in db.execute(
"SELECT * FROM blocked WHERE sync_state='pending' ORDER BY account,conversation LIMIT 500")
]
def mark_rejected(rows, *, root=None):
"""The server refused these (malformed identifiers): keep blocking, stop re-uploading."""
pairs = [(_clean(row.get('account')), _clean(row.get('conversation')))
for row in rows or [] if isinstance(row, dict)]
pairs = [pair for pair in pairs if pair[0] and pair[1]]
if not pairs:
return 0
with _connect(root) as db, db:
_initialize(db)
before = db.total_changes
db.executemany("UPDATE blocked SET sync_state='rejected' WHERE account=? AND conversation=? "
"AND sync_state='pending'", pairs)
return db.total_changes - before
def pending_count(root=None):
path = _path(root)
if not path.exists():
return 0
try:
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=2)) as db:
columns = {row[1] for row in db.execute('PRAGMA table_info(blocked)')}
if 'sync_state' not in columns:
return int(db.execute('SELECT COUNT(*) FROM blocked').fetchone()[0])
return int(db.execute("SELECT COUNT(*) FROM blocked WHERE sync_state='pending'").fetchone()[0])
except sqlite3.Error:
return 0
def _meta(root):
path = _path(root)
if not path.exists():
return {}
try:
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=2)) as db:
return {str(k): str(v) for k, v in db.execute('SELECT key,value FROM sync_meta')}
except sqlite3.Error:
return {}
def synced_version(root=None, tenant=None):
"""Server version of the cached list; None when it belongs to another account."""
meta = _meta(root)
if tenant is not None and meta.get('tenant', '') != str(tenant):
return None
try:
return int(meta['server_version'])
except (KeyError, TypeError, ValueError):
return None
def adopt_tenant(tenant, *, root=None):
"""Bind the cache to the logged-in account (its server list is per ZYT account).
When a different account logs in on this computer, rows synced for the previous
account become ``pending`` instead of being dropped: they keep blocking and are
uploaded to the new account, where a supervisor can review them. Returns True
when the binding changed.
"""
tenant = _clean(tenant)
if not tenant:
return False
current = _meta(root).get('tenant', '')
if current == tenant:
return False
with _connect(root) as db, db:
_initialize(db)
stored = db.execute("SELECT value FROM sync_meta WHERE key='tenant'").fetchone()
if stored and str(stored[0]) == tenant:
return False
if stored and str(stored[0]):
db.execute("UPDATE blocked SET sync_state='pending' WHERE sync_state='synced'")
db.execute("DELETE FROM sync_meta WHERE key='server_version'")
db.execute("INSERT INTO sync_meta(key,value) VALUES('tenant',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (tenant,))
return True
def _local_time(value):
text = _clean(value)
try:
return datetime.fromisoformat(text.replace('Z', '+00:00')).astimezone().strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
return text or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def apply_server_snapshot(items, version, *, root=None, tenant=None):
"""Replace the synced part of the cache with the server's full list.
Rows still ``pending`` are kept even when the snapshot lacks them: they were
added here after the request left (or the upload is still to come), and
blocking is the safe side. Returns counts for logging.
"""
server = {}
for item in items or []:
if not isinstance(item, dict):
continue
account, conversation = _clean(item.get('account')), _clean(item.get('conversation'))
if account and conversation:
server[(account, conversation)] = item
added = removed = 0
with _connect(root) as db, db:
_initialize(db)
if tenant is not None:
stored = db.execute("SELECT value FROM sync_meta WHERE key='tenant'").fetchone()
if stored and str(stored[0]) and str(stored[0]) != str(tenant):
# The login changed while this snapshot was in flight; adopt_tenant()
# re-binds first, so a stale snapshot must not touch the new binding.
return {'added': 0, 'removed': 0, 'total': 0, 'stale': True}
local = {(str(a), str(c)): str(s or 'pending')
for a, c, s in db.execute('SELECT account,conversation,sync_state FROM blocked')}
for key, state in local.items():
if key not in server and state == 'synced':
db.execute('DELETE FROM blocked WHERE account=? AND conversation=?', key)
removed += 1
for key, item in server.items():
if key not in local:
added += 1
db.execute("""INSERT INTO blocked (account,conversation,name,account_name,created_at,reason,sync_state)
VALUES (?,?,?,?,?,?,'synced') ON CONFLICT(account,conversation) DO UPDATE SET
name=CASE WHEN excluded.name<>'' THEN excluded.name ELSE blocked.name END,
account_name=CASE WHEN excluded.account_name<>'' THEN excluded.account_name ELSE blocked.account_name END,
reason=excluded.reason,sync_state='synced'""",
(key[0], key[1], _clean(item.get('name')), _clean(item.get('account_name')),
_local_time(item.get('created_at')), _clean(item.get('reason'))[:200]))
if version is not None:
db.execute("INSERT INTO sync_meta(key,value) VALUES('server_version',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (str(int(version)),))
if tenant is not None:
db.execute("INSERT INTO sync_meta(key,value) VALUES('tenant',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (str(tenant),))
return {'added': added, 'removed': removed, 'total': len(server)}
def blocked_reason(account, conversation, *, root=None):
try:
return BLOCKED_REASON if (str(account or ''), str(conversation or '')) in entries(root) else ''
except (OSError, sqlite3.Error):
return '忽略暂不可读取,已暂停系统发送,请重试'
def state_reason(state):
state = state if isinstance(state, dict) else {}
event = state.get('database_event') or {}
return blocked_reason(state.get('account') or event.get('account'),
state.get('conv_id') or event.get('conv_id'))