Files
kefu/deploy/skip-sessions-20260917/visual-target.diff
T
2026-09-21 10:34:06 +08:00

343 lines
18 KiB
Diff
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.
--- C:\wechat_rpa\wechat_bot.py.before
+++ C:\wechat_rpa\wechat_bot.py
@@ -145,6 +145,7 @@
"日程", "文档", "智能文档", "会议", "直播", "工作台", "通讯录",
"客户群", "企业支付", "健康上报", "学习园地", "企微管家",
"企业微信团队", "微信团队", "离职继承",
+ "企小码", "企小码会话", "企小码会话管理", "企小碼", "企小碼會話管理",
# RapidOCR 在现场出现过的稳定误读。
"江卡", "亏业资讯",
})
@@ -1954,6 +1955,129 @@
normalized = session_name.normalize_name(str(name or ""))
return bool(normalized and normalized in SYSTEM_ENTRY_NAMES)
+ def _confirmed_open_session_exclusion(self, fp: bytes, name: str = "", observed_title=None) -> str:
+ """Confirm excluded headers twice against the actual selected identity."""
+ from reply_session_policy import excluded_session_reason, visual_group_title_reason
+ try:
+ title = self._open_chat_display_name() if observed_title is None else str(observed_title)
+ reason = excluded_session_reason(title)
+ if not reason:
+ # Every possible member suffix needs the independent list name,
+ # including explicit units: a customer may be named "张三(2人)".
+ if not name and re.search(r"[((]\d+[^))]*[))]$", str(title or "")):
+ image = self.capture_session_list()
+ row = self.detect_selected_row(image)
+ if row < 0 or not self._session_fp_matches(self._session_fingerprint(image, row, row_center=True), fp):
+ return ""
+ name = self._row_display_name(image, row, row_center=True)
+ if not name:
+ return ""
+ reason = visual_group_title_reason(title, name=name)
+ if not reason:
+ return ""
+
+ def proven(identity, observed_title):
+ if not identity or not self._message_workspace_selected(self._capture_full_window()):
+ return False
+ current = self._raw_selected_session_fingerprint() or self._selected_session_fingerprint()
+ if current:
+ proof = self._selected_target_proof(fp, current_fp=current, selected_fp_checked=True)
+ if proof is False:
+ return False # A complete different row vetoes the title.
+ if proof is True:
+ return self._chat_identity_signature() == identity
+ # Legacy/partial fingerprints cannot prove the target by
+ # comparing a freshly read title identity to itself.
+
+ # An opened row can disappear from the unread filter. Bind
+ # the current header to the saved list name on BOTH frames;
+ # the old name's hash alone cannot prove the user stayed here.
+ normalized_title = session_name.normalize_name(observed_title)
+ normalized_name = session_name.normalize_name(name)
+ members = re.fullmatch(r"(.+?)\(([0-9]{1,6})(?:人|位成员|成员)?\)", normalized_title)
+ title_bound = bool(normalized_name and (
+ normalized_title == normalized_name
+ or (members and members[1] == normalized_name
+ and visual_group_title_reason(observed_title, name=name))))
+ return bool(title_bound and self._fp_from_name(name) == fp
+ and self._chat_surface_signature())
+
+ identity = self._chat_identity_signature()
+ if not proven(identity, title):
+ return ""
+ time.sleep(0.06)
+ second_title = self._open_chat_display_name()
+ second_identity = self._chat_identity_signature()
+ if (second_identity != identity or not proven(second_identity, second_title)
+ or session_name.normalize_name(second_title) != session_name.normalize_name(title)):
+ return ""
+ second_reason = excluded_session_reason(second_title) or visual_group_title_reason(second_title, name=name)
+ return reason if second_reason == reason else ""
+ except Exception:
+ return "" # Missing OCR or geometry cannot prove a group.
+
+ def _skip_excluded_reply_session(self, fp: bytes, *, inspect_open=False, display_name="", metadata=None, observed_title=None) -> bool:
+ """Skip proven excluded sessions, retaining possibly-sent evidence."""
+ from contextlib import nullcontext
+ from reply_session_policy import excluded_session_reason
+ raw = bytes(fp or b"")
+ if not raw:
+ return False
+ key = raw.hex()
+ state_ref = (getattr(self, "_pending_reply_sessions", {}) or {}).get(key)
+ state = state_ref if isinstance(state_ref, dict) else {}
+ name = str(display_name or state.get("display_name") or "")
+ event = state.get("database_event") if isinstance(state.get("database_event"), dict) else {}
+ supplied = metadata if isinstance(metadata, dict) else {}
+ reason = excluded_session_reason(name, metadata=supplied) or excluded_session_reason(name, metadata=state)
+ cache = getattr(self, "_excluded_session_cache", None)
+ if cache is None:
+ cache = self._excluded_session_cache = {}
+ cached = cache.get(key) or {}
+ conv_id = str(supplied.get("conv_id") or event.get("conv_id") or state.get("conv_id") or "")
+ personal = (conv_id.startswith("M:") and len(conv_id) > 2 or re.fullmatch(r"S:\d+_\d+", conv_id) is not None)
+ if not reason and not personal and time.monotonic() < float(cached.get("until") or 0):
+ reason = str(cached.get("reason") or "")
+ if not reason and inspect_open and not personal:
+ reason = self._confirmed_open_session_exclusion(raw, name, observed_title=observed_title)
+ if not reason:
+ return False
+ self._last_session_exclusion_reason = reason
+ lock = getattr(self, "_pending_lock", None)
+ with lock if lock is not None else nullcontext():
+ current = (getattr(self, "_pending_reply_sessions", {}) or {}).get(key)
+ if current is not state_ref:
+ # Detection/another action replaced the task during the two
+ # frames. Yield this old attempt without deleting or caching
+ # a decision against the new task under the same fingerprint.
+ return True
+ if isinstance(current, dict):
+ protected = (str(current.get("stage") or "") in {"sending", "receipt_check", "uncertain", "unknown", "sent_uncommitted", "failed"}
+ or str(current.get("send_state") or "") in {"sending", "uncertain", "unknown", "sent_uncommitted", "failed"}
+ or bool(current.get("send_dispatched_at")))
+ if not protected:
+ self._clear_reply_pending(raw)
+ if reason:
+ cache[key] = {"reason": reason, "until": time.monotonic() + 300}
+ if len(cache) > 256:
+ for stale in sorted(cache, key=lambda item: cache[item].get("until", 0))[:-256]:
+ cache.pop(stale, None)
+ logged = getattr(self, "_excluded_session_logged", None)
+ if logged is None:
+ logged = self._excluded_session_logged = set()
+ marker = (key, reason)
+ if marker not in logged:
+ if len(logged) >= 256:
+ logged.clear()
+ logged.add(marker)
+ try:
+ self.queue_log.append(self.session_id_of(raw), name or "已排除会话", "跳过", reason)
+ except Exception:
+ pass
+ self.report_progress(reason)
+ print(f" [会话过滤] {reason}")
+ return True
+
def _purge_system_pending_tasks(self) -> int:
"""移除已经能由昵称明确证明是企业微信内置入口的旧积压。"""
pending = getattr(self, "_pending_reply_sessions", {})
@@ -1962,7 +2086,10 @@
if not isinstance(state, dict):
continue
name = str(state.get("display_name") or "")
- if self._is_system_entry_name(name):
+ protected = (str(state.get("stage") or "") in {"sending", "receipt_check", "uncertain", "unknown", "sent_uncommitted", "failed"}
+ or str(state.get("send_state") or "") in {"sending", "uncertain", "unknown", "sent_uncommitted", "failed"}
+ or bool(state.get("send_dispatched_at")))
+ if self._is_system_entry_name(name) and not protected:
pending.pop(key, None)
removed.append((key, name))
if removed:
@@ -8554,6 +8681,8 @@
return False, "非法会话指纹"
if len(fp) not in (_LEGACY_SESSION_FP_BYTES, _SESSION_FP_BYTES):
return False, "会话指纹长度不合法"
+ if self._skip_excluded_reply_session(fp, display_name=display_name, metadata=database_event):
+ return False, str(getattr(self, "_last_session_exclusion_reason", "会话不参与自动回复"))
now = time.time()
try:
with self._pending_lock:
@@ -9414,6 +9543,8 @@
display_name: str = "",
stage: str = "",
) -> bool:
+ if self._skip_excluded_reply_session(fp, display_name=display_name):
+ return False
lock = getattr(self, "_pending_lock", None)
if lock is None:
return self._mark_reply_pending_unlocked(
@@ -9900,6 +10031,8 @@
每一轮都会重新打开它、调一次模型、再卡在发送保护上,真正的客户被挤到
后面——用户看到的就是“机器人不回消息了”。
"""
+ if self._skip_excluded_reply_session(fp, inspect_open=True):
+ return False
key = bytes(fp or b"").hex()
if not key:
return True
@@ -11777,6 +11910,9 @@
) -> bool:
"""点击会话并校验实际打开的会话,避免列表重排造成误点。"""
self._last_click_failure_reason = "unknown"
+ if expected_fp and self._skip_excluded_reply_session(expected_fp):
+ self._last_click_failure_reason = "excluded_session"
+ return False
if not getattr(self, "_session_geometry_valid", True):
self._last_click_failure_reason = "invalid_geometry"
print(" [页面校验] 会话列表空间不足,已禁止窗口外点击。")
@@ -12025,6 +12161,10 @@
# row identity is the stronger proof and must be checked before the
# unchanged-title blocker heuristic.
if opened_matches:
+ if self._skip_excluded_reply_session(expected_fp or opened_fp, inspect_open=True,
+ observed_title=opened_name if title_matches_filtered_target else None):
+ self._last_click_failure_reason = "excluded_session"
+ return False
self._last_click_failure_reason = ""
self._remember_active_surface(opened_fp or expected_fp)
return True
@@ -12152,6 +12292,9 @@
) -> bool:
"""校验当前会话后发送回复;发送后保留聊天页,不跳转工具页面。"""
self._last_send_failure_reason = ""
+ exclusion_fp = expected_fp or getattr(self, "_active_session_fp", None)
+ if exclusion_fp and self._skip_excluded_reply_session(exclusion_fp, inspect_open=True):
+ return self._deny_send(str(getattr(self, "_last_session_exclusion_reason", "会话不参与自动回复")))
self.report_operation("发送", 1, 12, "检查任务、通话窗口与安全验证", expected_fp)
if expected_fp is not None and self._reply_task_cancelled(expected_fp):
return self._deny_send("任务已被手动删除")
@@ -12590,6 +12733,8 @@
# 全局"要不要全部审核"(_review_mode_enabled)之外,自动发送模式下
# 每一条还要单独过一遍规则闸——只有触发了规则或者被裁判判成高风险的
# 才破例停下来,其余照常自动发送。两者是 OR:任何一个成立都要停。
+ if self._skip_excluded_reply_session(expected_fp, inspect_open=True):
+ return self._deny_send(str(getattr(self, "_last_session_exclusion_reason", "会话不参与自动回复")))
blanket_review = self._review_mode_enabled()
rule_hit, rule_reason = (
(False, "")
@@ -13098,6 +13243,9 @@
non_conv_fp.add(system_fp)
continue
fp = self._session_fingerprint(img, badge_y)
+ if self._skip_excluded_reply_session(fp, display_name=row_name):
+ non_conv_fp.add(fp)
+ continue
if fp in processed_fp or fp in non_conv_fp:
continue
# 自动点击的身份必须同时包含头像和名称;空/旧式头像键不能作
@@ -13241,6 +13389,9 @@
or fp in non_conv_fp
):
continue
+ if self._skip_excluded_reply_session(fp, display_name=row_name):
+ non_conv_fp.add(fp)
+ continue
if self._flat_session_rejected(fp):
non_conv_fp.add(fp)
continue
@@ -14005,6 +14156,8 @@
except (TypeError, ValueError):
pending.pop(key, None)
self._persist_pending_replies()
+ continue
+ if self._skip_excluded_reply_session(target_fp):
continue
if bool(state.get("awaiting_review", False)):
continue
@@ -14093,6 +14246,8 @@
expected_fp=target_fp,
row_center=True,
):
+ if getattr(self, "_last_click_failure_reason", "") == "excluded_session":
+ continue
if getattr(self, "_last_click_failure_reason", "") == "not_a_conversation":
# 打不开是这一行本身的性质,不是时机不巧。留在队列里只会让
# 每一轮都为它做一次全列表分页扫描,真正的客户排在它后面
@@ -14497,6 +14652,8 @@
self._active_identity_signature = None
return False
+ if self._skip_excluded_reply_session(fp, inspect_open=True):
+ return False
pending_state = self._pending_reply_state(fp)
if pending_state is None and len(fp) == _SESSION_FP_BYTES:
transition_matches = []
@@ -14757,6 +14914,9 @@
def _generate_ai_reply(self, fp: bytes, chat_text: str = None,
force_media_check: bool = False, confirmed_unread: bool = False,
reliable_text_pending: bool = False) -> str:
+ if self._skip_excluded_reply_session(fp, inspect_open=True):
+ self._generation_logged = True
+ return None
self._consume_database_update(fp)
initial_state = self._pending_reply_state(fp)
state = initial_state or {}
@@ -15721,6 +15881,10 @@
**pending_kwargs,
)
if pending_saved is False:
+ if self._skip_excluded_reply_session(target_fp, display_name=candidate_name):
+ processed_fp.add(target_fp)
+ non_conv_fp.add(target_fp)
+ continue
if self._manual_handoff_key(target_fp):
self._notice_manual_handoff(target_fp)
continue
@@ -15766,6 +15930,10 @@
expected_fp=target_fp,
**click_kwargs,
):
+ if getattr(self, "_last_click_failure_reason", "") == "excluded_session":
+ processed_fp.add(target_fp)
+ non_conv_fp.add(target_fp)
+ continue
flat_candidate = (
target_fp.hex()
in getattr(self, "_flat_visual_proof_fps", set())
@@ -15832,6 +16000,10 @@
confirmed_unread=True,
requires_visual_proof=requires_visual_proof,
) is False:
+ if self._skip_excluded_reply_session(target_fp, display_name=candidate_name):
+ processed_fp.add(target_fp)
+ non_conv_fp.add(target_fp)
+ continue
print(" [队列] 任务已被手动删除,本轮不再读取、生成或发送。")
break
self._set_task_stage(target_fp, "reading", detail="会话已打开,正在读取新消息")
@@ -15876,6 +16048,10 @@
print(" [消息合并] 未能安全完成本轮收集,已取消回复。")
break
if self._mark_reply_pending(target_fp, batch_ready=True) is False:
+ if self._skip_excluded_reply_session(target_fp, display_name=candidate_name):
+ processed_fp.add(target_fp)
+ non_conv_fp.add(target_fp)
+ continue
print(" [队列] 消息合并期间任务被手动删除,本轮已停止。")
break
# 合并等待这几秒里人工可能已经亲自回过了;最后一条在右边就别再插嘴
@@ -15890,6 +16066,10 @@
confirmed_unread=True,
)
if not reply_text:
+ if self._skip_excluded_reply_session(target_fp, display_name=candidate_name):
+ processed_fp.add(target_fp)
+ non_conv_fp.add(target_fp)
+ continue
print(" [回复保护] AI 未生成可靠回复,本次不发送固定套话。")
self._log_generation_gap(target_fp)
break
@@ -15911,6 +16091,10 @@
session_id=target_fp.hex(),
expected_fp=target_fp,
):
+ if self._skip_excluded_reply_session(target_fp, display_name=candidate_name):
+ processed_fp.add(target_fp)
+ non_conv_fp.add(target_fp)
+ continue
if self._reply_awaiting_review(target_fp):
self.report_progress(f"{who} 的回复草稿等待人工审核")
self._log_send_outcome(target_fp, False, reply_text)