This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
+212 -10
View File
@@ -25,6 +25,14 @@ 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):
@@ -53,6 +61,29 @@ def normalize_send_delay_seconds(value, default=SEND_DELAY_SECONDS):
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()
@@ -143,6 +174,10 @@ class LogQueue:
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 = (
("[新消息]", "未读定位"),
("[未读扫描]", "未读扫描"),
@@ -169,11 +204,23 @@ class LogQueue:
("[AI]", "AI 处理"),
)
def __init__(self, target_queue):
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)
@@ -189,15 +236,37 @@ class LogQueue:
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,
)
for path in files[: max(0, len(files) - self.KEEP_FILES + 1)]:
os.unlink(path)
except Exception:
pass
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()
@@ -258,7 +327,12 @@ class BotThread(threading.Thread):
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):
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
@@ -266,16 +340,30 @@ class BotThread(threading.Thread):
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):
"""更新下一次消息合并窗口;不会改变已经开始等待的窗口快照。"""
@@ -285,6 +373,14 @@ class BotThread(threading.Thread):
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)
@@ -356,6 +452,24 @@ class BotThread(threading.Thread):
}
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)
@@ -370,6 +484,13 @@ class BotThread(threading.Thread):
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
@@ -381,11 +502,13 @@ class BotThread(threading.Thread):
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
@@ -397,6 +520,48 @@ class BotThread(threading.Thread):
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:
@@ -409,10 +574,20 @@ class BotThread(threading.Thread):
f"{bot.message_batch_window_seconds:.0f}"
)
print(f"[+] 发送前等待:{bot.send_delay_seconds:g}")
print(
"[+] 发送模式:"
+ ("人工审核草稿" if bot.send_mode == SEND_MODE_REVIEW else "自动发送")
)
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"))
@@ -427,7 +602,10 @@ class BotThread(threading.Thread):
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()
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"))
@@ -456,6 +634,18 @@ class BotThread(threading.Thread):
))
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()
@@ -467,6 +657,18 @@ class BotThread(threading.Thread):
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: