""" 微信「联系客服解封」自动化引擎(u2 + AI 对话循环 + 可选联网搜索) 需求来源:开发文档/1、需求/修改/工作手机_微信全量控机与私域_20260529.md 真实链路(基于 5 张截图反推 · 2026-05-24): ① 「安全保护中」页(com.tencent.mm WebView 或安全中心一级菜单) → 列出可解除/不可解除项;底部「联系客服 >」入口 ② 进入「终止使用说明」H5(kf.qq.com) → 显示申请未通过/违规明细;底部绿大按钮「联系专属客服」 ③ 系统弹窗「即将打开'腾讯客服'小程序」 → 「允许」 ④ 进入「微信安全专属客服」小程序对话 → 顶部标题「微信安全专属客服」;输入框 hint「专属客服仅处理账号安全问题」 → AI 持续与客服软磨硬泡直到解封 / 拒绝 / 超时 链路设计(7 阶段状态机 · 升级版): S1 DETECT_STATUS → 检测账号封禁/限制状态(hook check_account_status) S2 ENTER_SAFETY_CENTER → 我 → 设置 → 账号与安全 → 微信安全中心 (或被动等待「安全保护中」自动弹出) S3 PARSE_BLOCK_LIST → 从 hierarchy 解析「当前可解除」/「永久无法解除」项 S4 OPEN_CS_ENTRY → 点击「联系客服」入口(卡片) S5 OPEN_CS_MINIPROGRAM → H5「联系专属客服」→ 弹窗「允许」→ 进入腾讯客服小程序 S6 CHAT_LOOP → 抓客服气泡 → AI 生成回复 → 发送 → 循环 S7 DONE/ESCALATE → success / reject / timeout / fallback 退出判定(任一命中即终止 chat loop): - 客服最近消息包含 SUCCESS_KEYWORDS(解封成功 / 已恢复 / 已解除 / 已为您处理) - 客服最近消息包含 REJECT_KEYWORDS(无法处理 / 申诉失败 / 多次违规 / 永久封禁) - 累计轮次 >= max_rounds - 客服连续 N 轮无回复 安全/合规边界: - 仅用于「自有/授权」账号;调用方需传 reason(解封原因说明)作为审计字段 - 严禁发送涉违法、骚扰类话术;AI 提示词内置约束 - 联网搜索仅 SDK 侧调用(不读手机隐私),失败容错 """ from __future__ import annotations import json import logging import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) SUCCESS_KEYWORDS = [ "解封成功", "已恢复正常", "已解除限制", "申诉通过", "审核通过", "已为您解除", "已为您恢复", "已为您处理", "恢复使用", "已通过审核", "限制已解除", "账号已恢复", ] REJECT_KEYWORDS = [ "无法为您处理", "申诉失败", "不符合解封条件", "无法恢复", "再次违规", "永久封禁", "无法解除", "拒绝处理", "申请未通过", "核实违规属实", ] WAIT_HINT_KEYWORDS = [ "请稍候", "正在为您查询", "正在核实", "稍等", ] # ===== UI 文案锚点(基于真实截图)===== ENTRY_LABELS = [ "安全保护中", # 页面标题 "联系客服", # 安全保护中页底部入口 ] H5_BUTTON_LABELS = [ "联系专属客服", # 终止使用说明 H5 绿大按钮 "联系客服", "在线咨询", ] MINIPROGRAM_ALLOW_LABELS = ["允许", "Allow"] CS_MINIPROGRAM_TITLES = [ "微信安全专属客服", "腾讯客服", "微信客服", ] CS_INPUT_HINTS = [ "专属客服仅处理账号安全问题", "说点什么", "输入消息", "请输入", ] @dataclass class TurnRecord: """单轮对话记录""" round_index: int timestamp: float customer_service_message: str = "" ai_reply: str = "" sent_ok: bool = False note: str = "" @dataclass class UnblockSession: """整次解封会话上下文""" reason: str = "" blocked_detail: str = "" phone: str = "" wxid: str = "" max_rounds: int = 20 chat_interval_sec: int = 8 use_web_search: bool = True # 解析得到的可解除/不可解除项(自动从「安全保护中」页面识别) removable_items: List[str] = field(default_factory=list) permanent_items: List[str] = field(default_factory=list) started_at: float = field(default_factory=time.time) phase: str = "INIT" turns: List[TurnRecord] = field(default_factory=list) final_status: str = "" # success / reject / timeout / fallback / error final_message: str = "" web_snippets: List[Dict[str, str]] = field(default_factory=list) screenshots: List[str] = field(default_factory=list) # 关键阶段截图路径 def to_dict(self) -> Dict[str, Any]: return { "reason": self.reason, "blocked_detail": self.blocked_detail, "phone": self.phone, "wxid": self.wxid, "phase": self.phase, "rounds": len(self.turns), "max_rounds": self.max_rounds, "final_status": self.final_status, "final_message": self.final_message, "elapsed_sec": round(time.time() - self.started_at, 1), "removable_items": self.removable_items, "permanent_items": self.permanent_items, "turns": [ { "round": t.round_index, "ts": t.timestamp, "cs": t.customer_service_message[:300], "ai": t.ai_reply[:300], "sent_ok": t.sent_ok, "note": t.note, } for t in self.turns ], "web_snippets": self.web_snippets[:5], "screenshots": self.screenshots, } SYSTEM_PROMPT = """你是一名 25 岁的普通微信用户「卡若」,账号疑似被错误封禁/限制,正在通过「微信客服」会话申诉解封。 你的目标:通过文明、有诚意、有条理的对话,争取客服恢复账号正常使用。 强制规则(违反则失败): 1. 不承认任何违规事实;如客服说违反某条款,礼貌追问「具体哪个时间/对象/内容」并请求复核 2. 不威胁、不辱骂、不发垃圾内容;不暗示送礼、不冒充企业/媒体 3. 不编造身份证号、人脸、付款记录等真实凭证;可承诺「补充材料后再次申诉」 4. 每次回复 ≤120 字、口语化、带一处具体细节(如「我今天上午 10 点收到登录受限」) 5. 客服请你「等待人工」时,回复一次「好的,我等」即可,下一轮简短「在的,请问还有什么需要补充?」 6. 客服明确拒绝/告知申诉失败,回复一次「能否告诉我下次什么时候可以再申诉?」后停止 请只输出**这一轮要发给客服的文字**,不带任何解释或多余符号。""" # ---------- AI 调用封装(兼容 AIBrain.call_ai 与降级回退) ---------- async def _ai_compose_reply( ai_brain, session: UnblockSession, customer_service_text: str, history: List[TurnRecord], ) -> str: """让 AI 根据客服最新一句话生成本轮回复(最长 120 字)""" context_lines = [ f"封禁原因(用户自述):{session.reason or '未提供'}", f"封禁详情(系统):{session.blocked_detail or '未知'}", f"账号:wxid={session.wxid or '未知'} phone={session.phone or '未提供'}", f"本次会话已进行 {len(history)} 轮", ] if session.web_snippets: snip = "; ".join(s.get("title", "")[:40] for s in session.web_snippets[:3]) context_lines.append(f"参考网友经验:{snip}") if history: last3 = history[-3:] history_text = "\n".join( f"[第{t.round_index}轮] 客服: {t.customer_service_message[:80]} | 我: {t.ai_reply[:80]}" for t in last3 ) context_lines.append("近 3 轮对话:\n" + history_text) context_lines.append(f"\n客服最新消息:{customer_service_text[:400]}") context_lines.append("请输出你这一轮要发给客服的话:") user_message = "\n".join(context_lines) if ai_brain is None or not hasattr(ai_brain, "call_ai"): return _fallback_reply(customer_service_text, len(history)) try: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_message}, ] reply = await ai_brain.call_ai(messages, max_tokens=256) if not reply: return _fallback_reply(customer_service_text, len(history)) cleaned = reply.strip().strip('"').strip("'") for prefix in ("回复:", "回答:", "我:", "回复:", "回答:"): if cleaned.startswith(prefix): cleaned = cleaned[len(prefix):].strip() return cleaned[:300] except Exception as exc: logger.warning(f"[unblock_cs] AI 生成回复失败,使用兜底话术: {exc}") return _fallback_reply(customer_service_text, len(history)) def _fallback_reply(cs_text: str, round_index: int) -> str: """无 AI 时的兜底话术(按客服关键词命中)""" txt = cs_text or "" if any(k in txt for k in ("违反", "违规", "条款", "规则")): return "您好,麻烦帮我复核一下具体是哪条规则、哪个对象,我对照一下,避免下次再误触。" if any(k in txt for k in ("身份", "实名", "认证")): return "我是本人在用,可以提供登录手机号和最近一次登录地点,怎么提交方便?" if any(k in txt for k in ("等待", "稍候", "排队", "查询")): return "好的,我等" if round_index % 2 == 0 else "在的,请问还有什么需要补充?" if any(k in txt for k in ("申诉", "材料", "证明")): return "请问申诉链接在哪里?我把材料补齐后再提交,感谢。" if round_index == 0: return "您好,我账号被限制登录了,没有进行违规操作,麻烦帮我看看是怎么回事,谢谢。" return "好的,请问还需要我提供哪些信息?我配合处理。" # ---------- UI 链路辅助 ---------- class _UI: """对 WechatSkill 的薄封装,方便单测/mock""" def __init__(self, skill): self.s = skill def launch(self): self.s.launch() def back(self): try: self.s.back() except Exception: pass def click_text(self, t: str, timeout: float = 5) -> bool: return self.s.click_text(t, timeout=timeout) def click_contains(self, t: str, timeout: float = 5) -> bool: return self.s.click_contains(t, timeout=timeout) def sleep(self, sec: float): self.s.sleep(sec) def input_text(self, t: str, clear: bool = True): self.s.input_text(t, clear=clear) def dump_text(self) -> str: """抓当前页面所有可见文字(用于读客服最新消息)""" try: xml = self.s.d.dump_hierarchy() except Exception as exc: logger.warning(f"[unblock_cs] dump_hierarchy 失败: {exc}") return "" return xml or "" def _extract_latest_cs_message(xml: str, previous: str = "") -> str: """从 hierarchy XML 里粗提取最近一条非自己的可见文本(启发式:取最长非空且不等于 previous)""" if not xml: return "" import re texts = re.findall(r'text="([^"]+)"', xml) candidates = [t.strip() for t in texts if t and t.strip() and t.strip() != previous] if not candidates: return "" candidates.sort(key=len, reverse=True) return candidates[0][:500] def _hit_keyword(text: str, keywords: List[str]) -> bool: if not text: return False return any(k in text for k in keywords) # ---------- 联网搜索(可选) ---------- def _web_search(query: str, limit: int = 3) -> List[Dict[str, str]]: """简易联网搜索:调用 DuckDuckGo HTML(无 key),失败返回 []""" try: import urllib.parse import urllib.request url = f"https://duckduckgo.com/html/?q={urllib.parse.quote(query)}" req = urllib.request.Request( url, headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}, ) with urllib.request.urlopen(req, timeout=8) as resp: html = resp.read().decode("utf-8", errors="ignore") import re items = re.findall( r']+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)', html, re.DOTALL, ) out: List[Dict[str, str]] = [] for href, title in items[:limit]: title_clean = re.sub(r"<[^>]+>", "", title).strip() out.append({"url": href, "title": title_clean[:120]}) return out except Exception as exc: logger.info(f"[unblock_cs] 联网搜索失败(忽略): {exc}") return [] # ---------- 主流程 ---------- class UnblockCustomerServiceFlow: """整合「链路 + AI 对话」的单次会话执行器""" def __init__(self, skill, ai_brain=None): self.skill = skill self.ai_brain = ai_brain self.ui = _UI(skill) # ---- S1 ---- def detect_status(self, session: UnblockSession) -> bool: """尝试通过 hook check_account_status 获取详情,失败则继续走 UI""" try: if hasattr(self.skill, "check_account_status"): status = self.skill.check_account_status() if isinstance(status, dict) and status.get("success"): detail = status.get("data") or status.get("status") or status session.blocked_detail = json.dumps(detail, ensure_ascii=False)[:500] logger.info(f"[unblock_cs] status={session.blocked_detail[:120]}") return True except Exception as exc: logger.info(f"[unblock_cs] hook 探测失败: {exc}") return False # ---- S2 + S3 ---- def _on_restriction_or_kf_page(self) -> bool: """当前页是否已是「安全保护中 / 终止使用说明 / kf.qq.com」类限制页""" xml = self.ui.dump_text() markers = ( "安全保护中", "当前可解除的保护状态", "终止使用说明", "kf.qq.com", "联系专属客服", "联系客服", ) return any(m in xml for m in markers) def _allow_miniprogram_if_needed(self) -> bool: """弹窗:即将打开「腾讯客服」小程序 → 允许""" xml = self.ui.dump_text() if "即将打开" in xml and "小程序" in xml: if self.ui.click_text("允许", timeout=3) or self.ui.click_contains("允许", timeout=3): logger.info("[unblock_cs] 已点「允许」打开腾讯客服小程序") self.ui.sleep(2.5) return True logger.warning("[unblock_cs] 小程序弹窗未找到「允许」") return False return True def _enter_from_restriction_page(self, session: UnblockSession) -> bool: """ 截图链路(解封2): 安全保护中 → 联系客服 → 终止使用说明(kf.qq.com) → 联系专属客服 → 允许打开小程序 → 微信安全专属客服 """ session.phase = "DETECT_RESTRICTION_PAGE" if not self._on_restriction_or_kf_page(): return False logger.info("[unblock_cs] 检测到限制/客服页,走「联系客服解封2」直链") self._screenshot(session, "01_safety_protection") # 解析「当前可解除/永久无法解除」分组项 try: xml0 = self.ui.dump_text() session.removable_items = self._extract_block_items(xml0, "当前可解除的保护状态") session.permanent_items = self._extract_block_items(xml0, "永久无法解除的保护状态") if session.removable_items or session.permanent_items: logger.info(f"[unblock_cs] 可解除={session.removable_items} 永久={session.permanent_items}") except Exception: pass # 1) 安全保护中页:点「联系客服」(非「验证解除」) if self.ui.click_text("联系客服", timeout=3) or self.ui.click_contains("联系客服", timeout=3): logger.info("[unblock_cs] 命中「联系客服」") self.ui.sleep(1.5) self._screenshot(session, "02_cs_entry_clicked") # 2) 终止使用说明 / kf.qq.com:点「联系专属客服」 xml = self.ui.dump_text() if "终止使用说明" in xml or "联系专属客服" in xml: self._screenshot(session, "03_termination_page") if self.ui.click_text("联系专属客服", timeout=4) or self.ui.click_contains("联系专属客服", timeout=4): logger.info("[unblock_cs] 命中「联系专属客服」") self.ui.sleep(1.2) if not self._allow_miniprogram_if_needed(): return False self._screenshot(session, "04_miniprogram_allowed") # 3) 等待进入「微信安全专属客服」会话 session.phase = "WAIT_CS_MINIPROGRAM" for _ in range(8): xml = self.ui.dump_text() if any(k in xml for k in ("微信安全专属客服", "专属客服", "说点什么", "输入消息", "专属客服仅处理账号安全问题")): logger.info("[unblock_cs] 已进入客服会话/小程序") self._screenshot(session, "05_cs_chat_ready") session.phase = "CHAT_LOOP" return True self.ui.sleep(1.0) logger.warning("[unblock_cs] 未确认进入客服小程序,仍尝试 chat_loop") session.phase = "CHAT_LOOP" return True # ---- 辅助:解析阻断项分组 ---- def _extract_block_items(self, xml: str, section_title: str) -> List[str]: if not xml or section_title not in xml: return [] import re try: idx = xml.find(section_title) chunk = xml[idx: idx + 4000] texts = re.findall(r'text="([^"]+)"', chunk) items: List[str] = [] for t in texts: t = t.strip() if 3 < len(t) < 30 and not any(k in t for k in (section_title, "保护原因", "解除", "状态")): items.append(t) seen: set = set() uniq: List[str] = [] for it in items: if it not in seen: seen.add(it) uniq.append(it) return uniq[:6] except Exception: return [] # ---- 辅助:截图保存 ---- def _screenshot(self, session: UnblockSession, tag: str) -> None: try: ts = int(time.time()) path = f"/sdcard/unblock_{tag}_{ts}.png" self.skill.d.screenshot(path) session.screenshots.append(path) except Exception: pass def enter_customer_service(self, session: UnblockSession) -> bool: """进入客服:优先限制页直链(解封2),失败再走 我→设置→安全中心""" self.ui.launch() self.ui.sleep(1.2) for _ in range(2): self.ui.back() self.ui.sleep(0.3) if self._enter_from_restriction_page(session): return True session.phase = "ENTER_SAFETY_CENTER" if not self.ui.click_text("我"): logger.warning("[unblock_cs] 未找到「我」tab,尝试限制页二次检测") return self._enter_from_restriction_page(session) self.ui.sleep(0.6) if not (self.ui.click_text("设置") or self.ui.click_contains("设置")): return False self.ui.sleep(0.6) if not (self.ui.click_text("账号与安全") or self.ui.click_text("帐号与安全")): return False self.ui.sleep(0.6) if not (self.ui.click_text("微信安全中心") or self.ui.click_contains("安全中心")): return False self.ui.sleep(0.8) session.phase = "FIND_CUSTOMER_SERVICE" candidates = [ "联系专属客服", "联系客服", "在线咨询", "微信客服", "申诉客服", "联系微信团队", "客户服务", "客服", "更多帮助", ] for label in candidates: if self.ui.click_text(label, timeout=2) or self.ui.click_contains(label, timeout=2): logger.info(f"[unblock_cs] 命中入口: {label}") self.ui.sleep(2.0) self._allow_miniprogram_if_needed() session.phase = "CHAT_LOOP" return True for outer in ("解封账号", "申诉解封", "申诉", "验证解除"): if self.ui.click_text(outer, timeout=2) or self.ui.click_contains(outer, timeout=2): self.ui.sleep(1.0) for inner in ("联系专属客服", "联系客服", "在线咨询", "微信客服", "客服"): if self.ui.click_text(inner, timeout=2) or self.ui.click_contains(inner, timeout=2): logger.info(f"[unblock_cs] {outer}→{inner}") self.ui.sleep(2.0) self._allow_miniprogram_if_needed() session.phase = "CHAT_LOOP" return True self.ui.back() self.ui.sleep(0.4) return False # ---- S4 ---- async def chat_loop(self, session: UnblockSession) -> None: """与客服小程序循环对话,最多 max_rounds 轮""" # 预联网搜索一次(拿到通用申诉建议作为 system context) if session.use_web_search: session.web_snippets = _web_search( f"微信账号 {session.reason or '被限制'} 联系客服 解封 经验", limit=3, ) previous_cs_text = "" empty_streak = 0 for r in range(session.max_rounds): self.ui.sleep(session.chat_interval_sec) xml = self.ui.dump_text() cs_text = _extract_latest_cs_message(xml, previous=previous_cs_text) turn = TurnRecord(round_index=r + 1, timestamp=time.time(), customer_service_message=cs_text) # 退出条件检测 if _hit_keyword(cs_text, SUCCESS_KEYWORDS): session.final_status = "success" session.final_message = cs_text[:200] turn.note = "命中成功关键词" session.turns.append(turn) return if _hit_keyword(cs_text, REJECT_KEYWORDS): session.final_status = "reject" session.final_message = cs_text[:200] turn.note = "命中拒绝关键词" session.turns.append(turn) return if not cs_text or cs_text == previous_cs_text: empty_streak += 1 turn.note = "客服未回复 / 文本无变化" # 连续 3 轮无新消息 → 主动追问一次 if empty_streak >= 3: nudge = "您好,请问还在吗?我等了一会,刚才那个问题方便看下吗?" turn.ai_reply = nudge turn.sent_ok = self._send(nudge) session.turns.append(turn) if empty_streak >= 6: session.final_status = "timeout" session.final_message = "客服长时间无回复" return continue session.turns.append(turn) continue empty_streak = 0 previous_cs_text = cs_text reply = await _ai_compose_reply(self.ai_brain, session, cs_text, session.turns) turn.ai_reply = reply turn.sent_ok = self._send(reply) session.turns.append(turn) session.final_status = "timeout" session.final_message = f"达到最大轮次 {session.max_rounds}" def _send(self, text: str) -> bool: """在客服输入框发送一条消息(兼容客服小程序与原生会话)""" try: # 优先找输入框:常见 resourceId 含 chat_edit / et_text,文本提示「说点什么」等 candidates_id = [ "com.tencent.mm:id/chat_edit", "com.tencent.mm:id/et_message", "com.tencent.mm:id/bk0", # 旧版 ] clicked = False for rid in candidates_id: try: if self.skill.d(resourceId=rid).exists: self.skill.d(resourceId=rid).click() clicked = True break except Exception: pass if not clicked: for hint in ("说点什么", "输入消息", "请输入", "请输入您要咨询的问题"): if self.skill.click_contains(hint, timeout=2): clicked = True break if not clicked: logger.warning("[unblock_cs] 未找到输入框") return False self.skill.input_text(text, clear=True) self.skill.sleep(0.4) if not (self.skill.click_text("发送", timeout=2) or self.skill.click_contains("发送", timeout=2)): try: self.skill.d.press("enter") except Exception: return False self.skill.sleep(0.4) return True except Exception as exc: logger.warning(f"[unblock_cs] 发送失败: {exc}") return False # ---- 总入口 ---- async def run(self, session: UnblockSession) -> Dict[str, Any]: try: # 解封前先确保微信已登录(读 sdk/config/wechat_login.yaml) try: from .wechat_auto_login import WeChatAutoLogin login = WeChatAutoLogin(self.skill).ensure_logged_in("", "") session.phase = "ENSURE_LOGIN" if not login.get("success"): session.final_status = "error" session.final_message = login.get("error") or "微信未登录且自动登录失败" return { "success": False, "error": session.final_message, "login": login, "session": session.to_dict(), } except Exception as login_exc: logger.warning(f"[unblock_cs] ensure_login 跳过: {login_exc}") self.detect_status(session) if not self.enter_customer_service(session): session.phase = "FALLBACK" session.final_status = "fallback" session.final_message = "未能进入客服入口(路径变化)" return {"success": False, "error": session.final_message, "session": session.to_dict()} await self.chat_loop(session) ok = session.final_status == "success" return { "success": ok, "status": session.final_status, "message": session.final_message, "session": session.to_dict(), } except Exception as exc: logger.exception("[unblock_cs] 异常") session.final_status = "error" session.final_message = str(exc) return {"success": False, "error": str(exc), "session": session.to_dict()}