Files
kefu/deploy/review-assistant-20260917/visual-protocol-wechat_bot.py.diff
T
2026-09-21 10:34:06 +08:00

518 lines
28 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- C:\kefu\deploy\review-assistant-20260917\visual-before\protocol\wechat_bot.py
+++ C:\wechat_rpa\wechat_bot.py
@@ -2030,6 +2030,8 @@
event = state.get("database_event") if isinstance(state.get("database_event"), dict) else {}
supplied = metadata if isinstance(metadata, dict) else {}
reason = excluded_session_reason(name, metadata=supplied) or excluded_session_reason(name, metadata=state)
+ if not reason and self._review_assistant_recipient({**state, "display_name": name, "database_event": supplied or event}):
+ reason = "审核助理联系人仅接收提醒,不参与自动回复"
cache = getattr(self, "_excluded_session_cache", None)
if cache is None:
cache = self._excluded_session_cache = {}
@@ -8133,6 +8135,334 @@
else:
time.sleep(interval)
+ def _review_assistant_settings(self):
+ loader = getattr(self, "_review_assistant_settings_loader", None)
+ if loader is not None:
+ return loader()
+ try:
+ with open(os.path.join(_SCRIPT_DIR, "app_settings.json"), encoding="utf-8") as handle:
+ return json.load(handle)
+ except (OSError, ValueError):
+ return {}
+
+ def _review_assistant_warn(self, detail):
+ if detail != getattr(self, "_review_assistant_warning", None):
+ self._review_assistant_warning = detail
+ print(" [助理审核] " + detail + ";保留人工审核,不猜测人工回复。")
+
+ def _get_review_assistant(self, *, verify=False):
+ """Bind only to the live window's verified login, never a browser account."""
+ stop = getattr(self, "_stop_check", None)
+ if stop is not None and stop.is_set():
+ return None
+ try:
+ settings = self._review_assistant_settings()
+ configured = settings.get("review_assistant_accounts") if isinstance(settings, dict) else None
+ if not isinstance(configured, dict) or not any(
+ isinstance(value, dict) and value.get("enabled") for value in configured.values()):
+ self.close_review_assistant()
+ return None
+ current = getattr(self, "_review_assistant", None)
+ if current is not None and not verify:
+ return current if current.enabled() else None
+ from wecom_native_sender import NativeClient
+ _, pid = win32process.GetWindowThreadProcessId(self.hwnd)
+ with NativeClient(pid) as client:
+ account = str(client.account)
+ identity = (account, int(pid))
+ if identity != getattr(self, "_review_assistant_identity", None):
+ self.close_review_assistant()
+ from review_assistant import ReviewAssistantCoordinator
+ current = ReviewAssistantCoordinator(
+ root=_SCRIPT_DIR, settings_loader=self._review_assistant_settings,
+ account=account, pid=pid, db=getattr(self, "_db_source", None),
+ log=lambda message: print(" [助理审核] " + str(message)),
+ )
+ self._review_assistant = current
+ self._review_assistant_identity = identity
+ self._review_assistant_warning = None
+ return current if current is not None and current.enabled() else None
+ except Exception as exc:
+ self.close_review_assistant()
+ self._review_assistant_warn("当前账号或本机协议不可核验(" + type(exc).__name__ + ")")
+ return None
+
+ def close_review_assistant(self):
+ assistant = getattr(self, "_review_assistant", None)
+ self._review_assistant = None
+ self._review_assistant_identity = None
+ if assistant is not None:
+ try:
+ assistant.close()
+ except Exception:
+ pass
+
+ @staticmethod
+ def _review_assistant_protected(state):
+ return bool(state.get("send_state") or state.get("send_dispatched_at") or
+ str(state.get("stage") or "") in {
+ "sending", "receipt_check", "uncertain", "unknown", "sent_uncommitted", "failed"})
+
+ def _review_assistant_context(self, state, *, fp=None):
+ source = getattr(self, "_db_source", None)
+ identity = getattr(self, "_review_assistant_identity", None)
+ if source is None or not identity:
+ return None
+ account = str(identity[0])
+ event = state.get("database_event") or {}
+ bound_account = str(state.get("account") or event.get("account") or "")
+ conv_id = str(state.get("conv_id") or event.get("conv_id") or "")
+ if bound_account and bound_account != account:
+ return None
+ if conv_id:
+ context = source.get_conversation_context_by_id(account, conv_id, limit=120)
+ else:
+ # This API rejects duplicate names even when an account is supplied.
+ name = str(state.get("display_name") or "")
+ context = source.get_conversation_context(name, account=account, limit=120) if name else None
+ if (not isinstance(context, dict) or str(context.get("account") or "") != account
+ or not context.get("conv_id") or (conv_id and context.get("conv_id") != conv_id)):
+ return None
+ if fp is not None:
+ state["account"] = account
+ state["conv_id"] = str(context["conv_id"])
+ return context
+
+ def _review_assistant_recipient(self, state):
+ assistant = self._get_review_assistant()
+ if assistant is None:
+ return False
+ try:
+ context = self._review_assistant_context(state)
+ return bool(context and assistant.is_recipient(context["conv_id"]))
+ except Exception:
+ return False
+
+ def _review_assistant_resumed(self, state):
+ assistant = self._get_review_assistant(verify=True)
+ if assistant is None or not isinstance(state, dict):
+ return False
+ try:
+ context = self._review_assistant_context(state)
+ return bool(context and assistant.resumed(context["conv_id"]))
+ except Exception:
+ return False
+
+ def _register_assistant_review(self, fp):
+ state = self._pending_reply_state(fp)
+ if not state or not state.get("awaiting_review") or self._review_assistant_protected(state):
+ return
+ assistant = self._get_review_assistant(verify=True)
+ if assistant is None:
+ return
+ try:
+ context = self._review_assistant_context(state, fp=fp)
+ if not context:
+ self._review_assistant_warn("无法按当前账号核对待审核会话")
+ return
+ if assistant.is_recipient(context["conv_id"]):
+ return
+ # Bind the task identity durably before a reminder may be queued.
+ if not self._persist_pending_replies():
+ return
+ review_id = assistant.ensure_review(state, context)
+ if review_id:
+ state["assistant_review_id"] = str(review_id)
+ self._persist_pending_replies()
+ assistant.enqueue_notifications()
+ except Exception as exc:
+ self._review_assistant_warn("审核提醒登记暂不可用(" + type(exc).__name__ + ")")
+
+ def poll_review_assistant(self):
+ """Observe phone replies without moving the window or sending the old draft."""
+ now = time.monotonic()
+ if now < getattr(self, "_review_assistant_poll_at", 0.0):
+ return
+ self._review_assistant_poll_at = now + 2.0
+ stop = getattr(self, "_stop_check", None)
+ if stop is not None and stop.is_set():
+ return
+ assistant = self._get_review_assistant(verify=True)
+ if assistant is None:
+ return
+ from contextlib import nullcontext
+ items = list(getattr(self, "_pending_reply_sessions", {}).items())
+ start = int(getattr(self, "_review_assistant_cursor", 0)) % max(1, len(items))
+ for offset in range(min(8, len(items))):
+ if offset and time.monotonic() - now >= 0.5:
+ break
+ index = (start + offset) % len(items)
+ self._review_assistant_cursor = index + 1
+ key, state = items[index]
+ if not state.get("awaiting_review") or self._review_assistant_protected(state):
+ continue
+ try:
+ fp = bytes.fromhex(key)
+ context = self._review_assistant_context(state, fp=fp)
+ if not context or assistant.is_recipient(context["conv_id"]):
+ continue
+ review_id = assistant.ensure_review(state, context)
+ if not review_id:
+ continue
+ state["assistant_review_id"] = str(review_id)
+ if not self._persist_pending_replies():
+ continue
+ evidence = assistant.reconcile(state, context)
+ if (not isinstance(evidence, dict) or evidence.get("account") != context["account"]
+ or evidence.get("conv_id") != context["conv_id"]
+ or str(evidence.get("review_id") or "") != str(review_id)):
+ continue
+ with getattr(self, "_pending_lock", None) or nullcontext():
+ if (self._pending_reply_state(fp) is not state or not state.get("awaiting_review")
+ or self._review_assistant_protected(state)):
+ continue
+ current_event = str((state.get("database_event") or {}).get("message_id") or "")
+ if current_event and current_event not in {str(item.get("dedup_key") or "")
+ for item in context.get("messages") or []}:
+ continue # A DB event arrived while this older snapshot was being checked.
+ discarded = getattr(self, "_review_assistant_discarded_drafts", None)
+ if discarded is None:
+ discarded = self._review_assistant_discarded_drafts = {}
+ draft_key = str(context["account"]) + "\0" + str(context["conv_id"])
+ previous = discarded.get(draft_key)
+ discarded[draft_key] = {"account": context["account"], "conv_id": context["conv_id"],
+ "draft": str(state.get("reply_text") or state.get("staged_reply_text") or ""),
+ "review_id": str(review_id), "completed_at": time.time()}
+ # Remove only this exact old task; a concurrent replacement must survive.
+ self._pending_reply_sessions.pop(key)
+ if not self._persist_pending_replies():
+ self._pending_reply_sessions[key] = state
+ if previous is None:
+ discarded.pop(draft_key, None)
+ else:
+ discarded[draft_key] = previous
+ continue
+ getattr(self, "_pending_scan_progress", {}).pop(key, None)
+ getattr(self, "_pending_exchanges", {}).pop(key, None)
+ self.queue_log.append(self.session_id_of(fp), str(state.get("display_name") or "客户"),
+ "已审核", "已核对同账号人工回复,旧审核草稿已作废;后续新消息重新经过风控")
+ # Preserve customer messages arriving after the proven human reply.
+ human_time = float(evidence.get("send_time") or 0)
+ human_row = int(evidence.get("rowid") or 0)
+ later = [item for item in context.get("messages") or []
+ if item.get("is_self") is False and item.get("dedup_key")
+ and item.get("account", context["account"]) == context["account"]
+ and item.get("conv_id", context["conv_id"]) == context["conv_id"]
+ and (float(item.get("send_time") or 0) > human_time or
+ (float(item.get("send_time") or 0) == human_time and int(item.get("rowid") or 0) > human_row))]
+ last = max(later, key=lambda item: (float(item.get("send_time") or 0), int(item.get("rowid") or 0))) if later else {}
+ if last:
+ self.enqueue_detected(key, display_name=str(context.get("display_name") or ""),
+ chat_text=str(context.get("text") or ""), dedup_key=str(last["dedup_key"]),
+ detected_by="人工审核续接", database_event={
+ "account": context["account"], "conv_id": context["conv_id"],
+ "message_id": last["dedup_key"], "send_time": last.get("send_time"),
+ "name_unique": False})
+ except Exception as exc:
+ self._review_assistant_warn("人工回复核验暂不可用(" + type(exc).__name__ + ")")
+ assistant.enqueue_notifications()
+
+ def _is_discarded_review_draft(self, state, draft):
+ """Called only after normal composer identity and focus checks have passed."""
+ if not draft or self._review_assistant_protected(state or {}):
+ return False
+ try:
+ if self._get_review_assistant(verify=True) is None:
+ return False
+ context = self._review_assistant_context(state or {})
+ if not context:
+ return False
+ key = str(context["account"]) + "\0" + str(context["conv_id"])
+ entry = getattr(self, "_review_assistant_discarded_drafts", {}).get(key) or {}
+ # Strict raw comparison: edited human text is never eligible for deletion.
+ matches = bool(entry.get("draft") and str(draft).replace("\r\n", "\n") ==
+ str(entry["draft"]).replace("\r\n", "\n"))
+ if entry and not matches:
+ self._retire_discarded_review_marker(state)
+ return matches
+ except Exception:
+ return False
+
+ def _retire_discarded_review_marker(self, state):
+ discarded = getattr(self, "_review_assistant_discarded_drafts", {})
+ if not discarded:
+ return True
+ try:
+ context = self._review_assistant_context(state or {})
+ if not context:
+ return True # No verified editor binding: leave the marker and never touch text.
+ key = str(context["account"]) + "\0" + str(context["conv_id"])
+ old = discarded.pop(key, None)
+ if old is None:
+ return True
+ if self._persist_pending_replies():
+ return True
+ discarded[key] = old
+ except Exception:
+ if 'old' in locals() and old is not None:
+ discarded[key] = old
+ return False
+
+ def _record_assistant_automated_attempt(self, state, reply_text):
+ assistant = self._get_review_assistant(verify=True)
+ if assistant is None:
+ return True
+ try:
+ context = self._review_assistant_context(state or {})
+ if not context:
+ self._review_assistant_warn("自动发送前无法核对会话,暂缓本次发送")
+ return False
+ state["account"], state["conv_id"] = context["account"], context["conv_id"]
+ assistant.record_automated_attempt({**state, "reply_text": reply_text})
+ return True
+ except Exception as exc:
+ self._review_assistant_warn("自动发送留痕失败(" + type(exc).__name__ + "),暂缓本次发送")
+ return False
+
+ def _record_assistant_automated_send(self, state):
+ assistant = self._get_review_assistant()
+ if assistant is None or not isinstance(state, dict):
+ return
+ try:
+ # A verified visual receipt closes this local approval, but cannot
+ # grant blanket-mode resume or invent a server message identifier.
+ assistant.complete_local_review(state)
+ context = self._review_assistant_context(state)
+ if not context:
+ return
+ dispatched = float(state.get("send_dispatched_at") or 0)
+ reply = str(state.get("reply_text") or state.get("staged_reply_text") or "")
+ matches = [item for item in context.get("messages") or []
+ if item.get("account") == context["account"] and item.get("conv_id") == context["conv_id"]
+ and item.get("is_self") is True and str(item.get("sender_id") or "") == context["account"]
+ and str(item.get("content") or "").strip() == reply.strip()
+ and dispatched > 0 and float(item.get("send_time") or 0) >= dispatched - 1
+ and str(item.get("server_id") or "") not in {"", "0", "None"}]
+ if len(matches) == 1:
+ assistant.record_automated_send({**state, "account": context["account"],
+ "conv_id": context["conv_id"]}, {"status": "confirmed", "transport": "visual",
+ "accountId": context["account"], "conversationId": context["conv_id"],
+ "serverId": str(matches[0]["server_id"]), "confirmedAt": time.time()})
+ except Exception as exc:
+ self._review_assistant_warn("自动回复留痕暂不可用(" + type(exc).__name__ + ")")
+
+ def _reply_review_decision(self, pending_state, customer_text, reply_text):
+ resumed = held = False
+ assistant = self._get_review_assistant(verify=True)
+ if assistant is not None:
+ try:
+ context = self._review_assistant_context(pending_state or {})
+ if context:
+ held = assistant.has_pending(context["conv_id"])
+ resumed = assistant.resumed(context["conv_id"]) and not held
+ else:
+ held = True # Cannot exclude an existing review without exact conversation evidence.
+ except Exception:
+ held = True # Missing review state cannot grant an automatic send.
+ blanket = self._review_mode_enabled() and not resumed
+ hit, reason = self._reply_needs_review(pending_state, customer_text, reply_text)
+ return blanket, bool(hit or held), reason or ("本会话仍有待审核消息,等待同账号人工回复" if held else "")
+
def _review_mode_enabled(self) -> bool:
return str(getattr(self, "send_mode", SEND_MODE_AUTO)) == SEND_MODE_REVIEW
@@ -8792,6 +9122,7 @@
def _load_pending_replies(self) -> dict:
"""Restore read-but-unreplied work after a crash without restoring stale tasks."""
self._manual_handoffs = {}
+ self._review_assistant_discarded_drafts = {}
path = getattr(self, "_pending_reply_path", "")
if not path or not os.path.exists(path):
return {}
@@ -8802,6 +9133,10 @@
return {}
if not isinstance(raw, dict):
return {}
+ discarded = raw.get("assistant_discarded_drafts")
+ if isinstance(discarded, dict):
+ self._review_assistant_discarded_drafts = {str(key): dict(value) for key, value in discarded.items()
+ if isinstance(value, dict) and value.get("account") and value.get("conv_id")}
handoffs = raw.get("manual_handoffs")
if isinstance(handoffs, dict):
self._manual_handoffs = {str(key): dict(entry) for key, entry in handoffs.items()
@@ -8911,6 +9246,9 @@
"uncertain_since": uncertain_since,
"send_dispatched_at": send_dispatched_at,
"awaiting_review": bool(value.get("awaiting_review", False)),
+ "account": str(value.get("account") or ""),
+ "conv_id": str(value.get("conv_id") or ""),
+ "assistant_review_id": str(value.get("assistant_review_id") or ""),
"review_reason": str(value.get("review_reason") or "")[:500],
"review_origin": str(value.get("review_origin") or "")[:16],
"review_approved": bool(value.get("review_approved", False)),
@@ -9095,6 +9433,9 @@
value.get("send_dispatched_at", 0.0) or 0.0
),
"awaiting_review": bool(value.get("awaiting_review", False)),
+ "account": str(value.get("account") or ""),
+ "conv_id": str(value.get("conv_id") or ""),
+ "assistant_review_id": str(value.get("assistant_review_id") or ""),
"review_reason": str(value.get("review_reason") or "")[:500],
"review_origin": str(value.get("review_origin") or "")[:16],
"review_approved": bool(value.get("review_approved", False)),
@@ -9205,8 +9546,10 @@
item[field] = bytes(signature).hex() if signature else ""
serializable[str(key)] = item
handoffs = getattr(self, "_manual_handoffs", {})
- if handoffs:
- serializable = {"pending": serializable, "manual_handoffs": handoffs}
+ discarded = getattr(self, "_review_assistant_discarded_drafts", {})
+ if handoffs or discarded:
+ serializable = {"pending": serializable, "manual_handoffs": handoffs,
+ "assistant_discarded_drafts": discarded}
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as handle:
@@ -9769,6 +10112,7 @@
state.pop("send_dispatched_at", None)
state.pop("awaiting_review", None)
state.pop("review_requested_at", None)
+ state.pop("assistant_review_id", None)
state.pop("review_reason", None)
state.pop("review_origin", None)
state.pop("judge_risk", None)
@@ -10348,7 +10692,10 @@
persisted = handoff_unlocked()
if not persisted:
return result
+ from gui_runtime import cancel_assistant_review_for_state
for key, state in removed:
+ if state.get("assistant_review_id"):
+ cancel_assistant_review_for_state(state, os.path.dirname(self._pending_reply_path))
result["handed_off"].append(key)
result["deleted"].append(key)
getattr(self, "_pending_exchanges", {}).pop(key, None)
@@ -10451,7 +10798,10 @@
self._active_hold_key = ""
self._active_hold_since = 0.0
self._active_hold_expired = False
+ from gui_runtime import cancel_assistant_review_for_state
for key, _state, name in removed:
+ if _state.get("assistant_review_id"):
+ cancel_assistant_review_for_state(_state, os.path.dirname(self._pending_reply_path))
try:
self.queue_log.append(
key,
@@ -10658,6 +11008,7 @@
key = str(session_id or "")
exchange = getattr(self, "_pending_exchanges", {}).pop(key, None)
pending_state = getattr(self, "_pending_reply_sessions", {}).get(key) or {}
+ self._record_assistant_automated_send(pending_state)
if not exchange:
staged_user = str(pending_state.get("staged_user_text") or "")
staged_reply = str(pending_state.get("staged_reply_text") or "")
@@ -12514,8 +12865,17 @@
print(" [发送保护] 无法确认输入框焦点,已取消发送。")
return self._deny_send("确认不了输入框焦点,不敢往里打字")
normalized_draft = self._normalized_message_text(existing_draft)
+ if not normalized_draft and not self._retire_discarded_review_marker(pending_state):
+ return self._deny_send("旧审核稿作废记录尚未保存,等待重试")
if normalized_draft:
- if self._draft_is_our_own(
+ if self._is_discarded_review_draft(pending_state, existing_draft):
+ pyautogui.hotkey('ctrl', 'a')
+ pyautogui.press('backspace')
+ if not self._retire_discarded_review_marker(pending_state):
+ return self._deny_send("已清除旧审核稿,但作废记录尚未保存,等待重试")
+ self._clear_foreign_draft_hold(expected_fp)
+ print(" [助理审核] 已核对人工回复,仅清除本会话作废的原始 AI 草稿,重新处理新消息。")
+ elif self._draft_is_our_own(
normalized_draft,
pending_state,
expected_reply,
@@ -12735,22 +13095,15 @@
# 才破例停下来,其余照常自动发送。两者是 OR:任何一个成立都要停。
if self._skip_excluded_reply_session(expected_fp, inspect_open=True):
return self._deny_send(str(getattr(self, "_last_session_exclusion_reason", "会话不参与自动回复")))
- blanket_review = self._review_mode_enabled()
- rule_hit, rule_reason = (
- (False, "")
- if blanket_review
- else self._reply_needs_review(
- pending_state,
- str((pending_state or {}).get("staged_user_text") or "\n".join(
- str(line) for line in (pending_state or {}).get("last_lines") or []
- )),
- reply_text,
- )
+ blanket_review, rule_hit, rule_reason = self._reply_review_decision(
+ pending_state, str((pending_state or {}).get("staged_user_text") or "\n".join(
+ str(line) for line in (pending_state or {}).get("last_lines") or [])), reply_text,
)
if (blanket_review or rule_hit) and not (pending_state or {}).get(
"review_approved", False
):
pending_state["awaiting_review"] = True
+ pending_state.pop("assistant_review_id", None)
pending_state["review_requested_at"] = time.time()
pending_state["reply_text"] = reply_text
pending_state["last_pasted_draft"] = expected_reply
@@ -12771,6 +13124,8 @@
)
if not persisted:
reason = "回复已填入输入框,但审核任务状态未能保存;已禁止自动发送"
+ if persisted:
+ self._register_assistant_review(expected_fp)
self._last_send_failure_reason = reason
self.report_operation("人工审核", 1, 1, reason, expected_fp)
print(f" [人工审核] {reason}。")
@@ -12802,6 +13157,8 @@
return self._deny_send("建不出发送前的聊天锚点,无法核对回执")
if baseline_raw:
print(" [发送保护] 该会话复制不出消息表头,改用整屏文字锚点核对回执。")
+ if not self._record_assistant_automated_attempt(pending_state, reply_text):
+ return self._deny_send("助理审核自动发送留痕未完成,本轮不发送")
pending_state["send_state"] = "sending"
pending_state["reply_text"] = reply_text
pending_state["send_surface_signature"] = final_surface or pending_surface