# -*- coding: utf-8 -*- """GUI 无关的后台运行时:日志转发与机器人轮询线程。 原先住在 wechat_gui.py(Tk 控制台)里,Qt 控制台 import 它时会连带把整个 tkinter/tcl 拖进进程——打包后的 EXE 每次冷启动都白付这笔钱。抽出来之后两个 界面共用,谁也不用替对方的依赖买单。 """ import glob import json import os import tempfile import threading import time import traceback from runtime_paths import application_data_dir MESSAGE_BATCH_WINDOW_SECONDS = 20.0 MESSAGE_BATCH_WINDOW_MIN_SECONDS = 1.0 MESSAGE_BATCH_WINDOW_MAX_SECONDS = 120.0 SEND_DELAY_SECONDS = 1.0 SEND_DELAY_MIN_SECONDS = 0.0 SEND_DELAY_MAX_SECONDS = 30.0 SEND_MODE_AUTO = "auto" SEND_MODE_REVIEW = "review" # 输入框里有人工草稿时,等多少分钟就直接把框里那句发出去。 # # 0 = 关闭(默认):永远不替人按回车,草稿一直留着,会话被跳过。这是唯一 # 一个"机器人发送的内容不是它自己生成的"的口子,所以默认必须是关的——开不开 # 由用它的人明确决定,而不是装上就替他做主。 FOREIGN_DRAFT_AUTOSEND_MINUTES = 0.0 FOREIGN_DRAFT_AUTOSEND_MIN_MINUTES = 0.0 FOREIGN_DRAFT_AUTOSEND_MAX_MINUTES = 1440.0 def normalize_message_batch_window_seconds(value, default=MESSAGE_BATCH_WINDOW_SECONDS): """读取本地设置时安全归一化消息合并等待时间。""" if isinstance(value, bool): return float(default) try: seconds = float(value) except (TypeError, ValueError): return float(default) if not MESSAGE_BATCH_WINDOW_MIN_SECONDS <= seconds <= MESSAGE_BATCH_WINDOW_MAX_SECONDS: return float(default) return seconds def normalize_send_delay_seconds(value, default=SEND_DELAY_SECONDS): """读取本地设置时安全归一化发送前等待时间。""" if isinstance(value, bool): return float(default) try: seconds = float(value) except (TypeError, ValueError): return float(default) if not SEND_DELAY_MIN_SECONDS <= seconds <= SEND_DELAY_MAX_SECONDS: return float(default) return seconds def normalize_foreign_draft_autosend_minutes( value, default=FOREIGN_DRAFT_AUTOSEND_MINUTES ): """归一化"人工草稿放多久就直接发"的分钟数。 坏值一律回落到默认的 0(关闭)而不是某个中间值:这个开关一旦生效,机器人 会把别人写在输入框里的字发给真实客户。配置读坏了的时候,宁可什么都不做。 """ if isinstance(value, bool): return float(default) try: minutes = float(value) except (TypeError, ValueError): return float(default) if not ( FOREIGN_DRAFT_AUTOSEND_MIN_MINUTES <= minutes <= FOREIGN_DRAFT_AUTOSEND_MAX_MINUTES ): return float(default) return minutes def normalize_send_mode(value, default=SEND_MODE_AUTO): """Normalize persisted send mode to the two production-supported values.""" normalized = str(value or "").strip().lower() if normalized in {SEND_MODE_AUTO, SEND_MODE_REVIEW}: return normalized return SEND_MODE_REVIEW if default == SEND_MODE_REVIEW else SEND_MODE_AUTO def cancel_assistant_review_for_state(state, root): """Close only the explicitly cancelled episode; never start native I/O.""" if not isinstance(state, dict) or not state.get("assistant_review_id"): return True event = state.get("database_event") or {} account = str(state.get("account") or event.get("account") or "") if not account: return False coordinator = None try: from review_assistant import ReviewAssistantCoordinator coordinator = ReviewAssistantCoordinator(root=root, account=account) if coordinator.cancel_review(state) is False: print("[助理审核] 任务已移除,但提醒取消尚未保存,请检查审核提醒状态") return False return True except Exception as exc: print(f"[助理审核] 任务已移除,但提醒取消失败({type(exc).__name__}),请检查审核提醒状态") return False finally: if coordinator is not None: coordinator.close() def delete_pending_reply_file(keys, path="", *, handoff=False, confirm_uncertain=False): """Atomically remove selected reply tasks while the bot is not running.""" requested = {str(key or "").strip() for key in (keys or [])} requested.discard("") result = {"deleted": [], "missing": sorted(requested), "protected": [], "scheduled": []} if handoff: result["requires_confirmation"] = [] if not requested: return result target = str(path or os.path.join(application_data_dir(), "pending_replies.json")) try: with open(target, encoding="utf-8") as handle: raw = json.load(handle) except FileNotFoundError: return result except (OSError, ValueError, TypeError) as exc: result["error"] = str(exc) return result if not isinstance(raw, dict): result["error"] = "待回复队列文件格式不正确" return result pending = raw["pending"] if isinstance(raw.get("pending"), dict) else raw if handoff: history = raw.get("manual_handoffs", {}) if not isinstance(history, dict): result["error"] = "人工接管记录格式不正确,无法安全转交人工" return result if pending is raw: pending = {key: state for key, state in raw.items() if key != "manual_handoffs"} raw = {"pending": pending, "manual_handoffs": history} else: raw["manual_handoffs"] = history removed = [] protocol = raw.get("transport") == "protocol" if handoff and protocol and not isinstance(raw.get("seen", {}), dict): result["error"] = "协议去重记录格式不正确,无法安全转交人工" return result for key in sorted(requested): state = pending.get(key) if not isinstance(state, dict): continue send_state = str(state.get("send_state") or "") stage = str(state.get("stage") or "") sending = send_state == "sending" or stage == "sending" uncertain = send_state in {"uncertain", "sent_uncommitted", "unknown"} or stage in {"receipt_check", "uncertain", "unknown"} if sending or (uncertain and not (handoff and confirm_uncertain)): result["protected"].append(key) if handoff and uncertain and not sending: result["requires_confirmation"].append(key) continue if handoff and protocol and state.get("dedup_key"): raw.setdefault("seen", {})[key] = state["dedup_key"] if handoff: raw["manual_handoffs"][key] = { "state": dict(state), "handed_off_at": time.time(), "receipt_checked_by_user": bool(confirm_uncertain), "delivery_confirmed": False, } pending.pop(key, None) removed.append((key, state)) if not removed: result["missing"] = sorted(requested.difference(result["protected"])) return result directory = os.path.dirname(os.path.abspath(target)) or "." os.makedirs(directory, exist_ok=True) handle = tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=directory, prefix=".pending_replies_", suffix=".tmp", delete=False, ) try: with handle: json.dump(raw, handle, ensure_ascii=False, indent=2) os.replace(handle.name, target) except Exception as exc: try: os.unlink(handle.name) except OSError: pass result["error"] = str(exc) return result for _key, state in removed: cancel_assistant_review_for_state(state, directory) result["deleted"] = [key for key, _state in removed] result["missing"] = sorted(requested.difference(result["deleted"]).difference(result["protected"])) try: from queue_log import QueueLog queue_log = QueueLog() for key, state in removed: queue_log.append( key, str(state.get("display_name") or "(未识别昵称)"), "转人工" if handoff else "手动删除", ("用户已确认人工接管;发送结果未作确认,保留发送流水和去重记录,不自动重发" if handoff else "用户从回复队列中手动删除;本条未完成回复不再自动发送"), ) except Exception: pass return result def handoff_pending_reply_file(keys, path="", *, confirm_uncertain=False): """Retire stopped work after explicit human takeover; preserve send evidence.""" result = delete_pending_reply_file(keys, path, handoff=True, confirm_uncertain=confirm_uncertain) result["handed_off"] = list(result.get("deleted") or []) return result class LogQueue: """把后台线程的标准输出转发到界面,同时留一份带时间戳的磁盘副本。 界面日志随窗口关闭就没了,出问题时无从回溯——真正卡住发送的那一行往往 几分钟前就被刷走了。落盘副本让事后还查得到。 日志目录用的是可长期写入的数据目录:打包成 EXE 后 __file__ 指向随进程 销毁的解包目录,往那儿写等于退出即丢。 """ LOG_DIR = os.path.join(str(application_data_dir()), "logs") KEEP_FILES = 20 # 「数据保留 N 天」的默认值与允许范围,与设置页的输入框保持一致 DEFAULT_RETENTION_DAYS = 90 MIN_RETENTION_DAYS = 7 MAX_RETENTION_DAYS = 365 _PROGRESS_PREFIXES = ( ("[新消息]", "未读定位"), ("[未读扫描]", "未读扫描"), ("[页面校验]", "页面校验"), ("[会话校验]", "会话校验"), ("[会话识别]", "会话识别"), ("[会话守护]", "会话守护"), ("[页面恢复]", "页面恢复"), ("[页面清理]", "页面清理"), ("[视觉重定位]", "视觉重定位"), ("[视觉安全]", "视觉安全"), ("[视觉队列]", "视觉队列"), ("[目标指纹]", "目标确认"), ("[消息合并]", "消息合并"), ("[剪贴板]", "消息复制"), ("[发送保护]", "发送保护"), ("[发送对账]", "发送对账"), ("[发送回执]", "发送回执"), ("[安全模式]", "安全模式"), ("[安全验证]", "安全验证"), ("[人手]", "人机协同"), ("[AI 页面确认]", "AI 页面确认"), ("[AI页面守护]", "AI 页面守护"), ("[AI]", "AI 处理"), ) def __init__(self, target_queue, retention_days=None): self.target_queue = target_queue self.retention_days = self.normalize_retention_days(retention_days) self._handle = None self._open_log_file() @classmethod def normalize_retention_days(cls, value): """把设置里的「数据保留」天数收进允许范围;不合法一律用默认值。""" try: if isinstance(value, bool): raise TypeError days = int(value) except (TypeError, ValueError): return cls.DEFAULT_RETENTION_DAYS return max(cls.MIN_RETENTION_DAYS, min(cls.MAX_RETENTION_DAYS, days)) def _open_log_file(self): try: os.makedirs(self.LOG_DIR, exist_ok=True) self._prune_old_logs() stamp = time.strftime("%Y%m%d_%H%M%S") self._handle = open( os.path.join(self.LOG_DIR, f"gui_{stamp}.log"), "a", encoding="utf-8", buffering=1, ) except Exception: self._handle = None def _prune_old_logs(self): """按「数据保留天数」和文件数上限清理历史日志。 设置页一直有个「数据保留 90 天」的输入框,能改、能存、能读回来,但过去 代码里没有任何一处按它删过东西——只有一条按文件个数保留最近 20 份的规则。 界面上承诺的事情必须真的发生,否则这个设置就是骗人的。 """ try: files = sorted( glob.glob(os.path.join(self.LOG_DIR, "gui_*.log")), key=os.path.getmtime, ) except Exception: return doomed = list(files[: max(0, len(files) - self.KEEP_FILES + 1)]) keep_seconds = self.retention_days * 86400.0 now = time.time() for path in files: if path in doomed: continue try: age = now - os.path.getmtime(path) except OSError: continue # 时钟被回拨时 age 会是负数。这时宁可留着,也不能把刚写的日志删掉 if age > keep_seconds: doomed.append(path) for path in doomed: try: os.unlink(path) except OSError: pass def write(self, message): text = str(message).rstrip() if not text: return self.target_queue.put(("log", text)) progress = self.progress_from_log(text) if progress: # 旧代码里大量关键闸门只写了 print。把这些结构化标签同步到进程 # 通道,扫描、页面恢复等底层动作也能进入胶囊,不会只显示模型阶段。 self.target_queue.put(("progress", progress)) if self._handle is not None: try: self._handle.write(f"{time.strftime('%H:%M:%S')} {text}\n") except Exception: self._handle = None @classmethod def progress_from_log(cls, message): """把带操作标签的日志压成一行胶囊进程;聊天正文不进入浮窗。""" first_line = next( (line.strip() for line in str(message or "").splitlines() if line.strip()), "", ) if not first_line: return "" for prefix, phase in cls._PROGRESS_PREFIXES: if not first_line.startswith(prefix): continue detail = first_line[len(prefix):].strip() if prefix == "[AI]" and ( not detail or detail.startswith("本次提取的新内容") ): return "" return f"{phase} · {detail}" if detail else phase return "" def flush(self): if self._handle is not None: try: self._handle.flush() except Exception: pass def close(self): if self._handle is not None: try: self._handle.close() except Exception: pass self._handle = None class BotThread(threading.Thread): """在后台运行企业微信轮询,避免阻塞界面主线程。""" def __init__(self, target_queue, reply_text, poll_seconds, mouse_idle_enabled=True, mouse_idle_seconds=5.0, message_batch_window_seconds=MESSAGE_BATCH_WINDOW_SECONDS, send_delay_seconds=SEND_DELAY_SECONDS, send_mode=SEND_MODE_AUTO, foreign_draft_autosend_minutes=FOREIGN_DRAFT_AUTOSEND_MINUTES, enable_engine_b=True, engine_b_poll_interval=2.0, engine_a_enabled=True, engine_b_data_source="parallel"): super().__init__(daemon=True) self.target_queue = target_queue self.reply_text = reply_text self.poll_seconds = poll_seconds self.mouse_idle_enabled = mouse_idle_enabled self.mouse_idle_seconds = mouse_idle_seconds self.bot = None self._engine_b = None self._db_source = None self._cancel_lock = threading.Lock() self._queued_cancellations = set() self._queued_handoffs = set() self._queued_handoff_confirmations = set() self._queued_retries = set() self._queued_approvals = set() self.message_batch_window_seconds = MESSAGE_BATCH_WINDOW_SECONDS self.set_message_batch_window_seconds(message_batch_window_seconds) self.send_delay_seconds = SEND_DELAY_SECONDS self.set_send_delay_seconds(send_delay_seconds) self.send_mode = SEND_MODE_AUTO self.set_send_mode(send_mode) self.foreign_draft_autosend_minutes = FOREIGN_DRAFT_AUTOSEND_MINUTES self.set_foreign_draft_autosend_minutes(foreign_draft_autosend_minutes) self.stop_event = threading.Event() self._reply_wakeup = threading.Event() self.enable_engine_b = bool(enable_engine_b) self.engine_a_enabled = bool(engine_a_enabled) self.engine_b_data_source = str(engine_b_data_source or "parallel").lower() if self.engine_b_data_source not in ("parallel", "db", "json"): self.engine_b_data_source = "parallel" try: self.engine_b_poll_interval = max(0.5, float(engine_b_poll_interval)) except (TypeError, ValueError): self.engine_b_poll_interval = 2.0 def set_message_batch_window_seconds(self, value): """更新下一次消息合并窗口;不会改变已经开始等待的窗口快照。""" seconds = normalize_message_batch_window_seconds(value) self.message_batch_window_seconds = seconds bot = getattr(self, "bot", None) if bot is not None: bot.message_batch_window_seconds = seconds def set_foreign_draft_autosend_minutes(self, value): """更新"人工草稿放多久就直接发出去"的分钟数(0 = 关闭)。""" minutes = normalize_foreign_draft_autosend_minutes(value) self.foreign_draft_autosend_minutes = minutes bot = getattr(self, "bot", None) if bot is not None: bot.foreign_draft_autosend_minutes = minutes def set_send_delay_seconds(self, value): """更新后续自动回复在最终发送前使用的独立等待时间。""" seconds = normalize_send_delay_seconds(value) self.send_delay_seconds = seconds bot = getattr(self, "bot", None) if bot is not None: bot.send_delay_seconds = seconds def set_send_mode(self, value): """Switch subsequent replies between automatic Enter and manual review.""" mode = normalize_send_mode(value) self.send_mode = mode bot = getattr(self, "bot", None) if bot is not None: bot.send_mode = mode def _report_progress(self, text): """把机器人此刻在做什么送到界面上那行进度文字。""" try: self.target_queue.put(("progress", str(text or ""))) except Exception: # 进度提示纯属好看,永远不该把轮询带下去 pass def _report_visual_state(self, payload): """把只读视觉监听结果送到 Qt 主线程显示。""" try: self.target_queue.put(("visual", dict(payload or {}))) except Exception: pass def cancel_pending_tasks(self, keys): """Cancel live work, or queue cancellation until the bot finishes starting.""" requested = {str(key or "").strip() for key in (keys or [])} requested.discard("") if not requested: return {"deleted": [], "missing": [], "protected": [], "scheduled": []} with self._cancel_lock: bot = self.bot if bot is None: self._queued_cancellations.update(requested) return { "deleted": [], "missing": [], "protected": [], "scheduled": sorted(requested), } return bot.cancel_pending_replies(requested) @staticmethod def _handoff_bot_tasks(bot, keys, *, confirm_uncertain=False): handoff = getattr(bot, "handoff_pending_replies", None) if callable(handoff): return handoff(keys, confirm_uncertain=confirm_uncertain) result = bot.cancel_pending_replies(keys) result["handed_off"] = list(result.get("deleted") or []) return result def handoff_pending_tasks(self, keys, *, confirm_uncertain=False): """Keep explicit takeover separate from ordinary cancellation at startup.""" requested = {str(key or "").strip() for key in (keys or [])} requested.discard("") if not requested: return {"handed_off": [], "deleted": [], "missing": [], "protected": [], "scheduled": []} with self._cancel_lock: bot = self.bot if bot is None: self._queued_handoffs.update(requested) if confirm_uncertain: self._queued_handoff_confirmations.update(requested) return {"handed_off": [], "deleted": [], "missing": [], "protected": [], "scheduled": sorted(requested)} return self._handoff_bot_tasks(bot, requested, confirm_uncertain=confirm_uncertain) def _drain_queued_handoffs(self, bot): with self._cancel_lock: requested = set(self._queued_handoffs) confirmed = requested & self._queued_handoff_confirmations self._queued_handoffs.clear() self._queued_handoff_confirmations.clear() if requested - confirmed: result = self._handoff_bot_tasks(bot, requested - confirmed, confirm_uncertain=False) self._report_queued_handoff_result(result) if confirmed: result = self._handoff_bot_tasks(bot, confirmed, confirm_uncertain=True) self._report_queued_handoff_result(result) def _report_queued_handoff_result(self, result): """Report startup command results without turning a retry into consent.""" result = result if isinstance(result, dict) else {} if result.get("error"): self.target_queue.put(("log", "[转人工] 启动后处理交接失败,请返回任务队列核对:" + str(result["error"]))) return accepted = set(result.get("handed_off") or result.get("deleted") or []) pending_confirmation = set(result.get("requires_confirmation") or []) protected = set(result.get("protected") or []) - pending_confirmation scheduled = set(result.get("scheduled") or []) missing = set(result.get("missing") or []) if accepted: self.target_queue.put(("log", f"[转人工] 启动后已将 {len(accepted)} 条任务交给人工;保留发送记录,不自动重发。")) if pending_confirmation: self.target_queue.put(("log", f"[转人工] {len(pending_confirmation)} 条任务的发送结果需要核对,此次未执行移交。请返回任务队列,在企业微信核对后重新点击转人工并确认。")) if protected: self.target_queue.put(("log", f"[转人工] {len(protected)} 条任务仍受发送或账号保护,此次未执行移交。请返回任务队列查看原因。")) if scheduled: self.target_queue.put(("log", f"[转人工] {len(scheduled)} 条任务仍在等待处理,尚未完成交接。")) if missing: self.target_queue.put(("log", f"[转人工] {len(missing)} 条任务已不在待处理队列,此次未执行交接。")) def retry_pending_tasks(self, keys): """Retry live work, or queue the request until bot startup completes.""" requested = {str(key or "").strip() for key in (keys or [])} requested.discard("") if not requested: return { "retried": [], "missing": [], "protected": [], "not_retryable": [], "scheduled": [], } with self._cancel_lock: bot = self.bot if bot is None: self._queued_retries.update(requested) return { "retried": [], "missing": [], "protected": [], "not_retryable": [], "scheduled": sorted(requested), } return bot.retry_pending_replies(requested) def approve_pending_tasks(self, keys): """人工放行待审核草稿;bot 还没起来就先记下,起来后补做。""" requested = {str(key or "").strip() for key in (keys or [])} requested.discard("") if not requested: return {"approved": [], "missing": [], "not_pending": [], "scheduled": []} with self._cancel_lock: bot = self.bot if bot is None: self._queued_approvals.update(requested) return { "approved": [], "missing": [], "not_pending": [], "scheduled": sorted(requested), } return bot.approve_pending_replies(requested) def _drain_queued_cancellations(self, bot): with self._cancel_lock: requested = set(self._queued_cancellations) self._queued_cancellations.clear() if requested: bot.cancel_pending_replies(requested) def _drain_queued_retries(self, bot): with self._cancel_lock: requested = set(self._queued_retries) self._queued_retries.clear() if requested: bot.retry_pending_replies(requested) def _drain_queued_approvals(self, bot): with self._cancel_lock: requested = set(self._queued_approvals) self._queued_approvals.clear() if requested: bot.approve_pending_replies(requested) def _wait_for_recovery(self, context, failures, *, error=None, detail=""): """Keep the listener alive without resetting delivery or approval state.""" delay = min(30.0, 2.0 ** min(max(0, failures - 1), 5)) self.target_queue.put(("status", "recovering")) self._report_progress(f"{context} · 监听仍在运行,{delay:g} 秒后重新检查") reason = str(detail or error or "暂不可用") signature = (context, type(error).__name__, reason) now = time.monotonic() if (signature != getattr(self, "_last_recovery_signature", None) or now - getattr(self, "_last_recovery_log_at", 0.0) >= 30): self._last_recovery_signature = signature self._last_recovery_log_at = now message = f"[监听恢复] {context}:{reason};保留任务与发送保护,{delay:g} 秒后重新检查。" if error is not None: # Unexpected programming errors stay observable, too; a single # failed poll must not silently terminate the whole listener. message += "\n" + "".join(traceback.format_exception(type(error), error, error.__traceback__)) self.target_queue.put(("log", message)) # Only user stop interrupts backoff. New-message wakeups must not cause # a disconnected database or unsupported client to spin in a hot loop. self.stop_event.wait(delay) def _recovery_completed(self, context): self._last_recovery_signature = None self.target_queue.put(("log", f"[监听恢复] {context}已恢复,继续监听。")) self.target_queue.put(("status", "running")) def _visual_verification_complete(self, bot, bot_module): """Observe the login gate; never click or solve a verification challenge.""" self.target_queue.put(("status", "verification")) self._report_progress("监听仍在运行 · 请在企业微信完成人工验证,验证期间不点击或发送") try: full = bot._capture_full_window() ready = (not bot_module.looks_like_security_verification(full) and bool(bot._message_nav_selected(full))) except Exception as exc: ready = False marker = (type(exc).__name__, str(exc)) if marker != getattr(self, "_verification_capture_error", None): self._verification_capture_error = marker self.target_queue.put(("log", f"[安全验证] 暂不能确认消息界面恢复,继续等待:{exc}")) self._verification_ready_checks = (getattr(self, "_verification_ready_checks", 0) + 1) if ready else 0 if self._verification_ready_checks < 2: self.stop_event.wait(2.0) return False if self.stop_event.is_set(): return False self._verification_ready_checks = 0 self._verification_capture_error = None bot.security_verification_required = False bot._security_log_emitted = False bot._window_ready = False # Next poll must revalidate the real window. self.target_queue.put(("log", "[安全验证] 连续确认验证页面已消失且消息界面已恢复,继续监听。")) return True def run(self): bot = None failed = False try: import wechat_bot as bot_module bot_module.AUTO_REPLY_TEXT = self.reply_text bot = bot_module.WeChatBot() self.bot = bot from reply_database import LiveReplyDatabase self._db_source = LiveReplyDatabase() bot._db_source = self._db_source bot._reply_wakeup = self._reply_wakeup self._drain_queued_handoffs(bot) self._drain_queued_cancellations(bot) self._drain_queued_retries(bot) self._drain_queued_approvals(bot) bot.mouse_idle_enabled = self.mouse_idle_enabled bot.mouse_idle_seconds = self.mouse_idle_seconds bot.message_batch_window_seconds = self.message_batch_window_seconds bot.send_delay_seconds = self.send_delay_seconds bot.send_mode = self.send_mode bot.foreign_draft_autosend_minutes = self.foreign_draft_autosend_minutes bot._stop_check = self.stop_event bot.safe_window_mode = True bot.auto_activate_window = True bot.progress_cb = self._report_progress bot._visual_state_cb = self._report_visual_state connection_failures = 0 while not self.stop_event.is_set(): try: connected = bot.connect(activate=False, wait_if_missing=True) except Exception as exc: connection_failures += 1 self._wait_for_recovery("企业微信连接等待恢复", connection_failures, error=exc) continue if connected: break connection_failures += 1 self._wait_for_recovery("企业微信连接等待恢复", connection_failures, detail="请确认企业微信已登录并显示主界面") if self.stop_event.is_set(): return if connection_failures: self._recovery_completed("企业微信连接") # 引擎 B:数据直读检测(第二套方案)。与引擎 A 并行投递共享队列, # 发送动作仍收敛到引擎 A 的恢复通道 + 发送互斥锁,B 自身不碰窗口。 if self.enable_engine_b: try: from engine_b import DataEngine # 按数据源模式决定是否接入 DB 直读: # parallel:DB 直读 + conversations.json 并行双跑(默认) # db:DB 直读;初始化或解密失败期间保留引擎 A 的备用检测 # json:仅 conversations.json 档案监听 db_source = self._db_source if self.engine_b_data_source in ("parallel", "db") else None self._engine_b = DataEngine( bot=bot, poll_interval=self.engine_b_poll_interval, db_source=db_source, data_source_mode=self.engine_b_data_source, ) self._engine_b.start() self.target_queue.put(( "log", f"[引擎B] 数据直读检测已启用(每 {self.engine_b_poll_interval:g}s 轮询," f"数据源={self.engine_b_data_source})", )) except Exception as exc: self._engine_b = None self.target_queue.put(( "log", f"[-] 引擎 B 启动失败(不影响引擎 A): {exc}", )) print("[+] 窗口激活模式已开启:企业微信未显示时会自动还原到前台") print("[i] 数据库新消息与未读标记共同触发回复任务,窗口异常时保留任务并重试") if bot.mouse_idle_enabled: print( f"[+] 人机共存已开启:鼠标静止 " f"{bot.mouse_idle_seconds:.0f} 秒后才自动操作" ) print( f"[+] 连续消息合并等待:" f"{bot.message_batch_window_seconds:.0f} 秒" ) print(f"[+] 发送前等待:{bot.send_delay_seconds:g} 秒") if bot.send_mode == SEND_MODE_REVIEW: # 审核模式下"输入框里出现了回复却不发送"是正常结果,不是故障。 # 不写清楚的话,每个第一次用的人都会来报同一个 bug。 print( "[+] 发送模式:人工审核草稿" "(AI 只把回复填进输入框,不会自动按回车;" "需要自动发送请在设置里切到“自动发送”)" ) else: print("[+] 发送模式:自动发送(通过全部安全校验后自动按回车)") try: bot.report_vision_capability() except Exception as exc: print(f"[-] 视觉巡检未能完成(不影响监听): {exc}") last_ready = bool(bot._window_ready) self.target_queue.put(("status", "running" if last_ready else "waiting")) self.target_queue.put(("info", { "hwnd": f"0x{bot.hwnd:08X}", "size": f"{bot.R - bot.L} x {bot.B - bot.T}", "input": f"({bot.input_x}, {bot.input_y})", })) poll_failures = 0 while not self.stop_event.is_set(): self._reply_wakeup.clear() # Read-only assistant reconciliation remains available while the screen is busy. try: bot.poll_review_assistant() except Exception as exc: self.target_queue.put(("log", f"[助理审核] 核验暂不可用({type(exc).__name__}),保留审核任务")) if bot.security_verification_required: if not self._visual_verification_complete(bot, bot_module): continue last_ready = None # GUI 保存后只改变尚未开始的下一轮合并窗口;当前窗口不会被截断。 bot.message_batch_window_seconds = self.message_batch_window_seconds bot.send_delay_seconds = self.send_delay_seconds bot.send_mode = self.send_mode bot.foreign_draft_autosend_minutes = self.foreign_draft_autosend_minutes # 引擎 A 开关:关闭时跳过红点截图扫描,只保留队列恢复 # (引擎 B 投递的待回复任务仍会被捡起并发送)。 try: bot._poll_once(scan_unread=self.engine_a_enabled) except Exception as exc: if self.stop_event.is_set(): break poll_failures += 1 self._wait_for_recovery("视觉轮询等待恢复", poll_failures, error=exc) last_ready = None continue if poll_failures: self._recovery_completed("视觉轮询") poll_failures = 0 last_ready = None if bot.security_verification_required: self.target_queue.put(("status", "verification")) self._report_progress("监听仍在运行 · 等待用户完成企业微信安全验证") continue ready = bool(bot._window_ready) if ready != last_ready: last_ready = ready self.target_queue.put(("status", "running" if ready else "waiting")) if ready: self.target_queue.put(("info", { "hwnd": f"0x{bot.hwnd:08X}", "size": f"{bot.R - bot.L} x {bot.B - bot.T}", "input": f"({bot.input_x}, {bot.input_y})", })) self.target_queue.put(("stats", { "replied": bot.reply_count, "false_pos": len(bot.false_pos_rows), })) if not self.stop_event.is_set(): self._reply_wakeup.wait(self.poll_seconds) except Exception as exc: failed = True self.target_queue.put(( "log", f"[-] 后台线程异常:{exc}\n{traceback.format_exc()}", )) self.target_queue.put(("status", "error")) finally: if self._engine_b is not None: try: self._engine_b.stop() except Exception: pass self._engine_b = None if bot is not None: try: bot.close_review_assistant() except Exception: pass if self._db_source is not None: try: self._db_source.close() except Exception: pass self._db_source = None if bot is not None: try: bot.stop_visual_observer() except Exception: pass self.bot = None if not failed: self.target_queue.put(("status", "stopped")) def stop(self): self.stop_event.set() self._reply_wakeup.set() if self._engine_b is not None: try: self._engine_b.stop() except Exception: pass self._engine_b = None active_bot = getattr(self, "bot", None) if active_bot is not None: try: active_bot.close_review_assistant() except Exception: pass if self._db_source is not None: try: self._db_source.close() except Exception: pass self._db_source = None bot = getattr(self, "bot", None) if bot is not None: try: bot.stop_visual_observer() except Exception: pass def stop_after_current(self): """不再开始下一轮轮询,并让当前会话处理自然收尾。 普通的 ``stop`` 会立即关闭数据库源和视觉观察器,适合用户主动停止; 自动升级则必须避免在一次回复尚未落盘时拆掉这些资源,所以这里只设置 循环退出标记。``run`` 的 finally 会在当前 ``_poll_once`` 返回后统一清理。 """ self.stop_event.set()