feat: publish workphone SDK deployment and API docs

This commit is contained in:
Manus AI
2026-07-14 18:10:52 +08:00
commit 021d633cc1
534 changed files with 122391 additions and 0 deletions

View File

@@ -0,0 +1,652 @@
"""
微信「联系客服解封」自动化引擎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()}