Files
kefu/wechat_rpa/gui_runtime.py
T
2026-09-21 10:34:06 +08:00

915 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()