新增功能
This commit is contained in:
+201
-4
@@ -148,8 +148,12 @@ CALL_WINDOW_KEYWORDS = (
|
||||
|
||||
# 人机共存:人工移动鼠标后,机器人暂停;鼠标静止满此秒数才继续操作
|
||||
MOUSE_IDLE_ENABLED = True
|
||||
MOUSE_IDLE_SECONDS = 20
|
||||
MOUSE_IDLE_SECONDS = 5
|
||||
MOUSE_MOVE_THRESHOLD = 8 # 位移超过此像素才视为人工移动(过滤抖动)
|
||||
SEND_DELAY_SECONDS = 1.0 # AI 回复写入输入框后、最终发送前的独立等待
|
||||
SEND_DELAY_MAX_SECONDS = 30.0
|
||||
SEND_MODE_AUTO = "auto"
|
||||
SEND_MODE_REVIEW = "review"
|
||||
|
||||
# 企业微信 UI 布局参数(相对于窗口左上角的像素偏移,适用于大多数 PC 版本)
|
||||
NAV_BAR_W = 68 # 最左侧导航图标栏宽度
|
||||
@@ -1303,6 +1307,8 @@ class WeChatBot:
|
||||
# 人机共存:人工操作鼠标时暂停自动回复
|
||||
self.mouse_idle_enabled = MOUSE_IDLE_ENABLED
|
||||
self.mouse_idle_seconds = MOUSE_IDLE_SECONDS
|
||||
self.send_delay_seconds = SEND_DELAY_SECONDS
|
||||
self.send_mode = SEND_MODE_AUTO
|
||||
# 每次开始新的消息合并窗口时读取实例值。GUI 可在监听期间更新它,
|
||||
# 已经开始的窗口保持原截止时间,下一轮回复使用新设置
|
||||
self.message_batch_window_seconds = MESSAGE_BATCH_WINDOW_SECONDS
|
||||
@@ -6813,6 +6819,38 @@ class WeChatBot:
|
||||
else:
|
||||
time.sleep(0.4)
|
||||
|
||||
def wait_before_send(self, expected_fp: bytes | None = None) -> bool:
|
||||
"""在最终发送前等待独立延迟,并允许停止监听或删除任务打断。"""
|
||||
raw_seconds = getattr(self, "send_delay_seconds", 0.0)
|
||||
try:
|
||||
seconds = float(raw_seconds)
|
||||
except (TypeError, ValueError):
|
||||
seconds = SEND_DELAY_SECONDS
|
||||
if isinstance(raw_seconds, bool):
|
||||
seconds = SEND_DELAY_SECONDS
|
||||
seconds = min(SEND_DELAY_MAX_SECONDS, max(0.0, seconds))
|
||||
if seconds <= 0:
|
||||
return True
|
||||
|
||||
print(f" [发送保护] 发送前等待 {seconds:g} 秒…")
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
if expected_fp is not None and self._reply_task_cancelled(expected_fp):
|
||||
return False
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return True
|
||||
interval = min(0.2, remaining)
|
||||
stop_check = getattr(self, "_stop_check", None)
|
||||
if stop_check is not None:
|
||||
if stop_check.wait(interval):
|
||||
return False
|
||||
else:
|
||||
time.sleep(interval)
|
||||
|
||||
def _review_mode_enabled(self) -> bool:
|
||||
return str(getattr(self, "send_mode", SEND_MODE_AUTO)) == SEND_MODE_REVIEW
|
||||
|
||||
def _mouse_is_idle_now(self) -> bool:
|
||||
"""Check human activity without blocking the receipt/recovery queue."""
|
||||
if not getattr(self, "mouse_idle_enabled", False):
|
||||
@@ -7385,6 +7423,13 @@ class WeChatBot:
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
send_dispatched_at = 0.0
|
||||
try:
|
||||
review_requested_at = max(
|
||||
0.0,
|
||||
float(value.get("review_requested_at", 0.0) or 0.0),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
review_requested_at = 0.0
|
||||
raw_baseline_blocks = value.get("send_baseline_blocks")
|
||||
send_baseline_blocks = None
|
||||
if isinstance(raw_baseline_blocks, list) and raw_baseline_blocks:
|
||||
@@ -7428,6 +7473,8 @@ class WeChatBot:
|
||||
),
|
||||
"uncertain_since": uncertain_since,
|
||||
"send_dispatched_at": send_dispatched_at,
|
||||
"awaiting_review": bool(value.get("awaiting_review", False)),
|
||||
"review_requested_at": review_requested_at,
|
||||
"reply_text": str(value.get("reply_text") or ""),
|
||||
"staged_user_text": str(value.get("staged_user_text") or ""),
|
||||
"staged_reply_text": str(value.get("staged_reply_text") or ""),
|
||||
@@ -7565,6 +7612,10 @@ class WeChatBot:
|
||||
"send_dispatched_at": float(
|
||||
value.get("send_dispatched_at", 0.0) or 0.0
|
||||
),
|
||||
"awaiting_review": bool(value.get("awaiting_review", False)),
|
||||
"review_requested_at": float(
|
||||
value.get("review_requested_at", 0.0) or 0.0
|
||||
),
|
||||
"reply_text": str(value.get("reply_text") or ""),
|
||||
"staged_user_text": str(value.get("staged_user_text") or ""),
|
||||
"staged_reply_text": str(value.get("staged_reply_text") or ""),
|
||||
@@ -7725,6 +7776,14 @@ class WeChatBot:
|
||||
"""
|
||||
if self._reply_task_cancelled(fp):
|
||||
return
|
||||
if self._reply_awaiting_review(fp):
|
||||
self._set_task_stage(
|
||||
fp,
|
||||
"manual_review",
|
||||
detail="AI 回复已填入输入框,等待人工核对并发送",
|
||||
)
|
||||
self._log_queue_event(fp, "待审核", "回复草稿已填入企业微信输入框")
|
||||
return
|
||||
if sent:
|
||||
self._log_queue_event(fp, "已发送", reply_text)
|
||||
return
|
||||
@@ -7749,6 +7808,10 @@ class WeChatBot:
|
||||
)
|
||||
self._log_queue_event(fp, "失败", reason or "发送未成功,任务保留")
|
||||
|
||||
def _reply_awaiting_review(self, fp: bytes) -> bool:
|
||||
state = self._pending_reply_state(bytes(fp or b"")) or {}
|
||||
return bool(state.get("awaiting_review", False))
|
||||
|
||||
_GENERATION_EVENTS = frozenset({"生成回复", "跳过", "失败"})
|
||||
|
||||
_QUEUE_EVENT_PROGRESS = {
|
||||
@@ -7756,6 +7819,7 @@ class WeChatBot:
|
||||
"开始处理": "正在读取消息",
|
||||
"调用模型": "正在调用 AI 模型",
|
||||
"生成回复": "回复已生成,准备发送",
|
||||
"待审核": "回复草稿等待人工审核",
|
||||
"已发送": "回复已发送",
|
||||
"发送待核对": "正在核对发送结果",
|
||||
"对账": "正在核对发送结果",
|
||||
@@ -7925,6 +7989,7 @@ class WeChatBot:
|
||||
"blocked_modal": "弹窗阻塞",
|
||||
"call_paused": "视频/通话暂停",
|
||||
"manual_takeover": "人工接管",
|
||||
"manual_review": "等待人工审核",
|
||||
"uncertain": "发送待核对",
|
||||
}
|
||||
|
||||
@@ -8024,6 +8089,8 @@ class WeChatBot:
|
||||
state.pop("registration_lead", None)
|
||||
state.pop("archive_enabled", None)
|
||||
state.pop("send_dispatched_at", None)
|
||||
state.pop("awaiting_review", None)
|
||||
state.pop("review_requested_at", None)
|
||||
state.pop("send_baseline_blocks", None)
|
||||
state.pop("send_baseline_text", None)
|
||||
state.pop("last_pasted_draft", None)
|
||||
@@ -8456,6 +8523,88 @@ class WeChatBot:
|
||||
pass
|
||||
return result
|
||||
|
||||
def retry_pending_replies(self, keys) -> dict:
|
||||
"""Move selected retry-wait tasks back to the runnable queue safely.
|
||||
|
||||
This deliberately refuses normal in-flight work, manual-review drafts and
|
||||
any transaction whose Enter key may already have been dispatched. The UI
|
||||
therefore cannot turn an innocent refresh click into a duplicate reply.
|
||||
"""
|
||||
requested = {str(key or "").strip() for key in (keys or [])}
|
||||
requested.discard("")
|
||||
result = {
|
||||
"retried": [],
|
||||
"missing": [],
|
||||
"protected": [],
|
||||
"not_retryable": [],
|
||||
"scheduled": [],
|
||||
}
|
||||
if not requested:
|
||||
return result
|
||||
lock = getattr(self, "_pending_lock", None)
|
||||
|
||||
def retry_unlocked() -> bool:
|
||||
pending = getattr(self, "_pending_reply_sessions", {})
|
||||
originals = {}
|
||||
for key in sorted(requested):
|
||||
state = pending.get(key)
|
||||
if not isinstance(state, dict):
|
||||
result["missing"].append(key)
|
||||
continue
|
||||
send_state = str(state.get("send_state") or "")
|
||||
if bool(state.get("awaiting_review", False)) or send_state in {
|
||||
"sending",
|
||||
"sent_uncommitted",
|
||||
"uncertain",
|
||||
}:
|
||||
result["protected"].append(key)
|
||||
continue
|
||||
if str(state.get("stage") or "") != "retry_wait":
|
||||
result["not_retryable"].append(key)
|
||||
continue
|
||||
try:
|
||||
fp = bytes.fromhex(key)
|
||||
except ValueError:
|
||||
result["missing"].append(key)
|
||||
continue
|
||||
originals[key] = dict(state)
|
||||
state["last_resume_attempt"] = 0.0
|
||||
self._set_task_stage_unlocked(
|
||||
fp,
|
||||
"queued",
|
||||
detail="用户请求立即重试",
|
||||
)
|
||||
result["retried"].append(key)
|
||||
if not result["retried"]:
|
||||
return True
|
||||
if self._persist_pending_replies_unlocked():
|
||||
return True
|
||||
for key, state in originals.items():
|
||||
pending[key] = state
|
||||
result["error"] = "待回复队列写入失败"
|
||||
result["retried"].clear()
|
||||
return False
|
||||
|
||||
if lock is None:
|
||||
persisted = retry_unlocked()
|
||||
else:
|
||||
with lock:
|
||||
persisted = retry_unlocked()
|
||||
if not persisted:
|
||||
return result
|
||||
for key in result["retried"]:
|
||||
state = getattr(self, "_pending_reply_sessions", {}).get(key) or {}
|
||||
try:
|
||||
self.queue_log.append(
|
||||
key,
|
||||
str(state.get("display_name") or "") or "(未识别昵称)",
|
||||
"手动重试",
|
||||
"用户从任务队列请求立即重试,已重新排队",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _clear_reply_pending(self, fp: bytes) -> None:
|
||||
lock = getattr(self, "_pending_lock", None)
|
||||
if lock is None:
|
||||
@@ -10009,6 +10158,8 @@ class WeChatBot:
|
||||
"""
|
||||
if not normalized_draft or not isinstance(pending_state, dict):
|
||||
return False
|
||||
if pending_state.get("awaiting_review", False):
|
||||
return False
|
||||
# 已经按过回车的事务归回执核对管,这里擅自清掉草稿再发一遍就是重复发送
|
||||
if str(pending_state.get("send_state") or ""):
|
||||
return False
|
||||
@@ -10066,6 +10217,14 @@ class WeChatBot:
|
||||
return self._deny_send("检测到登录/安全验证页,已禁止一切自动操作")
|
||||
if not self._await_send_gate():
|
||||
return self._deny_send("发送限频未过,等待后仍不允许发送")
|
||||
if not self.wait_before_send(expected_fp):
|
||||
if expected_fp is not None and self._reply_task_cancelled(expected_fp):
|
||||
return self._deny_send("任务已被手动删除")
|
||||
return self._deny_send("监听已停止,未发送自动回复")
|
||||
if expected_fp is not None and self._reply_task_cancelled(expected_fp):
|
||||
return self._deny_send("任务已被手动删除")
|
||||
if self._security_gate_visible():
|
||||
return self._deny_send("发送前等待期间出现登录/安全验证页,已禁止发送")
|
||||
if not self.wait_for_mouse_idle():
|
||||
return self._deny_send("人在用鼠标,等待其停下超时")
|
||||
self.report_operation("发送", 2, 12, "限频通过且鼠标空闲", expected_fp)
|
||||
@@ -10411,6 +10570,24 @@ class WeChatBot:
|
||||
print(" [发送保护] 任务已被手动删除,已清除尚未发送的自动草稿。")
|
||||
return self._deny_send("任务已被手动删除")
|
||||
|
||||
if self._review_mode_enabled():
|
||||
pending_state["awaiting_review"] = True
|
||||
pending_state["review_requested_at"] = time.time()
|
||||
pending_state["reply_text"] = reply_text
|
||||
pending_state["last_pasted_draft"] = expected_reply
|
||||
persisted = self._set_task_stage(
|
||||
expected_fp,
|
||||
"manual_review",
|
||||
detail="AI 回复已填入输入框,等待人工核对并发送",
|
||||
)
|
||||
reason = "回复已填入输入框,等待人工审核发送"
|
||||
if not persisted:
|
||||
reason = "回复已填入输入框,但审核任务状态未能保存;已禁止自动发送"
|
||||
self._last_send_failure_reason = reason
|
||||
self.report_operation("人工审核", 1, 1, reason, expected_fp)
|
||||
print(f" [人工审核] {reason}。")
|
||||
return False
|
||||
|
||||
if pending_state:
|
||||
baseline_text = "\n".join(
|
||||
str(line)
|
||||
@@ -11605,6 +11782,11 @@ class WeChatBot:
|
||||
self._active_hold_since = 0.0
|
||||
self._active_hold_expired = False
|
||||
return False
|
||||
if bool((pending.get(key) or {}).get("awaiting_review", False)):
|
||||
self._active_hold_key = ""
|
||||
self._active_hold_since = 0.0
|
||||
self._active_hold_expired = False
|
||||
return False
|
||||
now = time.monotonic()
|
||||
if getattr(self, "_active_hold_key", "") != key:
|
||||
self._active_hold_key = key
|
||||
@@ -11653,6 +11835,8 @@ class WeChatBot:
|
||||
pending.pop(key, None)
|
||||
self._persist_pending_replies()
|
||||
continue
|
||||
if bool(state.get("awaiting_review", False)):
|
||||
continue
|
||||
if self._session_is_unrepliable(target_fp):
|
||||
# 已经证实没有输入框,连打开都不必,免得每轮都被它占满
|
||||
self._clear_reply_pending(target_fp)
|
||||
@@ -11841,13 +12025,17 @@ class WeChatBot:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
self._set_task_stage(target_fp, "sending", detail="恢复任务,正在发送回复")
|
||||
if self.send_reply(
|
||||
sent = self.send_reply(
|
||||
reply_text,
|
||||
session_id=target_fp.hex(),
|
||||
expected_fp=target_fp,
|
||||
):
|
||||
)
|
||||
if sent:
|
||||
state.pop("resume_failures", None)
|
||||
self._log_send_outcome(target_fp, True, reply_text)
|
||||
elif self._reply_awaiting_review(target_fp):
|
||||
state.pop("resume_failures", None)
|
||||
self._log_send_outcome(target_fp, False, reply_text)
|
||||
else:
|
||||
self._log_send_outcome(target_fp, False)
|
||||
self._note_resume_failure(state)
|
||||
@@ -12151,6 +12339,10 @@ class WeChatBot:
|
||||
return False
|
||||
|
||||
if pending_state is not None:
|
||||
if bool(pending_state.get("awaiting_review", False)):
|
||||
if self._nothing_left_to_answer(fp):
|
||||
return True
|
||||
return False
|
||||
send_reconciliation = self._reconcile_uncertain_send(fp, pending_state)
|
||||
if send_reconciliation == "sent":
|
||||
return True
|
||||
@@ -12338,7 +12530,7 @@ class WeChatBot:
|
||||
)
|
||||
self._log_send_outcome(fp, sent, reply_text)
|
||||
time.sleep(0.5)
|
||||
return sent
|
||||
return bool(sent or self._reply_awaiting_review(fp))
|
||||
|
||||
def _generate_ai_reply(
|
||||
self,
|
||||
@@ -13371,6 +13563,11 @@ class WeChatBot:
|
||||
session_id=target_fp.hex(),
|
||||
expected_fp=target_fp,
|
||||
):
|
||||
if self._reply_awaiting_review(target_fp):
|
||||
self.report_progress(f"{who} 的回复草稿等待人工审核")
|
||||
self._log_send_outcome(target_fp, False, reply_text)
|
||||
processed_fp.add(target_fp)
|
||||
break
|
||||
print(" [发送保护] 本次回复未发送,已停止继续处理当前批次。")
|
||||
self.report_progress(f"{who} 未发送成功,稍后重试")
|
||||
self._log_send_outcome(target_fp, False)
|
||||
|
||||
Reference in New Issue
Block a user