Files
workphone-sdk/sdk/agent/skills/wechat/unblock_customer_service.py

653 lines
27 KiB
Python
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.

"""
微信「联系客服解封」自动化引擎u2 + AI 对话循环 + 可选联网搜索)
需求来源:开发文档/1、需求/修改/工作手机_微信全量控机与私域_20260529.md
真实链路(基于 5 张截图反推 · 2026-05-24
① 「安全保护中」页com.tencent.mm WebView 或安全中心一级菜单)
→ 列出可解除/不可解除项;底部「联系客服 >」入口
② 进入「终止使用说明」H5kf.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()}