987 lines
41 KiB
Python
987 lines
41 KiB
Python
"""SQLite task truth; each connection belongs to its calling thread."""
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from account_log import UI_LIMIT, visible
|
|
from follow_user import validate_uid
|
|
|
|
|
|
def validate_rule(rule):
|
|
rule = dict(rule)
|
|
rule["require_follow"] = False
|
|
rule.setdefault("cooldown", 14400)
|
|
rule.setdefault("work_mode", "all")
|
|
rule.setdefault("work_ids", [])
|
|
kinds = rule.get("kinds", [])
|
|
if not isinstance(kinds, list) or any(
|
|
k not in ("digg", "follow", "comment", "general_notice") for k in kinds
|
|
):
|
|
raise ValueError("请选择有效通知类型")
|
|
for flag in ("enabled", "follow", "dm", "require_follow"):
|
|
if type(rule.get(flag)) is not bool:
|
|
raise ValueError("规则开关必须为布尔值")
|
|
if not isinstance(rule.get("text"), str) or len(rule["text"]) > 1000:
|
|
raise ValueError("私信正文最多 1000 字")
|
|
if rule["enabled"] and (not kinds or not (rule["follow"] or rule["dm"])):
|
|
raise ValueError("启用规则必须选择通知类型及动作")
|
|
if rule["dm"] and not rule["text"].strip():
|
|
raise ValueError("私信正文不能为空")
|
|
if type(rule.get("interval")) is not int or not 5 <= rule["interval"] <= 86400:
|
|
raise ValueError("执行间隔需为 5..86400 秒")
|
|
if type(rule["cooldown"]) is not int or not 0 <= rule["cooldown"] <= 31536000:
|
|
raise ValueError("同 UID 冷却需为 0..31536000 秒")
|
|
if rule["work_mode"] not in ("all", "selected"):
|
|
raise ValueError("作品监控模式无效")
|
|
if (
|
|
not isinstance(rule["work_ids"], list)
|
|
or len(rule["work_ids"]) > 50000
|
|
or any(
|
|
not isinstance(item, str) or not item.isascii() or not item.isdecimal()
|
|
for item in rule["work_ids"]
|
|
)
|
|
):
|
|
raise ValueError("作品 ID 列表无效")
|
|
rule["work_ids"] = list(dict.fromkeys(rule["work_ids"]))
|
|
return rule
|
|
|
|
|
|
DEFAULT_RULE = {
|
|
"enabled": False,
|
|
"kinds": ["follow"],
|
|
"follow": False,
|
|
"dm": False,
|
|
"require_follow": False,
|
|
"text": "",
|
|
"interval": 30,
|
|
"cooldown": 14400,
|
|
"work_mode": "all",
|
|
"work_ids": [],
|
|
}
|
|
|
|
|
|
def decode(value):
|
|
try:
|
|
return json.loads(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("持久化数据损坏,请从备份恢复") from exc
|
|
|
|
|
|
def notice_work_id(notice):
|
|
values = [notice]
|
|
values.extend(
|
|
notice.get(key)
|
|
for key in ("comment", "digg", "favorite", "collect", "general_notice")
|
|
)
|
|
for value in values:
|
|
if not isinstance(value, dict):
|
|
continue
|
|
for candidate in (value, value.get("aweme"), value.get("item")):
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
ident = candidate.get("aweme_id") or candidate.get("awemeId")
|
|
if isinstance(ident, str) and ident.isascii() and ident.isdecimal():
|
|
return ident
|
|
if type(ident) is int and ident > 0:
|
|
return str(ident)
|
|
return ""
|
|
|
|
|
|
def notice_kind(notice):
|
|
if notice.get("comment"):
|
|
return "comment"
|
|
if notice.get("follow"):
|
|
return "follow"
|
|
if notice.get("digg"):
|
|
return "digg"
|
|
return "general_notice"
|
|
|
|
|
|
class Store:
|
|
def __init__(self, root, audit=None):
|
|
self.audit = audit
|
|
self._audit_pending = []
|
|
self._dispatch_wait = {}
|
|
self._dependency_wait = {}
|
|
self.root = Path(root).resolve()
|
|
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
self.db = sqlite3.connect(
|
|
self.root / "accounts.sqlite3", isolation_level=None, timeout=10
|
|
)
|
|
self.db.row_factory = sqlite3.Row
|
|
self.db.execute("PRAGMA foreign_keys=ON")
|
|
self.db.execute("PRAGMA journal_mode=WAL")
|
|
self.db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('main','worker')),
|
|
name TEXT NOT NULL, uid TEXT UNIQUE, owner TEXT REFERENCES accounts(id),
|
|
avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '',
|
|
rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0,
|
|
profile TEXT NOT NULL DEFAULT '{}',
|
|
CHECK(role='worker' OR owner IS NULL));
|
|
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS inbox (
|
|
source TEXT REFERENCES accounts(id), nid TEXT, state TEXT NOT NULL DEFAULT 'pending',
|
|
retry_count INTEGER NOT NULL DEFAULT 0, next_retry_at REAL NOT NULL DEFAULT 0,
|
|
PRIMARY KEY(source,nid));
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY, source TEXT NOT NULL REFERENCES accounts(id), nid TEXT NOT NULL,
|
|
created REAL NOT NULL, targets TEXT NOT NULL, rule TEXT NOT NULL, state TEXT NOT NULL,
|
|
origin TEXT NOT NULL DEFAULT 'live' CHECK(origin IN ('live','history')),
|
|
UNIQUE(source,nid));
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY, event INTEGER NOT NULL REFERENCES events(id),
|
|
source TEXT NOT NULL REFERENCES accounts(id), worker TEXT NOT NULL REFERENCES accounts(id),
|
|
target TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('follow','dm')),
|
|
params TEXT NOT NULL, dependency INTEGER REFERENCES tasks(id),
|
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','succeeded','failed','unknown','cancelled')),
|
|
result TEXT NOT NULL DEFAULT '', updated REAL NOT NULL,
|
|
UNIQUE(event,worker,target,action));
|
|
CREATE TABLE IF NOT EXISTS cooldowns (
|
|
source TEXT NOT NULL REFERENCES accounts(id), target TEXT NOT NULL,
|
|
last_at REAL NOT NULL, event INTEGER NOT NULL REFERENCES events(id),
|
|
PRIMARY KEY(source,target));
|
|
DROP INDEX IF EXISTS worker_single_running;
|
|
""")
|
|
self.migrate_accounts()
|
|
if "deleted" not in {
|
|
r["name"] for r in self.db.execute("PRAGMA table_info(accounts)")
|
|
}:
|
|
self.db.execute(
|
|
"ALTER TABLE accounts ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0"
|
|
)
|
|
columns = {r["name"] for r in self.db.execute("PRAGMA table_info(inbox)")}
|
|
event_columns = {
|
|
r["name"] for r in self.db.execute("PRAGMA table_info(events)")
|
|
}
|
|
task_columns = {r["name"] for r in self.db.execute("PRAGMA table_info(tasks)")}
|
|
with self.transaction():
|
|
if "retry_count" not in columns:
|
|
self.db.execute(
|
|
"ALTER TABLE inbox ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"
|
|
)
|
|
if "next_retry_at" not in columns:
|
|
self.db.execute(
|
|
"ALTER TABLE inbox ADD COLUMN next_retry_at REAL NOT NULL DEFAULT 0"
|
|
)
|
|
if "origin" not in event_columns:
|
|
self.db.execute(
|
|
"ALTER TABLE events ADD COLUMN origin TEXT NOT NULL DEFAULT 'live' CHECK(origin IN ('live','history'))"
|
|
)
|
|
if "priority" not in task_columns:
|
|
self.db.execute(
|
|
"ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 100"
|
|
)
|
|
for row in self.db.execute("SELECT id,rule FROM accounts").fetchall():
|
|
rule = validate_rule(decode(row["rule"]))
|
|
self.db.execute(
|
|
"UPDATE accounts SET rule=? WHERE id=?",
|
|
(json.dumps(rule), row["id"]),
|
|
)
|
|
self.db.executescript("""
|
|
CREATE TRIGGER IF NOT EXISTS owner_insert BEFORE INSERT ON accounts
|
|
WHEN NEW.owner IS NOT NULL AND NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.owner AND role='main')
|
|
BEGIN SELECT RAISE(ABORT,'owner must be main'); END;
|
|
CREATE TRIGGER IF NOT EXISTS owner_update BEFORE UPDATE OF owner ON accounts
|
|
WHEN NEW.owner IS NOT NULL AND NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.owner AND role='main')
|
|
BEGIN SELECT RAISE(ABORT,'owner must be main'); END;
|
|
""")
|
|
|
|
def migrate_accounts(self):
|
|
columns = {r["name"]: r for r in self.db.execute("PRAGMA table_info(accounts)")}
|
|
if not columns["uid"]["notnull"]:
|
|
return
|
|
backup = sqlite3.connect(
|
|
self.root / ("before-onboarding-" + uuid.uuid4().hex + ".sqlite3")
|
|
)
|
|
try:
|
|
self.db.backup(backup)
|
|
finally:
|
|
backup.close()
|
|
self.db.execute("PRAGMA foreign_keys=OFF")
|
|
try:
|
|
with self.transaction():
|
|
self.db.execute("""CREATE TABLE accounts_new (
|
|
id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('main','worker')),
|
|
name TEXT NOT NULL, uid TEXT UNIQUE, owner TEXT REFERENCES accounts(id),
|
|
avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '',
|
|
rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0,
|
|
profile TEXT NOT NULL DEFAULT '{}', CHECK(role='worker' OR owner IS NULL))""")
|
|
self.db.execute("""INSERT INTO accounts_new(id,role,name,uid,owner,avatar,nickname,rule,rr)
|
|
SELECT id,role,name,uid,owner,avatar,nickname,rule,rr FROM accounts ORDER BY rowid""")
|
|
self.db.execute("DROP TABLE accounts")
|
|
self.db.execute("ALTER TABLE accounts_new RENAME TO accounts")
|
|
if self.db.execute("PRAGMA foreign_key_check").fetchone():
|
|
raise RuntimeError("账号升级校验失败,已回滚;原数据库备份已保留")
|
|
finally:
|
|
self.db.execute("PRAGMA foreign_keys=ON")
|
|
|
|
def log(self, stage, message, level=logging.INFO, **fields):
|
|
if self.audit is None:
|
|
return
|
|
labels = {
|
|
"source": "大号",
|
|
"worker": "小号",
|
|
"account": "账号",
|
|
"owner": "归属",
|
|
"event": "事件",
|
|
"task": "任务",
|
|
"nid": "通知标识",
|
|
"target": "目标",
|
|
"action": "动作",
|
|
}
|
|
values = {}
|
|
for key, value in fields.items():
|
|
if key in ("source", "worker", "account", "owner") and value:
|
|
row = self.db.execute(
|
|
"SELECT name,deleted FROM accounts WHERE id=?", (value,)
|
|
).fetchone()
|
|
value = (
|
|
(row["name"] + ("(已删除)" if row["deleted"] else ""))
|
|
if row
|
|
else "不存在的账号"
|
|
)
|
|
elif key == "action":
|
|
value = {"follow": "关注", "dm": "私信"}.get(value, value)
|
|
values[labels.get(key, key)] = value
|
|
entry = (stage, message, level, values)
|
|
if self.db.in_transaction:
|
|
self._audit_pending.append(entry)
|
|
else:
|
|
self.audit.record(stage, message, level, **values)
|
|
|
|
@contextmanager
|
|
def transaction(self):
|
|
self.db.execute("BEGIN IMMEDIATE")
|
|
start = len(self._audit_pending)
|
|
try:
|
|
yield
|
|
self.db.execute("COMMIT")
|
|
except BaseException:
|
|
self.db.execute("ROLLBACK")
|
|
del self._audit_pending[start:]
|
|
self._dispatch_wait.clear()
|
|
self._dependency_wait.clear()
|
|
raise
|
|
entries = self._audit_pending[start:]
|
|
del self._audit_pending[start:]
|
|
if self.audit is not None:
|
|
for stage, message, level, fields in entries:
|
|
self.audit.record(stage, message, level, **fields)
|
|
|
|
def close(self):
|
|
self.db.close()
|
|
|
|
def setting(self, key, default=None):
|
|
row = self.db.execute(
|
|
"SELECT value FROM settings WHERE key=?", (key,)
|
|
).fetchone()
|
|
return decode(row[0]) if row else default
|
|
|
|
def set_setting(self, key, value):
|
|
self.db.execute(
|
|
"INSERT OR REPLACE INTO settings VALUES (?,?)", (key, json.dumps(value))
|
|
)
|
|
|
|
def accounts(self, include_deleted=False):
|
|
return [
|
|
dict(r)
|
|
for r in self.db.execute(
|
|
"SELECT * FROM accounts WHERE deleted=0 OR ? ORDER BY rowid",
|
|
(include_deleted,),
|
|
)
|
|
]
|
|
|
|
def account(self, ident):
|
|
row = self.db.execute(
|
|
"SELECT * FROM accounts WHERE id=? AND deleted=0", (ident,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise ValueError("账号不存在")
|
|
return dict(row)
|
|
|
|
def add(self, role, name, uid=None, owner=None):
|
|
if (
|
|
role not in ("main", "worker")
|
|
or not isinstance(name, str)
|
|
or not name.strip()
|
|
or len(name) > 80
|
|
):
|
|
raise ValueError("账号角色或名称无效")
|
|
if uid is not None:
|
|
validate_uid(uid)
|
|
ident = uuid.uuid4().hex
|
|
self.db.execute(
|
|
"INSERT INTO accounts(id,role,name,uid,owner,rule) VALUES (?,?,?,?,?,?)",
|
|
(ident, role, name.strip(), uid, owner, json.dumps(DEFAULT_RULE)),
|
|
)
|
|
self.log(
|
|
"账号添加",
|
|
"已创建独立账号,等待手动登录;业务默认不启动",
|
|
account=ident,
|
|
owner=owner,
|
|
)
|
|
return ident
|
|
|
|
def bind_profile(self, ident, profile):
|
|
uid = validate_uid(profile.get("uid"))
|
|
saved = visible(profile)
|
|
if not isinstance(saved, dict):
|
|
raise ValueError("账号资料格式无效")
|
|
saved["uid"] = uid
|
|
with self.transaction():
|
|
account = self.account(ident)
|
|
if account["uid"] is not None and account["uid"] != uid:
|
|
raise ValueError(
|
|
"当前登录账号与已绑定账号不同,请登录原账号;不会覆盖原 UID"
|
|
)
|
|
if self.db.execute(
|
|
"SELECT 1 FROM accounts WHERE uid=? AND id<>?", (uid, ident)
|
|
).fetchone():
|
|
raise ValueError("该抖音账号已添加,请在浏览器中切换到另一个账号")
|
|
if account["uid"] is None:
|
|
self.log(
|
|
"身份绑定",
|
|
"首次身份核验通过,绑定已锁定;业务资料按原文输出,认证凭据保持隐藏",
|
|
account=ident,
|
|
UID=uid,
|
|
平台资料=saved,
|
|
)
|
|
self.db.execute(
|
|
"UPDATE accounts SET uid=?,nickname=?,avatar=?,profile=? WHERE id=?",
|
|
(
|
|
uid,
|
|
profile.get("nickname") or "",
|
|
profile.get("avatar") or profile.get("avatar_url") or "",
|
|
json.dumps(saved, ensure_ascii=False),
|
|
ident,
|
|
),
|
|
)
|
|
|
|
def set_rule(self, ident, rule):
|
|
if self.account(ident)["role"] != "main":
|
|
raise ValueError("只有大号可配置规则")
|
|
rule = validate_rule(rule)
|
|
self.db.execute(
|
|
"UPDATE accounts SET rule=? WHERE id=?", (json.dumps(rule), ident)
|
|
)
|
|
self.log(
|
|
"规则更新",
|
|
"已保存;已入队任务保留原快照,业务字段按原文写日志",
|
|
source=ident,
|
|
启用=rule["enabled"],
|
|
通知类型=",".join(rule["kinds"]),
|
|
关注=rule["follow"],
|
|
私信=rule["dm"],
|
|
双动作="关注和私信并行" if rule["follow"] and rule["dm"] else "单动作",
|
|
动作间隔秒=rule["interval"],
|
|
同组UID冷却秒=rule["cooldown"],
|
|
作品监控模式=rule["work_mode"],
|
|
作品ID=rule["work_ids"],
|
|
私信正文=rule["text"],
|
|
)
|
|
|
|
def set_work_filter(self, ident, mode, ids):
|
|
rule = decode(self.account(ident)["rule"])
|
|
rule["work_mode"] = mode
|
|
rule["work_ids"] = ids
|
|
self.set_rule(ident, rule)
|
|
|
|
def move(self, worker, owner):
|
|
with self.transaction():
|
|
account = self.account(worker)
|
|
if account["role"] != "worker":
|
|
raise ValueError("只有小号可切换归属")
|
|
if owner is not None and self.account(owner)["role"] != "main":
|
|
raise ValueError("归属必须为大号")
|
|
if account["owner"] == owner:
|
|
return
|
|
if self.db.execute(
|
|
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (worker,)
|
|
).fetchone():
|
|
raise ValueError("正在执行任务,请等待结束后切换")
|
|
self.cancel_pending(worker, "归属变更,取消旧组待执行任务")
|
|
self.db.execute("UPDATE accounts SET owner=? WHERE id=?", (owner, worker))
|
|
self.log(
|
|
"归属变更",
|
|
"已变更归属;旧任务不改派、不重发",
|
|
worker=worker,
|
|
owner=owner or "",
|
|
)
|
|
|
|
def cancel_pending(self, worker, reason):
|
|
rows = self.db.execute(
|
|
"UPDATE tasks SET status='cancelled',result=?,updated=? WHERE worker=? AND status='pending' RETURNING id,event,source,target,action",
|
|
(reason, time.time(), worker),
|
|
).fetchall()
|
|
for row in rows:
|
|
self.log(
|
|
"任务取消",
|
|
reason,
|
|
worker=worker,
|
|
task=row["id"],
|
|
event=row["event"],
|
|
source=row["source"],
|
|
target=row["target"],
|
|
action=row["action"],
|
|
)
|
|
|
|
def delete_worker(self, ident):
|
|
"""Call only after the engine has safely stopped/closed/erased this profile."""
|
|
with self.transaction():
|
|
account = self.account(ident)
|
|
if account["role"] != "worker":
|
|
raise ValueError("此操作只支持删除小号")
|
|
if self.db.execute(
|
|
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (ident,)
|
|
).fetchone():
|
|
raise ValueError("正在执行任务,不能删除小号")
|
|
self.cancel_pending(ident, "小号已删除,取消待执行任务,不改派、不重发")
|
|
self.db.execute(
|
|
"UPDATE accounts SET deleted=1,uid=NULL,owner=NULL,nickname='',avatar='',profile='{}',rule=?,rr=0 WHERE id=?",
|
|
(json.dumps(DEFAULT_RULE), ident),
|
|
)
|
|
self.db.execute(
|
|
"DELETE FROM settings WHERE key IN (?,?)",
|
|
("fingerprint_browser:" + ident, "browser:" + ident),
|
|
)
|
|
self.log(
|
|
"小号删除",
|
|
"浏览器与登录目录已清除;账号已移除,历史任务和日志保留,允许重新添加原账号",
|
|
worker=ident,
|
|
)
|
|
|
|
def record_push(self, source, ids):
|
|
if self.account(source)["role"] != "main":
|
|
raise ValueError("通知来源必须为大号")
|
|
added = 0
|
|
with self.transaction():
|
|
for nid in ids:
|
|
validate_uid(nid)
|
|
cur = self.db.execute(
|
|
"INSERT OR IGNORE INTO inbox(source,nid) VALUES (?,?)",
|
|
(source, nid),
|
|
)
|
|
added += cur.rowcount
|
|
self.log(
|
|
"通知接收" if cur.rowcount else "通知去重",
|
|
"推送 ID 已持久化,等待详情"
|
|
if cur.rowcount
|
|
else "重复推送已忽略,不重复执行且不重置重试时间",
|
|
source=source,
|
|
nid=nid,
|
|
)
|
|
return added
|
|
|
|
def due_details(self, source):
|
|
return [
|
|
r["nid"]
|
|
for r in self.db.execute(
|
|
"SELECT nid FROM inbox WHERE source=? AND state='pending' AND next_retry_at<=? ORDER BY next_retry_at,rowid LIMIT 20",
|
|
(source, time.time()),
|
|
)
|
|
]
|
|
|
|
def defer_details(self, source, missing):
|
|
# ponytail: retain unavailable IDs indefinitely; add explicit discard/retention only if volume requires it.
|
|
now = time.time()
|
|
with self.transaction():
|
|
for nid in missing:
|
|
row = self.db.execute(
|
|
"SELECT retry_count FROM inbox WHERE source=? AND nid=? AND state='pending'",
|
|
(source, nid),
|
|
).fetchone()
|
|
if row is not None:
|
|
delay = min(30 * 2 ** min(row["retry_count"], 4), 300)
|
|
self.db.execute(
|
|
"UPDATE inbox SET retry_count=retry_count+1,next_retry_at=? WHERE source=? AND nid=? AND state='pending'",
|
|
(now + delay, source, nid),
|
|
)
|
|
self.log(
|
|
"详情延期",
|
|
"本次未返回该通知,保留 ID,不阻塞新通知",
|
|
logging.WARNING,
|
|
source=source,
|
|
nid=nid,
|
|
重试次数=row["retry_count"] + 1,
|
|
等待秒=delay,
|
|
下次允许重试=time.strftime(
|
|
"%Y-%m-%d %H:%M:%S", time.localtime(now + delay)
|
|
),
|
|
)
|
|
|
|
def pending_details_count(self, source):
|
|
return self.db.execute(
|
|
"SELECT COUNT(*) FROM inbox WHERE source=? AND state='pending'", (source,)
|
|
).fetchone()[0]
|
|
|
|
def ingest(self, source, notice, origin="live"):
|
|
if origin not in ("live", "history"):
|
|
raise ValueError("事件来源无效")
|
|
account = self.account(source)
|
|
if account["role"] != "main" or str(notice.get("user_id")) != account["uid"]:
|
|
raise ValueError("通知所属身份不符")
|
|
nid = validate_uid(notice.get("nid_str") or str(notice.get("nid", "")))
|
|
kind = notice_kind(notice)
|
|
detail = (
|
|
notice.get(kind) or notice.get("favorite") or notice.get("collect") or {}
|
|
)
|
|
users = detail.get("from_user") or []
|
|
if isinstance(users, dict):
|
|
users = [users]
|
|
if not users and (detail.get("comment") or {}).get("user"):
|
|
users = [detail["comment"]["user"]]
|
|
targets = sorted({validate_uid(str(u.get("uid", ""))) for u in users})
|
|
rule = validate_rule(decode(account["rule"]))
|
|
work_id = notice_work_id(notice)
|
|
work_allowed = (
|
|
kind == "follow"
|
|
or rule["work_mode"] == "all"
|
|
or work_id in rule["work_ids"]
|
|
)
|
|
enabled = (
|
|
rule["enabled"] and kind in rule["kinds"] and bool(targets) and work_allowed
|
|
)
|
|
created = notice.get("create_time") or notice.get("createTime") or time.time()
|
|
if type(created) not in (int, float) or created <= 0:
|
|
created = time.time()
|
|
if created > 10_000_000_000:
|
|
created /= 1000
|
|
with self.transaction():
|
|
cur = self.db.execute(
|
|
"INSERT OR IGNORE INTO events(source,nid,created,targets,rule,state,origin) VALUES (?,?,?,?,?,?,?)",
|
|
(
|
|
source,
|
|
nid,
|
|
created,
|
|
json.dumps(targets),
|
|
json.dumps(rule),
|
|
"waiting" if enabled else "ignored",
|
|
origin,
|
|
),
|
|
)
|
|
event = self.db.execute(
|
|
"SELECT id,origin FROM events WHERE source=? AND nid=?", (source, nid)
|
|
).fetchone()
|
|
if not cur.rowcount and origin == "live" and event["origin"] == "history":
|
|
self.db.execute(
|
|
"UPDATE events SET origin='live' WHERE id=?", (event["id"],)
|
|
)
|
|
promoted = self.db.execute(
|
|
"UPDATE tasks SET priority=100 WHERE event=? AND status='pending'",
|
|
(event["id"],),
|
|
).rowcount
|
|
self.log(
|
|
"新消息优先",
|
|
"该通知此前从历史列表进入;已提升未执行任务优先级",
|
|
source=source,
|
|
event=event["id"],
|
|
nid=nid,
|
|
提升任务数=promoted,
|
|
)
|
|
if cur.rowcount:
|
|
reason = (
|
|
"规则匹配,等待组内分配"
|
|
if enabled
|
|
else "规则未启用,本通知不生成任务"
|
|
if not rule["enabled"]
|
|
else "通知类型未勾选,本通知不生成任务"
|
|
if kind not in rule["kinds"]
|
|
else "未提取到有效通知来源用户,不生成任务"
|
|
if not targets
|
|
else "通知不属于当前选择的作品,不生成任务"
|
|
)
|
|
self.log(
|
|
"详情入库",
|
|
"详情已取得且所属身份一致",
|
|
source=source,
|
|
nid=nid,
|
|
event=event["id"],
|
|
来源="实时通知" if origin == "live" else "历史列表",
|
|
通知类型={
|
|
"digg": "点赞",
|
|
"comment": "评论",
|
|
"follow": "关注",
|
|
"general_notice": "其他作品互动",
|
|
}[kind],
|
|
作品ID=work_id,
|
|
目标数量=len(targets),
|
|
业务数据=notice,
|
|
)
|
|
self.log(
|
|
"规则匹配" if enabled else "通知跳过",
|
|
reason,
|
|
source=source,
|
|
event=event["id"],
|
|
)
|
|
elif not (origin == "live" and event["origin"] == "history"):
|
|
self.log(
|
|
"详情去重",
|
|
"已有该通知,不重复生成事件或任务",
|
|
source=source,
|
|
event=event["id"],
|
|
nid=nid,
|
|
)
|
|
self.db.execute(
|
|
"UPDATE inbox SET state='done' WHERE source=? AND nid=?", (source, nid)
|
|
)
|
|
self._dispatch(source)
|
|
return bool(cur.rowcount)
|
|
|
|
def dispatch(self, source):
|
|
with self.transaction():
|
|
self._dispatch(source)
|
|
|
|
def _dispatch(self, source):
|
|
main = self.account(source)
|
|
events = self.db.execute(
|
|
"SELECT * FROM events WHERE source=? AND state='waiting' ORDER BY CASE origin WHEN 'live' THEN 0 ELSE 1 END,id",
|
|
(source,),
|
|
).fetchall()
|
|
if not events:
|
|
self._dispatch_wait.pop(source, None)
|
|
return
|
|
workers = list(
|
|
self.db.execute(
|
|
"SELECT id,uid FROM accounts WHERE owner=? AND role='worker' AND uid IS NOT NULL AND deleted=0 ORDER BY rowid",
|
|
(source,),
|
|
)
|
|
)
|
|
reason = (
|
|
"大号尚未绑定身份"
|
|
if main["uid"] is None
|
|
else "大号规则已关闭,保留待分配通知"
|
|
if not decode(main["rule"])["enabled"]
|
|
else "没有已绑定且归属本组的小号,保留待分配通知"
|
|
if not workers
|
|
else ""
|
|
)
|
|
if reason:
|
|
if self._dispatch_wait.get(source) != (reason, len(events)):
|
|
self.log("分配等待", reason, source=source, 等待通知数=len(events))
|
|
self._dispatch_wait[source] = (reason, len(events))
|
|
return
|
|
self._dispatch_wait.pop(source, None)
|
|
rr = main["rr"]
|
|
for event in events:
|
|
worker = workers[rr % len(workers)]
|
|
rr += 1
|
|
rule = decode(event["rule"])
|
|
created = skipped = 0
|
|
self.log(
|
|
"通知分配",
|
|
"按组内轮询选定一个小号",
|
|
source=source,
|
|
worker=worker["id"],
|
|
event=event["id"],
|
|
)
|
|
for target in decode(event["targets"]):
|
|
if target in (worker["uid"], main["uid"]):
|
|
skipped += 1
|
|
self.log(
|
|
"任务跳过",
|
|
"触发者就是执行小号自身,禁止关注自己或私信自己"
|
|
if target == worker["uid"]
|
|
else "目标是来源大号,安全规则禁止操作",
|
|
source=source,
|
|
worker=worker["id"],
|
|
event=event["id"],
|
|
target=target,
|
|
)
|
|
continue
|
|
active = self.db.execute(
|
|
"""SELECT t.id,t.event,t.status,e.origin FROM tasks t JOIN events e ON e.id=t.event
|
|
WHERE t.source=? AND t.target=? AND t.status IN ('pending','running','unknown') ORDER BY t.id""",
|
|
(source, target),
|
|
).fetchall()
|
|
if event["origin"] == "live":
|
|
history = [
|
|
row
|
|
for row in active
|
|
if row["status"] == "pending" and row["origin"] == "history"
|
|
]
|
|
for row in history:
|
|
self.db.execute(
|
|
"UPDATE tasks SET status='cancelled',result='新消息优先,取消未执行历史任务',updated=? WHERE id=? AND status='pending'",
|
|
(time.time(), row["id"]),
|
|
)
|
|
self.log(
|
|
"新消息优先",
|
|
"同 UID 新消息到达,取消尚未执行的历史任务,不重复打扰",
|
|
source=source,
|
|
event=event["id"],
|
|
task=row["id"],
|
|
target=target,
|
|
)
|
|
active = [row for row in active if row not in history]
|
|
if active:
|
|
skipped += 1
|
|
self.log(
|
|
"冷却跳过",
|
|
"同组同 UID 已有待执行、执行中或待核对任务,不重复排队",
|
|
source=source,
|
|
event=event["id"],
|
|
target=target,
|
|
现有状态=active[-1]["status"],
|
|
)
|
|
continue
|
|
cooldown = rule.get("cooldown", 14400)
|
|
previous = self.db.execute(
|
|
"SELECT last_at FROM cooldowns WHERE source=? AND target=?",
|
|
(source, target),
|
|
).fetchone()
|
|
now = time.time()
|
|
if cooldown and previous and previous["last_at"] + cooldown > now:
|
|
skipped += 1
|
|
self.log(
|
|
"冷却跳过",
|
|
"同组同 UID 仍在冷却期,本通知不创建任务",
|
|
source=source,
|
|
event=event["id"],
|
|
target=target,
|
|
剩余秒=max(1, round(previous["last_at"] + cooldown - now)),
|
|
)
|
|
continue
|
|
for action in ("follow", "dm"):
|
|
if not rule[action]:
|
|
continue
|
|
cur = self.db.execute(
|
|
"INSERT INTO tasks(event,source,worker,target,action,params,dependency,priority,updated) VALUES (?,?,?,?,?,?,NULL,?,?)",
|
|
(
|
|
event["id"],
|
|
source,
|
|
worker["id"],
|
|
target,
|
|
action,
|
|
json.dumps(rule),
|
|
100 if event["origin"] == "live" else 0,
|
|
now,
|
|
),
|
|
)
|
|
created += 1
|
|
self.log(
|
|
"任务入队",
|
|
"已创建任务,等待执行;同一通知的关注和私信将并行",
|
|
source=source,
|
|
worker=worker["id"],
|
|
event=event["id"],
|
|
task=cur.lastrowid,
|
|
target=target,
|
|
action=action,
|
|
)
|
|
self.log(
|
|
"分配完成",
|
|
"分配流程已完成;不代表动作已执行",
|
|
source=source,
|
|
worker=worker["id"],
|
|
event=event["id"],
|
|
生成任务数=created,
|
|
跳过目标数=skipped,
|
|
)
|
|
self.db.execute(
|
|
"UPDATE events SET state='dispatched' WHERE id=?", (event["id"],)
|
|
)
|
|
self.db.execute("UPDATE accounts SET rr=? WHERE id=?", (rr, source))
|
|
|
|
def _claim(self, worker, batch):
|
|
with self.transaction():
|
|
account = self.account(worker)
|
|
if (
|
|
account["role"] != "worker"
|
|
or account["owner"] is None
|
|
or account["uid"] is None
|
|
):
|
|
return []
|
|
owner = self.account(account["owner"])
|
|
if owner["uid"] is None or not decode(owner["rule"])["enabled"]:
|
|
return []
|
|
if self.db.execute(
|
|
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (worker,)
|
|
).fetchone():
|
|
return []
|
|
cancelled = self.db.execute(
|
|
"""UPDATE tasks SET status='cancelled',result='前置关注未成功',updated=?
|
|
WHERE worker=? AND status='pending' AND dependency IN
|
|
(SELECT id FROM tasks WHERE status IN ('failed','cancelled')) RETURNING id,event,action""",
|
|
(time.time(), worker),
|
|
).fetchall()
|
|
for row in cancelled:
|
|
self.log(
|
|
"任务取消",
|
|
"前置关注未成功,取消旧版依赖步骤,不重发",
|
|
worker=worker,
|
|
source=owner["id"],
|
|
task=row["id"],
|
|
event=row["event"],
|
|
action=row["action"],
|
|
)
|
|
first = self.db.execute(
|
|
"""SELECT t.* FROM tasks t WHERE worker=? AND source=? AND status='pending'
|
|
AND (dependency IS NULL OR EXISTS(SELECT 1 FROM tasks d WHERE d.id=t.dependency AND d.status='succeeded'))
|
|
ORDER BY priority DESC,id LIMIT 1""",
|
|
(worker, account["owner"]),
|
|
).fetchone()
|
|
if first is None:
|
|
blocked = self.db.execute(
|
|
"SELECT t.id,t.dependency,d.status FROM tasks t JOIN tasks d ON d.id=t.dependency WHERE t.worker=? AND t.source=? AND t.status='pending' ORDER BY t.id LIMIT 1",
|
|
(worker, account["owner"]),
|
|
).fetchone()
|
|
key = tuple(blocked) if blocked else None
|
|
if key and self._dependency_wait.get(worker) != key:
|
|
self.log(
|
|
"依赖等待",
|
|
"旧版前置关注尚未确认成功;待核对不会自动重发",
|
|
worker=worker,
|
|
source=owner["id"],
|
|
task=blocked["id"],
|
|
前置任务=blocked["dependency"],
|
|
前置状态=blocked["status"],
|
|
)
|
|
self._dependency_wait[worker] = key
|
|
return []
|
|
self._dependency_wait.pop(worker, None)
|
|
tasks = [first]
|
|
if batch and first["dependency"] is None:
|
|
tasks = self.db.execute(
|
|
"""SELECT * FROM tasks WHERE worker=? AND source=? AND event=? AND target=?
|
|
AND status='pending' AND dependency IS NULL ORDER BY id""",
|
|
(worker, first["source"], first["event"], first["target"]),
|
|
).fetchall()
|
|
now = time.time()
|
|
self.db.executemany(
|
|
"UPDATE tasks SET status='running',updated=? WHERE id=?",
|
|
[(now, task["id"]) for task in tasks],
|
|
)
|
|
cooldown = decode(first["params"]).get("cooldown", 14400)
|
|
if cooldown:
|
|
self.db.execute(
|
|
"""INSERT INTO cooldowns(source,target,last_at,event) VALUES (?,?,?,?)
|
|
ON CONFLICT(source,target) DO UPDATE SET last_at=excluded.last_at,event=excluded.event""",
|
|
(first["source"], first["target"], now, first["event"]),
|
|
)
|
|
self.log(
|
|
"冷却开始",
|
|
"任务已实际领取;从现在起组内所有小号共享该 UID 冷却",
|
|
source=first["source"],
|
|
worker=worker,
|
|
event=first["event"],
|
|
target=first["target"],
|
|
冷却秒=cooldown,
|
|
)
|
|
if len(tasks) > 1:
|
|
self.log(
|
|
"并行批次",
|
|
"同一通知的关注和私信同时开始,互不依赖且不等待动作间隔",
|
|
source=first["source"],
|
|
worker=worker,
|
|
event=first["event"],
|
|
target=first["target"],
|
|
任务数=len(tasks),
|
|
)
|
|
for task in tasks:
|
|
self.log(
|
|
"任务开始",
|
|
"已领取任务,即将发起动作;中断后需人工核对,不自动重发",
|
|
source=task["source"],
|
|
worker=worker,
|
|
event=task["event"],
|
|
task=task["id"],
|
|
target=task["target"],
|
|
action=task["action"],
|
|
)
|
|
return [dict(task) for task in tasks]
|
|
|
|
def claim(self, worker):
|
|
tasks = self._claim(worker, False)
|
|
return tasks[0] if tasks else None
|
|
|
|
def claim_batch(self, worker):
|
|
return self._claim(worker, True)
|
|
|
|
def finish(self, task_id, status, result, elapsed_ms=None):
|
|
if status not in ("succeeded", "failed", "unknown"):
|
|
raise ValueError("无效执行结果")
|
|
task = self.db.execute(
|
|
"UPDATE tasks SET status=?,result=?,updated=? WHERE id=? AND status='running' RETURNING id,event,source,worker,target,action",
|
|
(status, json.dumps(result, ensure_ascii=False), time.time(), task_id),
|
|
).fetchone()
|
|
if task:
|
|
reason = {
|
|
"succeeded": "动作成功,结果已落库",
|
|
"failed": "动作明确失败,不自动重发",
|
|
"unknown": "结果不确定,必须人工核对;不会自动重发,依赖步骤继续等待",
|
|
}[status]
|
|
code = (
|
|
result.get("code", result.get("status_code"))
|
|
if isinstance(result, dict)
|
|
else None
|
|
)
|
|
self.log(
|
|
"任务结果",
|
|
reason,
|
|
logging.INFO if status == "succeeded" else logging.WARNING,
|
|
task=task_id,
|
|
event=task["event"],
|
|
source=task["source"],
|
|
worker=task["worker"],
|
|
target=task["target"],
|
|
action=task["action"],
|
|
耗时毫秒=elapsed_ms,
|
|
返回码=code,
|
|
)
|
|
|
|
def recover(self):
|
|
rows = self.db.execute(
|
|
"UPDATE tasks SET status='unknown',result='程序中断,先人工核对,不自动重发',updated=? WHERE status='running' RETURNING id,event,source,worker,action",
|
|
(time.time(),),
|
|
).fetchall()
|
|
for row in rows:
|
|
self.log(
|
|
"中断恢复",
|
|
"上次执行中断,已改为待核对,不自动重发",
|
|
logging.WARNING,
|
|
task=row["id"],
|
|
event=row["event"],
|
|
source=row["source"],
|
|
worker=row["worker"],
|
|
action=row["action"],
|
|
)
|
|
|
|
def resolve(self, task_id, succeeded):
|
|
task = self.db.execute(
|
|
"UPDATE tasks SET status=?,result='用户已人工核对确认',updated=? WHERE id=? AND status='unknown' RETURNING worker,source,event",
|
|
("succeeded" if succeeded else "failed", time.time(), task_id),
|
|
).fetchone()
|
|
if task:
|
|
self.log(
|
|
"人工核对",
|
|
"用户确认成功,依赖步骤可继续"
|
|
if succeeded
|
|
else "用户确认未成功,不重发",
|
|
task=task_id,
|
|
event=task["event"],
|
|
source=task["source"],
|
|
worker=task["worker"],
|
|
)
|
|
else:
|
|
self.log(
|
|
"人工核对",
|
|
"没有可确认的待核对任务,未修改记录",
|
|
logging.WARNING,
|
|
task=task_id,
|
|
)
|
|
|
|
def tasks(self):
|
|
return [
|
|
dict(r)
|
|
for r in self.db.execute(
|
|
"SELECT * FROM tasks ORDER BY id DESC LIMIT ?", (UI_LIMIT,)
|
|
)
|
|
]
|