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

264 lines
13 KiB
Python

"""Serial scheduling and the existing native sender adapter. No implicit sends."""
from contextlib import contextmanager
import json
from pathlib import Path
import sqlite3
import tempfile
from proactive_contact_schedule import next_slot
def verified_receipt(receipt, task):
return bool(isinstance(receipt, dict) and receipt.get("status") == "confirmed"
and receipt.get("accountId") == task["account"]
and receipt.get("conversationId") == task["conversation"] and receipt.get("serverId")
and receipt.get("senderVerified") is True and receipt.get("textVerified") is True)
class NativeContactTransport:
def __init__(self, root):
self.root = Path(root)
self.reply_active = lambda: False
self.waiting_reason = ""
@contextmanager
def database(self, account):
from reply_database import LiveReplyDatabase
# Each reader refreshes/decrypts its own snapshot. Reusing the reply
# cache can collide with another reader's open SQLite files on Windows.
scratch = self.root / "tmp"
scratch.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="proactive-db-", dir=scratch) as cache:
db = LiveReplyDatabase._open_database(account=account, initialize=False,
load_metadata=False, cache_dir=cache)
try:
yield db
finally:
if db is not None:
db.close()
@staticmethod
def read_baseline(db, task):
ctx = db.get_conversation_context_by_id(task["account"], task["conversation"], limit=500)
if not ctx or ctx.get("account") != task["account"] or ctx.get("conv_id") != task["conversation"]:
raise ValueError("当前客户的消息库无法按账号和会话核验")
incoming = [m for m in ctx.get("messages", []) if m.get("is_self") is False]
last = incoming[-1] if incoming else {}
return [str(last.get(k, "")) for k in ("rowid", "server_id", "dedup_key", "send_time")]
def baseline(self, task):
from wecom_native_sender import discover, NativeClient
from wecom_native_evidence import send_authorized
identity = discover()
if identity["accountId"] != task["account"]:
raise ValueError("企微账号已切换")
with NativeClient(identity["pid"]) as client:
if client.account != task["account"] or not send_authorized(client.profile, client.account):
raise ValueError("请先在自动化设置完成当前企微账号的协议发送及回执验证")
with self.database(task["account"]) as db:
return self.read_baseline(db, task)
def ready(self, task):
"""Native sends do not use the mouse/keyboard; keep reply priority only."""
self.waiting_reason = ""
if not self.reply_active():
return True
for name in ("pending_replies.json", "pending_replies.protocol.json"):
path = self.root / name
if not path.exists():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
self.waiting_reason = "正在读取自动回复状态,稍后自动继续"
return False
pending = data.get("pending", data)
for key, row in pending.items():
if not isinstance(row, dict) or key in {"seen", "manual_handoffs"}:
continue
if row.get("stage") in {"sending", "generating", "reading", "ready", "ready_to_send", "waiting_send", "queued"}:
self.waiting_reason = "自动回复正在处理客户消息,完成后自动继续,无需点击恢复"
return False
return True
@contextmanager
def lock(self):
import send_lock
with send_lock.lock("proactive-contact", 0) as acquired:
yield acquired
def send(self, task, guard):
from wecom_native_sender import NativeSender, discover
identity = discover()
if identity["accountId"] != task["account"]:
raise ValueError("企微账号已切换")
with self.database(task["account"]) as db:
sender = NativeSender(db, self.root / "protocol_sends.sqlite3")
try:
return sender.send("proactive-contact:" + task["id"], identity["pid"], task["account"],
task["conversation"], task["text"],
guard=lambda: guard() and self.read_baseline(db, task) == task["baseline"])
finally:
sender.close()
def receipt(self, task):
path = self.root / "protocol_sends.sqlite3"
if not path.exists():
return None
db = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
try:
row = db.execute("SELECT status,result FROM sends WHERE request_id=?",
("proactive-contact:" + task["id"],)).fetchone()
if row and row[0] == "confirmed":
return json.loads(row[1])
pending = json.loads(row[1]) if row else {}
finally:
db.close()
if pending.get("nativeCallback") != "success" or not pending.get("serverId"):
return None
from wecom_native_sender import NativeSender
with self.database(task["account"]) as messages:
sender = NativeSender(messages, path)
try:
return sender.reconcile("proactive-contact:" + task["id"], task["account"],
task["conversation"], task["text"])
finally:
sender.close()
class ContactScheduler:
def __init__(self, service, *, stopped=lambda: False, interrupted=lambda task: False):
self.service = service
self.stopped, self.interrupted = stopped, interrupted
self.recovered = set()
self.next_receipt_check = {}
def wait_reason(self, task):
if self.service.transport.ready(task):
return ""
return getattr(self.service.transport, "waiting_reason", "") or "正在等待发送通道可用,可用后自动继续,无需点击恢复"
def queue(self, task, reason):
if task["status"] == "queued" and task.get("error") == reason:
return False
return self.service.store.update(task["owner"], task["id"], "queued",
expected={"scheduled", "queued"}, error=reason)
def recover(self, owner):
# The application already enforces a single desktop instance. Also hold
# the common send mutex while resolving interrupted sends.
with self.service.transport.lock() as acquired:
if not acquired:
return False
for task in self.service.store.tasks(owner, pending=True, limit=10000):
if task["status"] == "sending":
receipt = self.service.transport.receipt(task)
confirmed = verified_receipt(receipt, task)
self.service.store.finish(task, "succeeded" if confirmed else "delivery_unknown",
receipt=receipt, error="" if confirmed else "上次执行中断,发送结果待核对,禁止自动重发")
self.recovered.add(owner)
return True
def tick(self, owner):
service, store = self.service, self.service.store
if owner not in self.recovered and not self.recover(owner):
for task in store.tasks(owner, pending=True, limit=10000):
if task["status"] in {"scheduled", "queued"} and task["due"] <= service.clock():
return self.queue(task, "发送通道正在使用,释放后自动继续")
return False
if self.stopped():
return False
now = service.clock()
changed = False
if now >= self.next_receipt_check.get(owner, 0):
self.next_receipt_check[owner] = now + 30
changed = bool(service.reconcile_receipts(owner))
if not store.enabled(owner):
return changed
tasks = sorted(store.tasks(owner, pending=True, limit=10000), key=lambda t: (t["due"], t["created"]))
for task in tasks:
if task["status"] not in {"scheduled", "queued"} or task["due"] > now:
continue
due = next_slot(task["schedule"], now)
if due is None:
store.update(owner, task["id"], "skipped", error="计划日期范围已结束")
return True
if due > now:
changed = store.update(owner, task["id"], "scheduled", due=due, error="") or changed
continue
last, count = store.pacing(task["account"], now)
if count >= 100:
store.update(owner, task["id"], "paused", error="已达到本账号滚动 24 小时 100 次发送上限")
return True
if now < last + task["schedule"]["interval"]:
return self.queue(task, f"等待客户发送间隔({task['schedule']['interval']} 秒),到时自动继续") or changed
with service.transport.lock() as acquired:
if not acquired:
return self.queue(task, "发送通道正在使用,释放后自动继续") or changed
if self.stopped() or self.interrupted(task):
return False
try:
if service.gateway.owner() != owner:
return False
waiting = self.wait_reason(task)
if waiting:
return self.queue(task, waiting) or changed
try:
service.verify(task)
except Exception as exc:
service.mark_invalid(owner, {**task, "id": task["customer"]}, str(exc))
raise
if service.transport.baseline(task) != task["baseline"]:
raise ValueError("客户已有新消息,请核对后重新创建主动联系任务")
except Exception as exc:
store.update(owner, task["id"], "paused", error=str(exc)[:300])
return True
claimed = store.claim(owner, task["id"], service.clock())
if claimed is None:
continue
reason = [""]
deferred = [False]
def guard():
deferred[0] = False
try:
current = store.get_task(owner, claimed["id"])
if (self.stopped() or self.interrupted(claimed) or not store.enabled(owner)
or service.gateway.owner() != owner or not current or current["status"] != "sending"):
raise ValueError("任务已暂停或登录账号已变化")
instant = service.clock()
slot = next_slot(claimed["schedule"], instant)
if slot is None or slot > instant:
raise ValueError("已离开允许发送的时间段")
service.verify(claimed, full=False)
waiting = self.wait_reason(claimed)
if waiting:
deferred[0] = True
raise ValueError(waiting)
return True
except Exception as exc:
reason[0] = str(exc)[:300]
return False
try:
if not guard():
store.finish(claimed, "queued" if deferred[0] else "paused", error=reason[0],
attempt_status="deferred" if deferred[0] else None)
return True
receipt = service.transport.send(claimed, guard)
if not verified_receipt(receipt, task):
store.finish(claimed, "delivery_unknown", error="发送回执无法完整核对,禁止自动重发")
else:
store.finish(claimed, "succeeded", receipt=receipt)
except Exception as exc:
kind = type(exc).__name__
# Only our own temporary guard rejection, accompanied by
# SendCancelled (guaranteed before native dispatch), may
# return to the queue. Unknown/failed dispatches never retry.
retry = kind == "SendCancelled" and deferred[0]
status = "queued" if retry else "paused" if kind in {"NativeUnavailable", "SendCancelled"} else "failed" if kind == "DeliveryFailed" else "delivery_unknown"
store.finish(claimed, status, error=reason[0] or str(exc)[:300],
attempt_status="deferred" if retry else None)
return True
return changed