--- 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)