678 lines
27 KiB
Python
678 lines
27 KiB
Python
# -*- 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 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
|
||
# 「数据保留 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_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.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)
|
||
|
||
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 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)
|
||
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
|
||
|
||
if not bot.connect(activate=False, wait_if_missing=True):
|
||
failed = True
|
||
self.target_queue.put(("status", "error"))
|
||
return
|
||
|
||
# 引擎 B:数据直读检测(第二套方案)。与引擎 A 并行投递共享队列,
|
||
# 发送动作仍收敛到引擎 A 的恢复通道 + 发送互斥锁,B 自身不碰窗口。
|
||
if self.enable_engine_b:
|
||
try:
|
||
from engine_b import DataEngine
|
||
|
||
# 按数据源模式决定是否接入 DB 直读:
|
||
# parallel:DB 直读 + conversations.json 并行双跑(默认)
|
||
# db:仅 DB 直读(无密钥/解密失败时自动退化为 JSON)
|
||
# json:仅 conversations.json 档案监听
|
||
db_source = None
|
||
if self.engine_b_data_source in ("parallel", "db"):
|
||
try:
|
||
from wxwork_db import WXWorkDB, detect_wxwork_dir, load_keys
|
||
|
||
db_source = WXWorkDB(detect_wxwork_dir(), load_keys())
|
||
except Exception as exc:
|
||
db_source = None
|
||
self.target_queue.put((
|
||
"log",
|
||
f"[引擎B] DB 直读初始化失败({exc}),仅用 conversations.json",
|
||
))
|
||
self._db_source = db_source
|
||
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})",
|
||
}))
|
||
|
||
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.foreign_draft_autosend_minutes = self.foreign_draft_autosend_minutes
|
||
# 引擎 A 开关:关闭时跳过红点截图扫描,只保留队列恢复
|
||
# (引擎 B 投递的待回复任务仍会被捡起并发送)。
|
||
bot._poll_once(scan_unread=self.engine_a_enabled)
|
||
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 self._engine_b is not None:
|
||
try:
|
||
self._engine_b.stop()
|
||
except Exception:
|
||
pass
|
||
self._engine_b = None
|
||
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()
|
||
if self._engine_b is not None:
|
||
try:
|
||
self._engine_b.stop()
|
||
except Exception:
|
||
pass
|
||
self._engine_b = None
|
||
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
|