Commit 8ce4471111fec7852a43f3ffab85bf133ee689cc

Authored by yibo
1 parent c4f8424b

feat: 语音全合成接入 wechat_send.sh + android_mcp.py MCP + skill

三种消息在使用层齐了(文字/图片/语音全走内部 API 全合成):

- wechat_send.sh voice: 弃用旧 SEND_VOICE_INTERNAL(hot-swap 相关),改走 SEND_VOICE_CGI
  全合成。SILK 须 WeChat 进程可读——动态取 WeChat uid(stat /data/user/0/com.tencent.mm),
  stage 进 /data/data/com.tencent.mm/files/ + chown + restorecon(/sdcard EACCES、
  root cp 进沙盒需正确 owner+ctx)。
- wechat_hook_send.py: 新增 send_voice_cgi_via_hook(同上 staging + 广播 SEND_VOICE_CGI)。
- android_mcp.py: send_wechat_voice 改调 send_voice_cgi_via_hook,docstring 更新为全合成。
- skill_wechat_send/SKILL.md: 语音段改写为 SEND_VOICE_CGI 全合成用法。

真机验证:wechat_send.sh voice + MCP send_voice_cgi_via_hook 均 status=sent +
hook svrMsgId>0(uploadvoice cgi 服务器接受)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
phoneuse/android_mcp.py
... ... @@ -889,17 +889,22 @@ def _build_server():
889 889 @mcp.tool()
890 890 def send_wechat_voice(to: str, silk_path: str, duration_ms: int = 3000) -> str:
891 891 """
892   - Send a voice message to a WeChat contact via the internal hook API (no UI simulation).
  892 + Send a FULLY-SYNTHETIC voice message to a WeChat contact (no recording, no hot-swap).
893 893  
894   - Uses WeChat's w11.r0 factory (MsgType 34). The hook injects the correct
895   - voice file ID into the DB insert so the message is stored with the right
896   - SILK file reference and is immediately playable.
  894 + Fires SEND_VOICE_CGI → the hook chunks the SILK, builds WeChat's own
  895 + /cgi-bin/micromsg-bin/uploadvoice request (audio inline), dispatches it via the
  896 + NetSceneQueue, and inserts a local sent bubble. Result: the recipient gets a
  897 + playable voice AND the sender sees a sent bubble. Verified end-to-end on WeChat
  898 + 8.0.74 (recipient + Mac both received). Delivery is async; confirm via UI if needed.
  899 +
  900 + The SILK is auto-staged into WeChat's data dir (its uid + SELinux context) so the
  901 + WeChat process can read it — a device /sdcard path alone would EACCES.
897 902  
898 903 Args:
899   - to: Contact wxid (e.g. 'filehelper'). IMPORTANT: test only with 'filehelper'.
900   - silk_path: Absolute device path to a SILK V3 file (Tencent variant,
901   - starts with \\x02#!SILK_V3). Generate with silkv3-encoder
902   - or convert from PCM via silk-v3-encoder.
  904 + to: Contact wxid as in rcontact.username (e.g. 'filehelper', 'wxid_xxx',
  905 + 'yibooo'). Test with 'filehelper' first to avoid account risk.
  906 + silk_path: Absolute device path to a SILK V3 file (Tencent variant, starts
  907 + with \\x02#!SILK_V3), e.g. edge-tts → ffmpeg 16k mono → silk-wasm.
903 908 duration_ms: Voice duration in milliseconds shown in WeChat UI.
904 909 """
905 910 import sys
... ... @@ -907,8 +912,8 @@ def _build_server():
907 912 if _base not in sys.path:
908 913 sys.path.insert(0, _base)
909 914 try:
910   - from wechat_hook_send import send_voice_via_hook
911   - result = send_voice_via_hook(wxid=to, silk_path=silk_path, duration_ms=duration_ms)
  915 + from wechat_hook_send import send_voice_cgi_via_hook
  916 + result = send_voice_cgi_via_hook(wxid=to, silk_path=silk_path, duration_ms=duration_ms)
912 917 return json.dumps(result, ensure_ascii=False)
913 918 except Exception as e:
914 919 return json.dumps({"status": "error", "error": str(e)})
... ...
phoneuse/wechat_hook_send.py
... ... @@ -24,6 +24,7 @@ from ui_control_tools import _run, _adb_prefix, _ON_DEVICE
24 24  
25 25 SEND_ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL"
26 26 SEND_IMAGE_ACTION = "com.hermes.wechat.SEND_IMAGE_INTERNAL"
  27 +SEND_VOICE_CGI_ACTION = "com.hermes.wechat.SEND_VOICE_CGI"
27 28 _WECHAT_PKG = "com.tencent.mm"
28 29 _WECHAT_MAIN = "com.tencent.mm/.ui.LauncherUI"
29 30  
... ... @@ -249,6 +250,69 @@ def send_image_via_hook(wxid: str, image_path: str) -&gt; dict[str, Any]:
249 250 }
250 251  
251 252  
  253 +def send_voice_cgi_via_hook(wxid: str, silk_path: str, duration_ms: int = 3000) -> dict[str, Any]:
  254 + """Send a FULLY-SYNTHETIC voice to *wxid* via the internal uploadvoice cgi.
  255 +
  256 + No recording, no hot-swap. The hook chunks the SILK, builds WeChat's own
  257 + /cgi-bin/micromsg-bin/uploadvoice request (audio inline), dispatches it via the
  258 + NetSceneQueue (modelbase.i.l() + subscribe), and inserts a local f9 bubble marked
  259 + sent. Result: the recipient gets a playable voice AND the sender sees a sent bubble.
  260 + Verified end-to-end on WeChat 8.0.74 (recipient + Mac both received; both sides visible).
  261 +
  262 + The hook reads *silk_path* AS the WeChat process, so the SILK is first staged into
  263 + WeChat's own data dir (its uid + app_data_file SELinux context) — /sdcard gives
  264 + EACCES and a plain root cp into the sandbox needs chown + restorecon.
  265 +
  266 + Args:
  267 + wxid: talker id as in rcontact.username (e.g. 'filehelper', 'wxid_xxx', 'yibooo').
  268 + Test with 'filehelper' first to avoid account risk.
  269 + silk_path: device path to a SILK V3 file (Tencent variant, \\x02#!SILK_V3),
  270 + e.g. from edge-tts → ffmpeg 16k mono → silk-wasm.
  271 + duration_ms: voice duration in milliseconds shown in WeChat UI.
  272 + """
  273 + if not wxid or not silk_path:
  274 + return {"status": "error", "error": "wxid and silk_path required"}
  275 + if not _wechat_running():
  276 + if not _start_wechat():
  277 + return {"status": "error", "error": "WeChat not running and failed to start"}
  278 + time.sleep(3)
  279 +
  280 + # Resolve WeChat's uid (changes across reinstalls) and stage the SILK into its data
  281 + # dir with the matching owner + SELinux context so the WeChat process can read it.
  282 + uid_res = _root_sh("stat -c %U /data/user/0/com.tencent.mm")
  283 + wx_uid = (uid_res.stdout or "").strip()
  284 + if not wx_uid:
  285 + return {"status": "error", "error": f"could not resolve WeChat uid: {uid_res.stderr or '(no output)'}"}
  286 + stage_path = "/data/data/com.tencent.mm/files/hook_voice_cgi.silk"
  287 + stage = _root_sh(
  288 + f'cp "{silk_path}" "{stage_path}" && '
  289 + f'chown {wx_uid}:{wx_uid} "{stage_path}" && '
  290 + f'chmod 600 "{stage_path}"; restorecon "{stage_path}" 2>/dev/null; true'
  291 + )
  292 + if stage.returncode != 0:
  293 + return {"status": "error", "error": f"failed to stage SILK: {stage.stderr or stage.stdout}"}
  294 +
  295 + cmd = (
  296 + f"am broadcast -a {SEND_VOICE_CGI_ACTION} "
  297 + f"--es to {wxid} --es src_path {stage_path} --ei duration {duration_ms}"
  298 + )
  299 + try:
  300 + r = _root_sh(cmd)
  301 + except subprocess.TimeoutExpired:
  302 + return {"status": "error", "error": "voice cgi broadcast timed out"}
  303 + stdout = (r.stdout or "").strip()
  304 + stderr = (r.stderr or "").strip()
  305 + if r.returncode == 0 and ("result=" in stdout or "Broadcast completed" in stdout):
  306 + return {
  307 + "status": "sent",
  308 + "to": wxid,
  309 + "method": "uploadvoice_cgi",
  310 + "verified": False, # cgi is async; confirm via UI / recipient
  311 + "detail": stdout[:200],
  312 + }
  313 + return {"status": "error", "error": f"broadcast rc={r.returncode}: {stderr or stdout or '(no output)'}"}
  314 +
  315 +
252 316 def send_voice_via_hook(wxid: str, silk_path: str, duration_ms: int = 3000) -> dict[str, Any]:
253 317 """Send a voice message to *wxid* via the wechathook internal API.
254 318  
... ...
xhs_lsposed_a16_fix/backups/wechat_send_20260707_voice-cgi.sh 0 → 100755
  1 +#!/bin/bash
  2 +# wechat_send.sh — unified WeChat send CLI via the internal hook
  3 +#
  4 +# Usage:
  5 +# ./wechat_send.sh text <wxid> <message>
  6 +# ./wechat_send.sh image <wxid> <device_image_path>
  7 +# ./wechat_send.sh voice <wxid> <local_silk_path> [duration_ms]
  8 +#
  9 +# All send operations go through the wechathook LSPosed module's internal API,
  10 +# which calls WeChat's own w11.r0/w11.s1 factory — no UI simulation.
  11 +# <wxid>: talker id as stored in WeChat DB (e.g. 'filehelper', 'wxid_xxx').
  12 +# IMPORTANT: test-only with 'filehelper' to avoid account risk.
  13 +#
  14 +# Voice notes (SEND_VOICE_CGI — fully synthetic, no recording/hot-swap):
  15 +# <local_silk_path> — local SILK V3 file (Tencent prefix \x02#!SILK_V3), e.g. from
  16 +# edge-tts → ffmpeg 16k mono → silk-wasm.
  17 +# [duration_ms] — playback duration in ms (default: 3000).
  18 +# Stages the SILK into WeChat's own data dir (uid + app_data_file ctx) then broadcasts
  19 +# the internal uploadvoice cgi: uploads audio inline + delivers to the recipient AND
  20 +# inserts a sent bubble on the sender side. Verified end-to-end + both-sides visible.
  21 +#
  22 +# Examples:
  23 +# ./wechat_send.sh text filehelper "hello world"
  24 +# ./wechat_send.sh image filehelper /tmp/screenshot.png
  25 +# ./wechat_send.sh voice filehelper /tmp/tts_out.silk 4617
  26 +
  27 +set -e
  28 +
  29 +ADB="${ADB:-adb}"
  30 +WECHAT_CACHE="/data/data/com.tencent.mm/cache/hook_voice_src.amr"
  31 +
  32 +usage() {
  33 + echo "Usage: $0 text <wxid> <message>"
  34 + echo " $0 image <wxid> <device_path>"
  35 + echo " $0 voice <wxid> <local_silk_path> [duration_ms]"
  36 + exit 1
  37 +}
  38 +
  39 +[ $# -lt 3 ] && usage
  40 +
  41 +CMD="$1"; WXID="$2"; shift 2
  42 +
  43 +case "$CMD" in
  44 +text)
  45 + PAYLOAD="$*"
  46 + B64_WXID=$(printf '%s' "$WXID" | base64 | tr -d '\n')
  47 + B64_TEXT=$(printf '%s' "$PAYLOAD" | base64 | tr -d '\n')
  48 + INNER=$(printf 'TO=$(printf "%%s" "%s" | base64 -d); TEXT=$(printf "%%s" "%s" | base64 -d); am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to "$TO" --es text "$TEXT"' \
  49 + "$B64_WXID" "$B64_TEXT")
  50 + B64_INNER=$(printf '%s' "$INNER" | base64 | tr -d '\n')
  51 + echo "Sending text to $WXID..."
  52 + $ADB shell "su -c 'echo $B64_INNER | base64 -d | sh'"
  53 + ;;
  54 +
  55 +image)
  56 + # Internal API: WeChat's own b80.u.oj (insert+prebuild) + v70.w.oj (CDN upload).
  57 + # No UI, no contact picker — fire-and-forget to <wxid>. Verified full-res delivery.
  58 + IMAGE_PATH="$1"
  59 + [ -z "$IMAGE_PATH" ] && usage
  60 + EXT="${IMAGE_PATH##*.}"
  61 + # Stage into /sdcard so the WeChat process (runs the hook) can read it, regardless
  62 + # of whether the source path is root-only. If already under /sdcard this is a no-op copy.
  63 + SHARE="/sdcard/wechat_send_tmp.$EXT"
  64 + echo "Staging image to $SHARE..."
  65 + $ADB shell "su -c 'cp \"$IMAGE_PATH\" \"$SHARE\" && chmod 644 \"$SHARE\"'"
  66 + echo "Sending image to $WXID via internal API..."
  67 + $ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL --es to \"$WXID\" --es path \"$SHARE\"'"
  68 + echo "Sent. (Verify via WeChat UI screenshot; full image uploads async via CDN.)"
  69 + ;;
  70 +
  71 +voice)
  72 + # Fully-synthetic voice via the internal uploadvoice cgi (no recording, no hot-swap).
  73 + # Uploads the SILK inline + delivers to the recipient AND inserts a sent bubble on the
  74 + # sender side. The hook reads src_path AS the WeChat process, so the SILK must live in
  75 + # WeChat's own data dir with its uid + app_data_file SELinux context — /sdcard gives
  76 + # EACCES and a root cp into the sandbox needs chown+restorecon to the right owner.
  77 + SILK_PATH="$1"
  78 + DURATION="${2:-3000}"
  79 + [ -z "$SILK_PATH" ] && usage
  80 + [ ! -f "$SILK_PATH" ] && { echo "File not found: $SILK_PATH"; exit 1; }
  81 +
  82 + WX_UID=$($ADB shell "su -c 'stat -c %U /data/user/0/com.tencent.mm'" | tr -d '\r')
  83 + [ -z "$WX_UID" ] && { echo "Could not resolve WeChat uid"; exit 1; }
  84 + STAGE="/data/data/com.tencent.mm/files/hook_voice_cgi.silk"
  85 +
  86 + echo "Staging SILK into WeChat data dir (uid=$WX_UID)..."
  87 + $ADB push "$SILK_PATH" /sdcard/hook_voice_tmp.silk >/dev/null
  88 + $ADB shell "su -c 'cp /sdcard/hook_voice_tmp.silk $STAGE'"
  89 + $ADB shell "su -c 'chown $WX_UID:$WX_UID $STAGE'"
  90 + $ADB shell "su -c 'chmod 600 $STAGE'"
  91 + $ADB shell "su -c 'restorecon $STAGE'" 2>/dev/null
  92 + $ADB shell "su -c 'rm -f /sdcard/hook_voice_tmp.silk'"
  93 +
  94 + echo "Sending synthetic voice to $WXID (duration=${DURATION}ms)..."
  95 + $ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_CGI --es to $WXID --es src_path $STAGE --ei duration $DURATION'"
  96 + echo "Sent. (Recipient gets a playable voice; sender sees a sent bubble. Verify via WeChat UI.)"
  97 + ;;
  98 +
  99 +*)
  100 + echo "Unknown command: $CMD"
  101 + usage
  102 + ;;
  103 +esac
... ...
xhs_lsposed_a16_fix/skill_wechat_send/SKILL.md
... ... @@ -48,9 +48,17 @@ adb shell su -c &#39;am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL --es to y
48 48 `su` 能读到的路径(如 `/sdcard/...`)。发前若图片在 Mac 上,先 `adb push` 到设备。
49 49 2026-07-07 真机验证:filehelper + 真人(火星人)均全图收到。详见技术文档 §3。
50 50  
51   -### ✅ 语音(TTS→SILK,半自动 hot-swap)
52   -纯合成卡「发送中」,用 hot-swap:让微信真录一段 + 偷换 SILK 字节。
53   -端到端脚本 `../tts_voice_swap.sh`(需先打开目标聊天并处语音模式)。详见技术文档 §2。
  51 +### ✅ 语音(TTS→SILK,全合成,无录音/无 hot-swap)
  52 +```bash
  53 +adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_CGI --es to yibooo --es src_path <设备上SILK> --ei duration 3000'
  54 +```
  55 +或用封装脚本(自动把 SILK stage 进 WeChat 数据目录):`./wechat_send.sh voice yibooo /path/to.silk 3000`
  56 +
  57 +走微信内部 `uploadvoice` cgi:音频内联上传 + 投递给接收方,同时插发送方本地已发送气泡。
  58 +真机验证:接收方(火星人/Mac)收到能播 + 发送方本地正常显示。SILK 来源 = edge-tts→ffmpeg
  59 +16k mono→silk-wasm。**SILK 须 WeChat 进程可读**——封装脚本已处理(stage 进
  60 +`/data/data/com.tencent.mm/files/` + chown WeChat uid + restorecon);直接 `/sdcard` 会 EACCES。
  61 +旧的半自动 hot-swap(`../tts_voice_swap.sh` + `ARM_VOICE_SWAP`)已被全合成取代,可弃用。
54 62  
55 63 ## 不要做
56 64 - **不要用 `dk5.s5.Ni` 发文字**——那是发名片的(会得到失败的空名片)。
... ...
xhs_lsposed_a16_fix/wechat_send.sh
... ... @@ -11,10 +11,13 @@
11 11 # <wxid>: talker id as stored in WeChat DB (e.g. 'filehelper', 'wxid_xxx').
12 12 # IMPORTANT: test-only with 'filehelper' to avoid account risk.
13 13 #
14   -# Voice notes:
15   -# <local_silk_path> — local SILK V3 file (Tencent prefix \x02#!SILK_V3).
  14 +# Voice notes (SEND_VOICE_CGI — fully synthetic, no recording/hot-swap):
  15 +# <local_silk_path> — local SILK V3 file (Tencent prefix \x02#!SILK_V3), e.g. from
  16 +# edge-tts → ffmpeg 16k mono → silk-wasm.
16 17 # [duration_ms] — playback duration in ms (default: 3000).
17   -# The script pushes the SILK to WeChat's cache dir and fires the broadcast.
  18 +# Stages the SILK into WeChat's own data dir (uid + app_data_file ctx) then broadcasts
  19 +# the internal uploadvoice cgi: uploads audio inline + delivers to the recipient AND
  20 +# inserts a sent bubble on the sender side. Verified end-to-end + both-sides visible.
18 21 #
19 22 # Examples:
20 23 # ./wechat_send.sh text filehelper "hello world"
... ... @@ -66,17 +69,31 @@ image)
66 69 ;;
67 70  
68 71 voice)
  72 + # Fully-synthetic voice via the internal uploadvoice cgi (no recording, no hot-swap).
  73 + # Uploads the SILK inline + delivers to the recipient AND inserts a sent bubble on the
  74 + # sender side. The hook reads src_path AS the WeChat process, so the SILK must live in
  75 + # WeChat's own data dir with its uid + app_data_file SELinux context — /sdcard gives
  76 + # EACCES and a root cp into the sandbox needs chown+restorecon to the right owner.
69 77 SILK_PATH="$1"
70 78 DURATION="${2:-3000}"
71 79 [ -z "$SILK_PATH" ] && usage
72 80 [ ! -f "$SILK_PATH" ] && { echo "File not found: $SILK_PATH"; exit 1; }
73 81  
74   - echo "Pushing SILK to WeChat cache..."
75   - $ADB push "$SILK_PATH" /sdcard/hook_voice_tmp.amr
76   - $ADB shell "su -c 'cp /sdcard/hook_voice_tmp.amr $WECHAT_CACHE && chmod 644 $WECHAT_CACHE && chown u0_a330:u0_a330 $WECHAT_CACHE && rm /sdcard/hook_voice_tmp.amr'"
  82 + WX_UID=$($ADB shell "su -c 'stat -c %U /data/user/0/com.tencent.mm'" | tr -d '\r')
  83 + [ -z "$WX_UID" ] && { echo "Could not resolve WeChat uid"; exit 1; }
  84 + STAGE="/data/data/com.tencent.mm/files/hook_voice_cgi.silk"
77 85  
78   - echo "Sending voice to $WXID (duration=${DURATION}ms)..."
79   - $ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL --es to $WXID --es src_path $WECHAT_CACHE --ei duration $DURATION'"
  86 + echo "Staging SILK into WeChat data dir (uid=$WX_UID)..."
  87 + $ADB push "$SILK_PATH" /sdcard/hook_voice_tmp.silk >/dev/null
  88 + $ADB shell "su -c 'cp /sdcard/hook_voice_tmp.silk $STAGE'"
  89 + $ADB shell "su -c 'chown $WX_UID:$WX_UID $STAGE'"
  90 + $ADB shell "su -c 'chmod 600 $STAGE'"
  91 + $ADB shell "su -c 'restorecon $STAGE'" 2>/dev/null
  92 + $ADB shell "su -c 'rm -f /sdcard/hook_voice_tmp.silk'"
  93 +
  94 + echo "Sending synthetic voice to $WXID (duration=${DURATION}ms)..."
  95 + $ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_CGI --es to $WXID --es src_path $STAGE --ei duration $DURATION'"
  96 + echo "Sent. (Recipient gets a playable voice; sender sees a sent bubble. Verify via WeChat UI.)"
80 97 ;;
81 98  
82 99 *)
... ...