This commit is contained in:
Your Name
2026-08-03 10:52:51 +08:00
parent 5ac803f7a6
commit 1048b9ba29
29 changed files with 436 additions and 597 deletions
+218
View File
@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""GUI 无关的后台运行时:日志转发与机器人轮询线程。
原先住在 wechat_gui.pyTk 控制台)里,Qt 控制台 import 它时会连带把整个
tkinter/tcl 拖进进程——打包后的 EXE 每次冷启动都白付这笔钱。抽出来之后两个
界面共用,谁也不用替对方的依赖买单。
"""
import glob
import os
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
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
class LogQueue:
"""把后台线程的标准输出转发到界面,同时留一份带时间戳的磁盘副本。
界面日志随窗口关闭就没了,出问题时无从回溯——真正卡住发送的那一行往往
几分钟前就被刷走了。落盘副本让事后还查得到。
日志目录用的是可长期写入的数据目录:打包成 EXE 后 __file__ 指向随进程
销毁的解包目录,往那儿写等于退出即丢。
"""
LOG_DIR = os.path.join(str(application_data_dir()), "logs")
KEEP_FILES = 20
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))
if self._handle is not None:
try:
self._handle.write(f"{time.strftime('%H:%M:%S')} {text}\n")
except Exception:
self._handle = None
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=20.0,
message_batch_window_seconds=MESSAGE_BATCH_WINDOW_SECONDS):
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.message_batch_window_seconds = MESSAGE_BATCH_WINDOW_SECONDS
self.set_message_batch_window_seconds(message_batch_window_seconds)
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 _report_progress(self, text):
"""把机器人此刻在做什么送到界面上那行进度文字。"""
try:
self.target_queue.put(("progress", str(text or "")))
except Exception:
# 进度提示纯属好看,永远不该把轮询带下去
pass
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
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._stop_check = self.stop_event
bot.safe_window_mode = True
bot.auto_activate_window = True
bot.progress_cb = self._report_progress
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}"
)
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._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:
self.bot = None
if not failed:
self.target_queue.put(("status", "stopped"))
def stop(self):
self.stop_event.set()