--- 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