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.""" 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["review_assistant_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 for key, state in list(getattr(self, "_pending_reply_sessions", {}).items()): 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["review_assistant_id"] = str(review_id) evidence = assistant.reconcile(state, context) if not evidence: 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 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. last = context.get("last_message") or {} if last and not last.get("is_self") and last.get("dedup_key"): self.enqueue_external_reply(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. return bool(entry.get("draft") and str(draft).replace("\r\n", "\n") == str(entry["draft"]).replace("\r\n", "\n")) except Exception: 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: context = self._review_assistant_context(state) if context: assistant.record_automated_send({**state, "account": context["account"], "conv_id": context["conv_id"]}, {"status": "confirmed", "transport": "visual", "confirmedAt": time.time(), "text": str(state.get("reply_text") or state.get("staged_reply_text") or ""), "messages": context.get("messages") or []}) except Exception as exc: self._review_assistant_warn("自动回复留痕暂不可用(" + type(exc).__name__ + ")")