Files
kefu/wechat_rpa/gui_runtime.py
2026-08-27 14:04:28 +08:00

678 lines
27 KiB
Python
Raw Permalink 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.pyTk 控制台)里,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 直读:
# parallelDB 直读 + 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