# -*- 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" 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_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 delete_pending_reply_file(keys, path=""): """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 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 removed = [] for key in sorted(requested): state = pending.pop(key, None) if isinstance(state, dict): removed.append((key, state)) if not removed: 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 result["deleted"] = [key for key, _state in removed] result["missing"] = sorted(requested.difference(result["deleted"])) try: from queue_log import QueueLog queue_log = QueueLog() for key, state in removed: queue_log.append( key, str(state.get("display_name") or "(未识别昵称)"), "手动删除", "用户从回复队列中手动删除;本条未完成回复不再自动发送", ) except Exception: pass return result class LogQueue: """把后台线程的标准输出转发到界面,同时留一份带时间戳的磁盘副本。 界面日志随窗口关闭就没了,出问题时无从回溯——真正卡住发送的那一行往往 几分钟前就被刷走了。落盘副本让事后还查得到。 日志目录用的是可长期写入的数据目录:打包成 EXE 后 __file__ 指向随进程 销毁的解包目录,往那儿写等于退出即丢。 """ LOG_DIR = os.path.join(str(application_data_dir()), "logs") KEEP_FILES = 20 _PROGRESS_PREFIXES = ( ("[新消息]", "未读定位"), ("[未读扫描]", "未读扫描"), ("[页面校验]", "页面校验"), ("[会话校验]", "会话校验"), ("[会话识别]", "会话识别"), ("[会话守护]", "会话守护"), ("[页面恢复]", "页面恢复"), ("[页面清理]", "页面清理"), ("[视觉重定位]", "视觉重定位"), ("[视觉安全]", "视觉安全"), ("[视觉队列]", "视觉队列"), ("[目标指纹]", "目标确认"), ("[消息合并]", "消息合并"), ("[剪贴板]", "消息复制"), ("[发送保护]", "发送保护"), ("[发送对账]", "发送对账"), ("[发送回执]", "发送回执"), ("[安全模式]", "安全模式"), ("[安全验证]", "安全验证"), ("[人手]", "人机协同"), ("[AI 页面确认]", "AI 页面确认"), ("[AI页面守护]", "AI 页面守护"), ("[AI]", "AI 处理"), ) def __init__(self, target_queue): self.target_queue = target_queue self._handle = None self._open_log_file() 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): try: files = sorted( glob.glob(os.path.join(self.LOG_DIR, "gui_*.log")), key=os.path.getmtime, ) for path in files[: max(0, len(files) - self.KEEP_FILES + 1)]: os.unlink(path) except Exception: 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): 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._cancel_lock = threading.Lock() self._queued_cancellations = set() self._queued_retries = 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.stop_event = threading.Event() 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_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) 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 _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 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 self._drain_queued_cancellations(bot) self._drain_queued_retries(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._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 if not bot.connect(activate=False, wait_if_missing=True): failed = True self.target_queue.put(("status", "error")) return 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} 秒") print( "[+] 发送模式:" + ("人工审核草稿" if bot.send_mode == SEND_MODE_REVIEW else "自动发送") ) 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})", })) while not self.stop_event.is_set(): # 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._poll_once() if bot.security_verification_required: failed = True self.target_queue.put(("status", "verification")) self.stop_event.set() break 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), })) self.stop_event.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 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() bot = getattr(self, "bot", None) if bot is not None: try: bot.stop_visual_observer() except Exception: pass