Files
xieyi-weixin-/proactive_contact_store.py
2026-11-02 14:05:03 +08:00

215 lines
12 KiB
Python

"""Durable, account-scoped proactive tasks. No network or messaging on import."""
from contextlib import contextmanager
import json
from pathlib import Path
import sqlite3
import time
import uuid
def encode(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
class ContactStore:
def __init__(self, root):
self.path = Path(root) / "proactive_contacts.sqlite3"
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.connect() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, account TEXT NOT NULL,
peer TEXT NOT NULL, data TEXT NOT NULL, UNIQUE(owner,account,peer));
CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, created REAL NOT NULL, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, campaign TEXT NOT NULL,
customer TEXT NOT NULL, account TEXT NOT NULL, status TEXT NOT NULL,
due REAL NOT NULL, created REAL NOT NULL, updated REAL NOT NULL, data TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS contact_due ON tasks(owner,status,due);
CREATE TABLE IF NOT EXISTS attempts (
id TEXT PRIMARY KEY, task TEXT NOT NULL, account TEXT NOT NULL,
started REAL NOT NULL, finished REAL, status TEXT NOT NULL, detail TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS settings (owner TEXT PRIMARY KEY, enabled INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS deleted_tasks (
task TEXT PRIMARY KEY, owner TEXT NOT NULL, deleted REAL NOT NULL, previous_status TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS reply_activations (
task TEXT PRIMARY KEY, requested REAL NOT NULL);
""")
@contextmanager
def connect(self):
db = sqlite3.connect(self.path, timeout=10)
db.row_factory = sqlite3.Row
db.execute("PRAGMA synchronous=FULL")
try:
with db:
yield db
finally:
db.close()
@staticmethod
def task_row(row):
return {**json.loads(row["data"]), **{k: row[k] for k in
("id", "owner", "campaign", "customer", "account", "status", "due", "created", "updated")}}
def customers(self, owner):
with self.connect() as db:
return [dict(json.loads(r["data"]), id=r["id"]) for r in db.execute(
"SELECT * FROM customers WHERE owner=? ORDER BY rowid DESC", (owner,))]
def save_customer(self, owner, data):
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT id FROM customers WHERE owner=? AND account=? AND peer=?",
(owner, data["account"], data["peer"])).fetchone()
key = row[0] if row else uuid.uuid4().hex
db.execute("INSERT INTO customers VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data",
(key, owner, data["account"], data["peer"], encode(data)))
return key
def tasks(self, owner, *, pending=False, limit=500, include_deleted=False):
with self.connect() as db:
extra = " AND status IN ('scheduled','queued','sending','paused')" if pending else ""
if not include_deleted:
extra += " AND NOT EXISTS (SELECT 1 FROM deleted_tasks d WHERE d.task=tasks.id)"
return [self.task_row(r) for r in db.execute(
"SELECT * FROM tasks WHERE owner=?" + extra + " ORDER BY created DESC,rowid DESC LIMIT ?",
(owner, limit))]
def get_task(self, owner, key, *, include_deleted=False):
with self.connect() as db:
extra = "" if include_deleted else " AND NOT EXISTS (SELECT 1 FROM deleted_tasks d WHERE d.task=tasks.id)"
row = db.execute("SELECT * FROM tasks WHERE owner=? AND id=?" + extra, (owner, key)).fetchone()
return self.task_row(row) if row else None
def delete_tasks(self, owner, ids):
"""Remove from lists, stop unsent work, retain receipts/dedup/pacing evidence."""
deleted, errors = [], []
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
for key in dict.fromkeys(ids):
row = db.execute("SELECT * FROM tasks WHERE owner=? AND id=?", (owner, key)).fetchone()
if not row:
errors.append("任务不存在或不属于当前账号")
continue
if row["status"] == "sending":
errors.append(f"{json.loads(row['data']).get('name', '客户')}:正在发送,请待结果返回后删除")
continue
if db.execute("SELECT 1 FROM deleted_tasks WHERE task=?", (key,)).fetchone():
continue
now = time.time()
db.execute("INSERT INTO deleted_tasks VALUES(?,?,?,?)", (key, owner, now, row["status"]))
if row["status"] in {"scheduled", "queued", "paused"}:
data = json.loads(row["data"])
data["error"] = "用户删除任务,已停止发送"
db.execute("UPDATE tasks SET status='cancelled',updated=?,data=? WHERE id=?", (now, encode(data), key))
deleted.append(key)
return deleted, errors
def create_campaign(self, owner, preview, now):
"""Confirmation is idempotent; dedup spans different campaigns for 24h."""
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
if db.execute("SELECT 1 FROM campaigns WHERE owner=? AND id=?", (owner, preview["id"])).fetchone():
return 0
for task in preview["tasks"]:
existing = db.execute("SELECT data FROM tasks WHERE owner=? AND customer=? AND "
"(status IN ('scheduled','queued','sending','paused','delivery_unknown') OR "
"(status='succeeded' AND updated>?))", (owner, task["customer"], now - 86400))
if any(json.loads(r[0])["text"] == task["text"] for r in existing):
raise ValueError(f"{task['name']} 已有相同内容任务或 24 小时内已发送,请核对记录")
db.execute("INSERT INTO campaigns VALUES(?,?,?,?)", (preview["id"], owner, now,
encode({k: v for k, v in preview.items() if k != "tasks"})))
for task in preview["tasks"]:
db.execute("INSERT INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?)",
(uuid.uuid4().hex, owner, preview["id"], task["customer"], task["account"],
"scheduled", task["due"], now, now, encode(task)))
return len(preview["tasks"])
def update(self, owner, key, status, *, expected=None, now=None, **changes):
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT * FROM tasks WHERE owner=? AND id=? AND NOT EXISTS "
"(SELECT 1 FROM deleted_tasks d WHERE d.task=tasks.id)", (owner, key)).fetchone()
if not row or (expected is not None and row["status"] not in expected):
return False
data = json.loads(row["data"])
due = changes.pop("due", row["due"])
data.update(changes)
db.execute("UPDATE tasks SET status=?,due=?,updated=?,data=? WHERE id=?",
(status, due, time.time() if now is None else now, encode(data), key))
return True
def claim(self, owner, key, now):
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT * FROM tasks WHERE owner=? AND id=? AND status IN ('scheduled','queued') "
"AND due<=?", (owner, key, now)).fetchone()
if not row:
return None
attempt = uuid.uuid4().hex
data = json.loads(row["data"])
data.update(attempt=attempt, attempts=int(data.get("attempts", 0)) + 1)
db.execute("UPDATE tasks SET status='sending',updated=?,data=? WHERE id=?", (now, encode(data), key))
db.execute("INSERT INTO attempts VALUES(?,?,?,?,NULL,'sending','{}')", (attempt, key, row["account"], now))
return {**data, **{k: row[k] for k in
("id", "owner", "campaign", "customer", "account", "due", "created", "updated")}, "status": "sending"}
def finish(self, task, status, *, error="", receipt=None, attempt_status=None):
now = time.time()
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT data FROM tasks WHERE id=? AND status='sending'", (task["id"],)).fetchone()
if not row:
return
data = json.loads(row[0])
data.update(error=error, receipt=receipt or {})
db.execute("UPDATE tasks SET status=?,updated=?,data=? WHERE id=?", (status, now, encode(data), task["id"]))
db.execute("UPDATE attempts SET finished=?,status=?,detail=? WHERE id=?",
(now, attempt_status or status, encode({"error": error, "receipt": receipt or {}}), task["attempt"]))
def pacing(self, account, now):
with self.connect() as db:
row = db.execute("SELECT MAX(started),COUNT(*) FROM attempts WHERE account=? AND started>? AND status!='deferred'",
(account, now - 86400)).fetchone()
return float(row[0] or 0), int(row[1])
def confirm_receipt(self, owner, key, receipt):
"""Reconcile a finished unknown attempt without scheduling another send."""
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT * FROM tasks WHERE owner=? AND id=? AND status='delivery_unknown'", (owner, key)).fetchone()
if not row:
return False
data = json.loads(row["data"])
evidence = {**receipt, "previousError": data.get("error", "")}
data.update(error="", receipt=evidence)
db.execute("UPDATE tasks SET status='succeeded',updated=?,data=? WHERE id=?",
(time.time(), encode(data), key))
db.execute("UPDATE attempts SET status='succeeded',detail=? WHERE id=? AND task=? AND status='delivery_unknown'",
(encode({"receipt": evidence, "reconciled_from": "delivery_unknown"}), data.get("attempt"), key))
return True
def enabled(self, owner, value=None):
with self.connect() as db:
if value is not None:
db.execute("INSERT INTO settings VALUES(?,?) ON CONFLICT(owner) DO UPDATE SET enabled=excluded.enabled",
(owner, int(bool(value))))
row = db.execute("SELECT enabled FROM settings WHERE owner=?", (owner,)).fetchone()
return bool(row[0]) if row else True
def pending_reply_activations(self, owner):
# Include removed tasks: deleting a displayed task does not cancel replies.
with self.connect() as db:
return [self.task_row(row) for row in db.execute(
"SELECT * FROM tasks WHERE owner=? AND status='succeeded' AND updated>=? "
"AND NOT EXISTS (SELECT 1 FROM reply_activations r WHERE r.task=tasks.id) "
"ORDER BY updated LIMIT 100", (owner, time.time()-86400))]
def acknowledge_reply_activations(self, owner, ids):
with self.connect() as db:
for key in ids:
db.execute("INSERT OR IGNORE INTO reply_activations SELECT id,? FROM tasks "
"WHERE owner=? AND id=? AND status='succeeded'", (time.time(), owner, key))