653 lines
27 KiB
Python
653 lines
27 KiB
Python
"""
|
||
微信「联系客服解封」自动化引擎(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'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
|
||
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()}
|