343 lines
18 KiB
Diff
343 lines
18 KiB
Diff
--- C:\kefu\wechat_rpa\wechat_bot.py.before
|
||
+++ C:\kefu\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:
|
||
@@ -9412,6 +9541,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(
|
||
@@ -9858,6 +9989,8 @@
|
||
每一轮都会重新打开它、调一次模型、再卡在发送保护上,真正的客户被挤到
|
||
后面——用户看到的就是“机器人不回消息了”。
|
||
"""
|
||
+ if self._skip_excluded_reply_session(fp, inspect_open=True):
|
||
+ return False
|
||
key = bytes(fp or b"").hex()
|
||
if not key:
|
||
return True
|
||
@@ -11730,6 +11863,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(" [页面校验] 会话列表空间不足,已禁止窗口外点击。")
|
||
@@ -11978,6 +12114,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
|
||
@@ -12105,6 +12245,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("任务已被手动删除")
|
||
@@ -12537,6 +12680,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, "")
|
||
@@ -13045,6 +13190,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
|
||
# 自动点击的身份必须同时包含头像和名称;空/旧式头像键不能作
|
||
@@ -13188,6 +13336,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
|
||
@@ -13952,6 +14103,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
|
||
@@ -14040,6 +14193,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":
|
||
# 打不开是这一行本身的性质,不是时机不巧。留在队列里只会让
|
||
# 每一轮都为它做一次全列表分页扫描,真正的客户排在它后面
|
||
@@ -14440,6 +14595,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 = []
|
||
@@ -14700,6 +14857,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 {}
|
||
@@ -15664,6 +15824,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
|
||
@@ -15709,6 +15873,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())
|
||
@@ -15775,6 +15943,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="会话已打开,正在读取新消息")
|
||
@@ -15818,6 +15990,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
|
||
# 合并等待这几秒里人工可能已经亲自回过了;最后一条在右边就别再插嘴
|
||
@@ -15832,6 +16008,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
|
||
@@ -15853,6 +16033,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)
|