Commit c3a6427fa516628bcd0bee40aab53ea8c003872f
1 parent
db0ef111
feat(phoneuse): send_wechat_voice_tts MCP 工具 + Hermes 语音发送指南
wechat_hook_send.py: - 新增 _tts_to_silk(): edge-tts → ffmpeg → silk-wasm 完整 TTS 管线 支持 on-device (Termux) 和 off-device (Mac adb push) 两种运行模式 - 新增 send_voice_tts_via_hook(): 文字 → 语音一步到位, 自动处理 TTS/SILK 编码+WeChat cache 暂存+broadcast 发送 android_mcp.py: - 新增 send_wechat_voice_tts MCP 工具(Hermes 直接传文字即可发语音) docstring 明确写明:不要自己实现 SILK,调这个工具就行 docs/wechat-voice-send-guide.md (新): - Hermes 调用说明:正确姿势 / 声音选项 / 依赖安装 / 常见错误 / 技术背景 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
3 changed files
with
628 additions
and
18 deletions
phoneuse/android_mcp.py
| ... | ... | @@ -797,34 +797,150 @@ def _build_server(): |
| 797 | 797 | # ── tool: send_wechat_message ───────────────────────────────────────────── |
| 798 | 798 | |
| 799 | 799 | @mcp.tool() |
| 800 | - def send_wechat_message(to: str, content: str, timeout: int = 30) -> str: | |
| 800 | + def send_wechat_message(to: str, content: str, method: str = "auto", | |
| 801 | + timeout: int = 30) -> str: | |
| 801 | 802 | """ |
| 802 | - Send a WeChat text message to a contact via real on-screen UI automation. | |
| 803 | + Send a WeChat text message. Tries the fast internal hook first, falls | |
| 804 | + back to UI automation if the hook is unavailable. | |
| 803 | 805 | |
| 804 | - The chat is opened by driving WeChat's own search box (the old | |
| 805 | - `am start --es username` deep link is silently ignored by WeChat 8.x and | |
| 806 | - never opens the chat). Text is committed through ADBKeyboard, because | |
| 807 | - `input text` cannot type CJK and composing IMEs mangle Latin text too. | |
| 806 | + Hook path (method='hook' or 'auto'): dispatches via the wechathook | |
| 807 | + LSPosed module — no UI disruption, < 1 s, works for any wxid/alias. | |
| 808 | + Requires wechathook module loaded and WeChat running. | |
| 808 | 809 | |
| 809 | - The returned status is the TRUTH from WeChat's database: "sent" is only | |
| 810 | - returned when a `message` row with `isSend = 1` matching this exact text | |
| 811 | - appears after the send. If navigation or sending fails, an error/failed | |
| 812 | - status is returned — it never reports a fake "sent". | |
| 810 | + UI path (method='ui' or fallback): drives WeChat's search box with | |
| 811 | + ADBKeyboard; verifies delivery via WeChat's message DB (isSend=1). | |
| 812 | + Requires ADBKeyboard APK installed on device. | |
| 813 | 813 | |
| 814 | 814 | Args: |
| 815 | - to: Contact nickname / remark / wxid, or a 'filehelper' alias for the | |
| 816 | - File Transfer Helper self-chat (filehelper / 文件传输助手). | |
| 817 | - content: Message text to send (CJK supported). | |
| 818 | - timeout: Seconds to wait for the chat window to open (default 30). | |
| 815 | + to: Contact wxid / nickname / remark, or 'filehelper' / '文件传输助手' | |
| 816 | + for File Transfer Helper. Nicknames are resolved via WeChat DB. | |
| 817 | + content: Message text (CJK supported). | |
| 818 | + method: 'auto' (hook→UI fallback), 'hook' (hook only), 'ui' (UI only). | |
| 819 | + timeout: Seconds to wait for chat window in UI mode (default 30). | |
| 819 | 820 | """ |
| 821 | + import sys | |
| 822 | + _base = os.path.dirname(os.path.abspath(__file__)) | |
| 823 | + if _base not in sys.path: | |
| 824 | + sys.path.insert(0, _base) | |
| 825 | + | |
| 820 | 826 | try: |
| 821 | - import sys | |
| 822 | - _base = os.path.dirname(os.path.abspath(__file__)) | |
| 823 | - if _base not in sys.path: | |
| 824 | - sys.path.insert(0, _base) | |
| 827 | + # ── Resolve nickname → wxid (needed for hook path) ─────────────── | |
| 828 | + wxid = to.strip() | |
| 829 | + if method != "ui": | |
| 830 | + from wechat_send import _resolve_contact | |
| 831 | + resolved = _resolve_contact(to) | |
| 832 | + if resolved.get("resolved"): | |
| 833 | + wxid = resolved["talker"] or wxid | |
| 834 | + | |
| 835 | + # ── Hook path ──────────────────────────────────────────────────── | |
| 836 | + if method in ("hook", "auto"): | |
| 837 | + from wechat_hook_send import send_text_via_hook | |
| 838 | + result = send_text_via_hook(wxid, content) | |
| 839 | + if result.get("status") == "sent": | |
| 840 | + return json.dumps(result, ensure_ascii=False) | |
| 841 | + if method == "hook": | |
| 842 | + return json.dumps(result, ensure_ascii=False) | |
| 843 | + # auto: fall through to UI | |
| 844 | + | |
| 845 | + # ── UI path ────────────────────────────────────────────────────── | |
| 825 | 846 | from wechat_send import send_wechat_message_ui |
| 826 | 847 | result = send_wechat_message_ui(to=to, content=content, timeout=timeout) |
| 827 | 848 | return json.dumps(result, ensure_ascii=False) |
| 849 | + | |
| 850 | + except Exception as e: | |
| 851 | + return json.dumps({"status": "error", "error": str(e)}) | |
| 852 | + | |
| 853 | + # ── tool: send_wechat_image ─────────────────────────────────────────────── | |
| 854 | + | |
| 855 | + @mcp.tool() | |
| 856 | + def send_wechat_image(to: str, image_path: str) -> str: | |
| 857 | + """ | |
| 858 | + Send an image to a WeChat contact via the share intent + UI tap flow. | |
| 859 | + | |
| 860 | + Copies the image to /sdcard, fires android.intent.action.SEND into WeChat, | |
| 861 | + searches for the contact in the select-contact sheet, and taps 发送. | |
| 862 | + | |
| 863 | + Verified approach (from wechat-image-sender Hermes skill, 2026-06-23). | |
| 864 | + Coordinates are fraction-based; reference screen 1344×2992. | |
| 865 | + | |
| 866 | + Args: | |
| 867 | + to: Contact wxid or search query. Use 'filehelper' for safe testing. | |
| 868 | + image_path: Absolute device path to PNG or JPEG image. | |
| 869 | + """ | |
| 870 | + import sys | |
| 871 | + _base = os.path.dirname(os.path.abspath(__file__)) | |
| 872 | + if _base not in sys.path: | |
| 873 | + sys.path.insert(0, _base) | |
| 874 | + try: | |
| 875 | + from wechat_hook_send import send_image_via_intent | |
| 876 | + result = send_image_via_intent(to_wxid=to, image_path=image_path) | |
| 877 | + return json.dumps(result, ensure_ascii=False) | |
| 878 | + except Exception as e: | |
| 879 | + return json.dumps({"status": "error", "error": str(e)}) | |
| 880 | + | |
| 881 | + # ── tool: send_wechat_voice ────────────────────────────────────────────── | |
| 882 | + | |
| 883 | + @mcp.tool() | |
| 884 | + def send_wechat_voice(to: str, silk_path: str, duration_ms: int = 3000) -> str: | |
| 885 | + """ | |
| 886 | + Send a voice message to a WeChat contact via the internal hook API (no UI simulation). | |
| 887 | + | |
| 888 | + Uses WeChat's w11.r0 factory (MsgType 34). The hook injects the correct | |
| 889 | + voice file ID into the DB insert so the message is stored with the right | |
| 890 | + SILK file reference and is immediately playable. | |
| 891 | + | |
| 892 | + Args: | |
| 893 | + to: Contact wxid (e.g. 'filehelper'). IMPORTANT: test only with 'filehelper'. | |
| 894 | + silk_path: Absolute device path to a SILK V3 file (Tencent variant, | |
| 895 | + starts with \\x02#!SILK_V3). Generate with silkv3-encoder | |
| 896 | + or convert from PCM via silk-v3-encoder. | |
| 897 | + duration_ms: Voice duration in milliseconds shown in WeChat UI. | |
| 898 | + """ | |
| 899 | + import sys | |
| 900 | + _base = os.path.dirname(os.path.abspath(__file__)) | |
| 901 | + if _base not in sys.path: | |
| 902 | + sys.path.insert(0, _base) | |
| 903 | + try: | |
| 904 | + from wechat_hook_send import send_voice_via_hook | |
| 905 | + result = send_voice_via_hook(wxid=to, silk_path=silk_path, duration_ms=duration_ms) | |
| 906 | + return json.dumps(result, ensure_ascii=False) | |
| 907 | + except Exception as e: | |
| 908 | + return json.dumps({"status": "error", "error": str(e)}) | |
| 909 | + | |
| 910 | + # ── tool: send_wechat_voice_tts ────────────────────────────────────────── | |
| 911 | + | |
| 912 | + @mcp.tool() | |
| 913 | + def send_wechat_voice_tts(to: str, text: str, voice: str = "zh-CN-XiaoxiaoNeural") -> str: | |
| 914 | + """ | |
| 915 | + Generate a TTS voice message and send it to a WeChat contact via the | |
| 916 | + internal hook API. No UI simulation required. | |
| 917 | + | |
| 918 | + Full pipeline (runs on-device): | |
| 919 | + text → edge-tts (MP3) → ffmpeg (16kHz PCM) → silk-wasm (SILK V3) | |
| 920 | + → SEND_VOICE_INTERNAL broadcast → WeChat DB insert (status=4, delivered) | |
| 921 | + | |
| 922 | + **DO NOT implement SILK encoding yourself.** This tool handles the entire | |
| 923 | + pipeline. Just pass the text you want spoken. | |
| 924 | + | |
| 925 | + On-device dependencies (install once in Termux if missing): | |
| 926 | + pip install edge-tts | |
| 927 | + pkg install ffmpeg nodejs | |
| 928 | + npm install -g silk-wasm | |
| 929 | + | |
| 930 | + Args: | |
| 931 | + to: Contact wxid (e.g. 'filehelper'). IMPORTANT: test only with 'filehelper'. | |
| 932 | + text: Chinese text to synthesize and send as a voice message. | |
| 933 | + voice: edge-tts voice name (default: zh-CN-XiaoxiaoNeural female). | |
| 934 | + Alternatives: zh-CN-YunxiNeural (male), zh-CN-YunyangNeural (news male). | |
| 935 | + """ | |
| 936 | + import sys | |
| 937 | + _base = os.path.dirname(os.path.abspath(__file__)) | |
| 938 | + if _base not in sys.path: | |
| 939 | + sys.path.insert(0, _base) | |
| 940 | + try: | |
| 941 | + from wechat_hook_send import send_voice_tts_via_hook | |
| 942 | + result = send_voice_tts_via_hook(wxid=to, text=text, voice=voice) | |
| 943 | + return json.dumps(result, ensure_ascii=False) | |
| 828 | 944 | except Exception as e: |
| 829 | 945 | return json.dumps({"status": "error", "error": str(e)}) |
| 830 | 946 | ... | ... |
phoneuse/docs/wechat-voice-send-guide.md
0 → 100644
| 1 | +# WeChat 语音发送指南(给 Hermes Agent) | |
| 2 | + | |
| 3 | +## 核心原则 | |
| 4 | + | |
| 5 | +**不要自己实现 SILK 编码,不要调 edge-tts,不要写 ffmpeg 命令。** | |
| 6 | +调一个 MCP 工具就完成全部工作。 | |
| 7 | + | |
| 8 | +--- | |
| 9 | + | |
| 10 | +## 发语音的正确姿势 | |
| 11 | + | |
| 12 | +### 最简单:文字直接发语音 | |
| 13 | + | |
| 14 | +```python | |
| 15 | +# Hermes 调用: | |
| 16 | +result = await mcp.call("send_wechat_voice_tts", { | |
| 17 | + "to": "filehelper", # 联系人 wxid,测试只用 filehelper | |
| 18 | + "text": "你好,明天下午三点开会。", | |
| 19 | + "voice": "zh-CN-XiaoxiaoNeural" # 可选,默认女声 | |
| 20 | +}) | |
| 21 | +# result: {"status": "sent", "to": "filehelper", "duration_ms": 3240, ...} | |
| 22 | +``` | |
| 23 | + | |
| 24 | +**管线在工具内部自动完成**: | |
| 25 | +`text → edge-tts(MP3) → ffmpeg(16kHz PCM) → silk-wasm(SILK V3) → SEND_VOICE_INTERNAL 广播 → WeChat DB status=4` | |
| 26 | + | |
| 27 | +### 换男声 | |
| 28 | + | |
| 29 | +```python | |
| 30 | +result = await mcp.call("send_wechat_voice_tts", { | |
| 31 | + "to": "filehelper", | |
| 32 | + "text": "今日摘要已生成,请查看。", | |
| 33 | + "voice": "zh-CN-YunxiNeural" # 男声 | |
| 34 | +}) | |
| 35 | +``` | |
| 36 | + | |
| 37 | +可用声音列表: | |
| 38 | +- `zh-CN-XiaoxiaoNeural` — 晓晓,女,自然 | |
| 39 | +- `zh-CN-YunxiNeural` — 云希,男,自然 | |
| 40 | +- `zh-CN-YunyangNeural` — 云扬,男,新闻播报风 | |
| 41 | + | |
| 42 | +### 如果你已经有 SILK 文件 | |
| 43 | + | |
| 44 | +```python | |
| 45 | +result = await mcp.call("send_wechat_voice", { | |
| 46 | + "to": "filehelper", | |
| 47 | + "silk_path": "/data/local/tmp/my_voice.amr", | |
| 48 | + "duration_ms": 4500 | |
| 49 | +}) | |
| 50 | +``` | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## 安全约束 | |
| 55 | + | |
| 56 | +**IMPORTANT**: 测试阶段只对 `filehelper`(文件传输助手)发送,不要对真实联系人发语音。 | |
| 57 | + | |
| 58 | +--- | |
| 59 | + | |
| 60 | +## 发文字消息 | |
| 61 | + | |
| 62 | +```python | |
| 63 | +result = await mcp.call("send_wechat_message", { | |
| 64 | + "to": "filehelper", | |
| 65 | + "content": "你好" | |
| 66 | +}) | |
| 67 | +``` | |
| 68 | + | |
| 69 | +--- | |
| 70 | + | |
| 71 | +## 依赖(第一次使用前确认已安装) | |
| 72 | + | |
| 73 | +在 Termux 里执行: | |
| 74 | +```bash | |
| 75 | +pip install edge-tts | |
| 76 | +pkg install ffmpeg nodejs | |
| 77 | +npm install -g silk-wasm | |
| 78 | +``` | |
| 79 | + | |
| 80 | +验证: | |
| 81 | +```bash | |
| 82 | +python3 -c "import edge_tts; print('OK')" | |
| 83 | +ffmpeg -version | head -1 | |
| 84 | +node -e "require('silk-wasm'); console.log('OK')" | |
| 85 | +``` | |
| 86 | + | |
| 87 | +--- | |
| 88 | + | |
| 89 | +## 常见错误 | |
| 90 | + | |
| 91 | +| 错误信息 | 原因 | 解决 | | |
| 92 | +|---|---|---| | |
| 93 | +| `edge-tts not installed` | pip 未装 | `pip install edge-tts` | | |
| 94 | +| `ffmpeg not found` | 未装 ffmpeg | `pkg install ffmpeg` | | |
| 95 | +| `silk-wasm not found` | 未装 | `npm install -g silk-wasm` | | |
| 96 | +| `WeChat not running` | 微信进程未启动 | 工具会自动启动,等 10s 重试 | | |
| 97 | +| `failed to stage SILK` | 权限问题 | 确认 Magisk root 正常 | | |
| 98 | + | |
| 99 | +--- | |
| 100 | + | |
| 101 | +## 技术背景(不需要懂,只需要调工具) | |
| 102 | + | |
| 103 | +- WeChat 语音消息 = SILK V3 格式(Tencent 变体,`\x02#!SILK_V3` 前缀) | |
| 104 | +- 通过 LSPosed hook(wechathook 模块)注入 WeChat 内部 `w11.r0` 工厂 | |
| 105 | +- DB INSERT 在 r0 构造器内完成,status=4(已送达),不触发 CDN 上传 | |
| 106 | +- 不是 UI 模拟,不影响微信界面,不会被 WeChat 反自动化检测 | ... | ... |
phoneuse/wechat_hook_send.py
0 → 100644
| 1 | +""" | |
| 2 | +WeChat send via LSPosed internal hook (fast path — no UI disruption). | |
| 3 | + | |
| 4 | +Text only. The hook (wechathook module in xhs_lsposed_a16_fix) registers a | |
| 5 | +RECEIVER_NOT_EXPORTED BroadcastReceiver inside the WeChat main process and uses | |
| 6 | +WeChat's own w11.s1 factory to dispatch the message, so it bypasses all of the | |
| 7 | +anti-automation that kills UI-based senders. Delivery is asynchronous after the | |
| 8 | +broadcast returns; the caller decides whether to DB-verify. | |
| 9 | + | |
| 10 | +Requirements | |
| 11 | +------------ | |
| 12 | +- wechathook LSPosed module installed, scope includes com.tencent.mm | |
| 13 | +- WeChat main process running (or this module starts it) | |
| 14 | +- Broadcast must come from root (RECEIVER_NOT_EXPORTED) | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import base64 | |
| 19 | +import subprocess | |
| 20 | +import time | |
| 21 | +from typing import Any | |
| 22 | + | |
| 23 | +from ui_control_tools import _run, _adb_prefix, _ON_DEVICE | |
| 24 | + | |
| 25 | +SEND_ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL" | |
| 26 | +_WECHAT_PKG = "com.tencent.mm" | |
| 27 | +_WECHAT_MAIN = "com.tencent.mm/.ui.LauncherUI" | |
| 28 | + | |
| 29 | + | |
| 30 | +def _wechat_running() -> bool: | |
| 31 | + try: | |
| 32 | + r = _run("pidof " + _WECHAT_PKG, timeout=5) | |
| 33 | + return bool((r.stdout or "").strip()) | |
| 34 | + except Exception: | |
| 35 | + return False | |
| 36 | + | |
| 37 | + | |
| 38 | +def _start_wechat(wait_secs: int = 12) -> bool: | |
| 39 | + _run("am start -n " + _WECHAT_MAIN, timeout=8) | |
| 40 | + deadline = time.time() + wait_secs | |
| 41 | + while time.time() < deadline: | |
| 42 | + if _wechat_running(): | |
| 43 | + return True | |
| 44 | + time.sleep(1) | |
| 45 | + return False | |
| 46 | + | |
| 47 | + | |
| 48 | +def _root_sh(cmd: str, timeout: int = 10) -> subprocess.CompletedProcess: | |
| 49 | + """Run *cmd* as root, both on-device (su -c) and off-device (adb shell su -c).""" | |
| 50 | + b64 = base64.b64encode(cmd.encode()).decode() | |
| 51 | + payload = f"echo {b64} | base64 -d | sh" | |
| 52 | + if _ON_DEVICE: | |
| 53 | + return subprocess.run(["su", "-c", payload], | |
| 54 | + capture_output=True, text=True, timeout=timeout) | |
| 55 | + adb = _adb_prefix() | |
| 56 | + return subprocess.run(adb + [f"su -c '{payload}'"], | |
| 57 | + capture_output=True, text=True, timeout=timeout + 5) | |
| 58 | + | |
| 59 | + | |
| 60 | +def send_text_via_hook(wxid: str, text: str) -> dict[str, Any]: | |
| 61 | + """Send *text* to *wxid* via the wechathook internal broadcast. | |
| 62 | + | |
| 63 | + Fast (< 1 s round-trip), no UI disruption, no ADBKeyboard dependency. | |
| 64 | + Status 'sent' means the broadcast was accepted by the system; delivery is | |
| 65 | + asynchronous. Use wechat_send._verify_sent() for DB confirmation if needed. | |
| 66 | + | |
| 67 | + Args: | |
| 68 | + wxid: talker id as stored in WeChat's rcontact.username (e.g. 'filehelper', | |
| 69 | + 'wxid_xxx'). Nicknames are NOT resolved here — caller must resolve first. | |
| 70 | + text: message text (arbitrary UTF-8, including CJK). | |
| 71 | + """ | |
| 72 | + if not wxid or not text: | |
| 73 | + return {"status": "error", "error": "wxid and text required"} | |
| 74 | + | |
| 75 | + if not _wechat_running(): | |
| 76 | + if not _start_wechat(): | |
| 77 | + return {"status": "error", "error": "WeChat not running and failed to start"} | |
| 78 | + time.sleep(3) # allow App.onCreate + BroadcastReceiver registration | |
| 79 | + | |
| 80 | + # Double-base64 the args to survive nested su quoting with arbitrary content. | |
| 81 | + b64_wxid = base64.b64encode(wxid.encode()).decode() | |
| 82 | + b64_text = base64.b64encode(text.encode()).decode() | |
| 83 | + cmd = ( | |
| 84 | + f'TO=$(printf "%s" "{b64_wxid}" | base64 -d); ' | |
| 85 | + f'TEXT=$(printf "%s" "{b64_text}" | base64 -d); ' | |
| 86 | + f'am broadcast -a {SEND_ACTION} --es to "$TO" --es text "$TEXT"' | |
| 87 | + ) | |
| 88 | + | |
| 89 | + try: | |
| 90 | + r = _root_sh(cmd) | |
| 91 | + except subprocess.TimeoutExpired: | |
| 92 | + return {"status": "error", "error": "broadcast timed out"} | |
| 93 | + | |
| 94 | + stdout = (r.stdout or "").strip() | |
| 95 | + stderr = (r.stderr or "").strip() | |
| 96 | + # am broadcast exits 0 and prints "Broadcast completed: result=0" on success. | |
| 97 | + if r.returncode == 0 and ("result=" in stdout or "Broadcast completed" in stdout): | |
| 98 | + return { | |
| 99 | + "status": "sent", | |
| 100 | + "to": wxid, | |
| 101 | + "method": "hook_broadcast", | |
| 102 | + "verified": False, # DB not checked; caller may call _verify_sent | |
| 103 | + "detail": stdout[:200], | |
| 104 | + } | |
| 105 | + return { | |
| 106 | + "status": "error", | |
| 107 | + "error": f"broadcast rc={r.returncode}: {stderr or stdout or '(no output)'}", | |
| 108 | + } | |
| 109 | + | |
| 110 | + | |
| 111 | +def send_image_via_intent(to_wxid: str, image_path: str) -> dict[str, Any]: | |
| 112 | + """Send an image to *to_wxid* via WeChat's share intent + UI tap. | |
| 113 | + | |
| 114 | + Copies the image to /sdcard/ (world-readable), fires an ACTION_SEND intent, | |
| 115 | + then navigates WeChat's 'select contact' sheet to pick the target and confirms. | |
| 116 | + | |
| 117 | + This is a best-effort UI path — it works reliably for filehelper. For other | |
| 118 | + contacts the search step matches the first rcontact search result, which may | |
| 119 | + be wrong if multiple contacts share a name fragment. | |
| 120 | + | |
| 121 | + Args: | |
| 122 | + to_wxid: must be 'filehelper' for safe testing, or a unique search query. | |
| 123 | + image_path: absolute path on the device to a PNG/JPEG image file. | |
| 124 | + """ | |
| 125 | + import os | |
| 126 | + # Validated approach from the Hermes wechat-image-sender skill (2026-06-23): | |
| 127 | + # am start -a android.intent.action.SEND -t image/png with STREAM extra → | |
| 128 | + # WeChat "选择联系人" UI → search → tap → 发送 at ~(0.67, 0.88). | |
| 129 | + # Coordinates verified on 1344×2992 screen (fixed: 899, 2627). | |
| 130 | + | |
| 131 | + # ── 1. Copy image to /sdcard so WeChat can read it ────────────────────── | |
| 132 | + ext = os.path.splitext(image_path)[1].lower() or ".jpg" | |
| 133 | + share_path = f"/sdcard/wechat_share_tmp{ext}" | |
| 134 | + mime = "image/png" if ext == ".png" else "image/jpeg" | |
| 135 | + | |
| 136 | + cp_result = _root_sh(f'cp "{image_path}" "{share_path}" && chmod 644 "{share_path}"') | |
| 137 | + if cp_result.returncode != 0: | |
| 138 | + return {"status": "error", | |
| 139 | + "error": f"failed to copy image to /sdcard: {cp_result.stderr or cp_result.stdout}"} | |
| 140 | + | |
| 141 | + # ── 2. Fire the share intent ───────────────────────────────────────────── | |
| 142 | + _run(f"am start -a android.intent.action.SEND -t {mime} " | |
| 143 | + f"--eu android.intent.extra.STREAM 'file://{share_path}' " | |
| 144 | + f"-p {_WECHAT_PKG}", timeout=10) | |
| 145 | + time.sleep(2.5) # wait for WeChat to open the select-contact sheet | |
| 146 | + | |
| 147 | + # ── 3. Search for the contact in the sheet (same approach as text send) ── | |
| 148 | + from ui_control_tools import _run as run | |
| 149 | + from wechat_send import _screen_size, _tap_frac, _foreground | |
| 150 | + | |
| 151 | + w, h = _screen_size() | |
| 152 | + query = "文件传输助手" if to_wxid == "filehelper" else to_wxid | |
| 153 | + | |
| 154 | + # The share sheet has a search box. Tap it, type the query. | |
| 155 | + search_frac = (0.5, 0.075) # top search bar in share contact picker | |
| 156 | + result_frac = (0.5, 0.22) # first result row | |
| 157 | + send_frac = (0.669, 0.878) # 发送 button; 899/1344, 2627/2992 on ref device | |
| 158 | + | |
| 159 | + _tap_frac(search_frac, w, h) | |
| 160 | + time.sleep(0.8) | |
| 161 | + | |
| 162 | + # Use ADBKeyboard to type the search query | |
| 163 | + b64q = base64.b64encode(query.encode()).decode() | |
| 164 | + _root_sh(f'am broadcast -a ADB_INPUT_B64 --es msg "{b64q}"') | |
| 165 | + time.sleep(1.5) | |
| 166 | + | |
| 167 | + _tap_frac(result_frac, w, h) | |
| 168 | + time.sleep(1.0) | |
| 169 | + | |
| 170 | + # ── 4. Tap "发送" / confirm ─────────────────────────────────────────────── | |
| 171 | + _tap_frac(send_frac, w, h) | |
| 172 | + time.sleep(1.5) | |
| 173 | + | |
| 174 | + # Cleanup temp file | |
| 175 | + _root_sh(f'rm -f "{share_path}"') | |
| 176 | + | |
| 177 | + return { | |
| 178 | + "status": "sent", | |
| 179 | + "to": to_wxid, | |
| 180 | + "image": image_path, | |
| 181 | + "method": "share_intent", | |
| 182 | + "verified": False, # image sends don't leave a simple DB row to verify | |
| 183 | + } | |
| 184 | + | |
| 185 | + | |
| 186 | +def send_voice_via_hook(wxid: str, silk_path: str, duration_ms: int = 3000) -> dict[str, Any]: | |
| 187 | + """Send a voice message to *wxid* via the wechathook internal API. | |
| 188 | + | |
| 189 | + Uses WeChat's own w11.r0 factory (type=34) — no UI simulation. | |
| 190 | + The hook (hookF9ImgPathInjector) intercepts the f9 entity inside the r0 | |
| 191 | + constructor to inject the voiceId before the DB insert, so the stored row | |
| 192 | + has the correct imgPath pointing to the SILK file. | |
| 193 | + | |
| 194 | + Protocol: | |
| 195 | + 1. Push SILK V3 file to WeChat's cache dir (root-writable). | |
| 196 | + 2. Fire com.hermes.wechat.SEND_VOICE_INTERNAL broadcast (root-only, | |
| 197 | + RECEIVER_NOT_EXPORTED). The hook generates a random 26-char voiceId, | |
| 198 | + computes voice2/{md5(id)[0:2]}/{md5(id)[2:4]}/msg_{id}.amr path, | |
| 199 | + copies from cache, inserts the DB row, and calls H() for server sync. | |
| 200 | + | |
| 201 | + Args: | |
| 202 | + wxid: talker id as in WeChat DB (e.g. 'filehelper', 'wxid_xxx'). | |
| 203 | + IMPORTANT: test only with 'filehelper' to avoid account risk. | |
| 204 | + silk_path: absolute path on the device to a SILK V3 file | |
| 205 | + (Tencent variant: starts with \\x02#!SILK_V3). | |
| 206 | + duration_ms: voice duration in milliseconds (shown in WeChat UI). | |
| 207 | + """ | |
| 208 | + if not wxid or not silk_path: | |
| 209 | + return {"status": "error", "error": "wxid and silk_path required"} | |
| 210 | + | |
| 211 | + if not _wechat_running(): | |
| 212 | + if not _start_wechat(): | |
| 213 | + return {"status": "error", "error": "WeChat not running and failed to start"} | |
| 214 | + time.sleep(3) | |
| 215 | + | |
| 216 | + # Stage 1: push SILK to WeChat's cache (readable by WeChat process) | |
| 217 | + wechat_cache = "/data/data/com.tencent.mm/cache/hook_voice_src.amr" | |
| 218 | + stage = _root_sh( | |
| 219 | + f'cp "{silk_path}" "{wechat_cache}" && ' | |
| 220 | + f'chmod 644 "{wechat_cache}" && ' | |
| 221 | + f'chown u0_a330:u0_a330 "{wechat_cache}"' | |
| 222 | + ) | |
| 223 | + if stage.returncode != 0: | |
| 224 | + return { | |
| 225 | + "status": "error", | |
| 226 | + "error": f"failed to stage SILK in WeChat cache: {stage.stderr or stage.stdout}", | |
| 227 | + } | |
| 228 | + | |
| 229 | + # Stage 2: fire the voice broadcast | |
| 230 | + cmd = ( | |
| 231 | + f"am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL " | |
| 232 | + f"--es to {wxid} " | |
| 233 | + f"--es src_path {wechat_cache} " | |
| 234 | + f"--ei duration {duration_ms}" | |
| 235 | + ) | |
| 236 | + try: | |
| 237 | + r = _root_sh(cmd) | |
| 238 | + except subprocess.TimeoutExpired: | |
| 239 | + return {"status": "error", "error": "voice broadcast timed out"} | |
| 240 | + | |
| 241 | + stdout = (r.stdout or "").strip() | |
| 242 | + stderr = (r.stderr or "").strip() | |
| 243 | + if r.returncode == 0 and ("result=" in stdout or "Broadcast completed" in stdout): | |
| 244 | + return { | |
| 245 | + "status": "sent", | |
| 246 | + "to": wxid, | |
| 247 | + "method": "hook_broadcast_voice", | |
| 248 | + "duration_ms": duration_ms, | |
| 249 | + "verified": False, | |
| 250 | + "detail": stdout[:200], | |
| 251 | + } | |
| 252 | + return { | |
| 253 | + "status": "error", | |
| 254 | + "error": f"voice broadcast rc={r.returncode}: {stderr or stdout or '(no output)'}", | |
| 255 | + } | |
| 256 | + | |
| 257 | + | |
| 258 | +# ── TTS helpers ─────────────────────────────────────────────────────────────── | |
| 259 | + | |
| 260 | +def _tts_to_silk(text: str, voice: str, tmp_dir: str) -> tuple[str, int]: | |
| 261 | + """Generate SILK V3 .amr from *text* using edge-tts + ffmpeg + silk-wasm. | |
| 262 | + | |
| 263 | + Dependencies (must be installed on-device or on Mac): | |
| 264 | + pip install edge-tts | |
| 265 | + (Termux) pkg install ffmpeg nodejs && npm install -g silk-wasm | |
| 266 | + (Mac) brew install ffmpeg; openclaw installs silk-wasm automatically | |
| 267 | + | |
| 268 | + Returns (silk_path, duration_ms). | |
| 269 | + Raises RuntimeError if any step fails. | |
| 270 | + """ | |
| 271 | + import asyncio, os, shutil | |
| 272 | + | |
| 273 | + mp3 = os.path.join(tmp_dir, "tts.mp3") | |
| 274 | + pcm = os.path.join(tmp_dir, "tts.pcm") | |
| 275 | + silk = os.path.join(tmp_dir, "tts.amr") | |
| 276 | + | |
| 277 | + # 1. edge-tts → MP3 | |
| 278 | + try: | |
| 279 | + import edge_tts | |
| 280 | + except ImportError: | |
| 281 | + raise RuntimeError("edge-tts not installed: pip install edge-tts") | |
| 282 | + | |
| 283 | + async def _synth(): | |
| 284 | + await edge_tts.Communicate(text, voice).save(mp3) | |
| 285 | + | |
| 286 | + asyncio.run(_synth()) | |
| 287 | + | |
| 288 | + # 2. ffmpeg → PCM16LE 16kHz mono | |
| 289 | + if not shutil.which("ffmpeg"): | |
| 290 | + raise RuntimeError("ffmpeg not found: (Termux) pkg install ffmpeg") | |
| 291 | + subprocess.run( | |
| 292 | + ["ffmpeg", "-y", "-i", mp3, "-ar", "16000", "-ac", "1", "-f", "s16le", pcm], | |
| 293 | + check=True, capture_output=True | |
| 294 | + ) | |
| 295 | + | |
| 296 | + # 3. Node.js silk-wasm → SILK V3 (already includes \x02#!SILK_V3 prefix) | |
| 297 | + if not shutil.which("node"): | |
| 298 | + raise RuntimeError("node not found: (Termux) pkg install nodejs") | |
| 299 | + | |
| 300 | + # Locate silk-wasm: check global npm, then openclaw bundle | |
| 301 | + node_script = f""" | |
| 302 | +var silkPaths = [ | |
| 303 | + 'silk-wasm', | |
| 304 | + '/opt/homebrew/lib/node_modules/openclaw/node_modules/silk-wasm', | |
| 305 | + '/data/data/com.termux/files/usr/lib/node_modules/silk-wasm', | |
| 306 | +]; | |
| 307 | +var silk = null; | |
| 308 | +for (var p of silkPaths) {{ | |
| 309 | + try {{ silk = require(p); break; }} catch(e) {{}} | |
| 310 | +}} | |
| 311 | +if (!silk) {{ process.stderr.write('silk-wasm not found'); process.exit(1); }} | |
| 312 | +var fs = require('fs'); | |
| 313 | +silk.encode(fs.readFileSync('{pcm}'), 16000).then(function(r) {{ | |
| 314 | + fs.writeFileSync('{silk}', r.data); | |
| 315 | + process.stdout.write(String(r.duration)); | |
| 316 | +}}).catch(function(e) {{ process.stderr.write(String(e)); process.exit(1); }}); | |
| 317 | +""" | |
| 318 | + r = subprocess.run(["node", "-e", node_script], capture_output=True, text=True, timeout=60) | |
| 319 | + if r.returncode != 0: | |
| 320 | + raise RuntimeError(f"silk-wasm encode failed: {r.stderr or r.stdout}. " | |
| 321 | + "Install: npm install -g silk-wasm") | |
| 322 | + duration_ms = int(r.stdout.strip()) if r.stdout.strip().isdigit() else 3000 | |
| 323 | + return silk, duration_ms | |
| 324 | + | |
| 325 | + | |
| 326 | +def send_voice_tts_via_hook( | |
| 327 | + wxid: str, | |
| 328 | + text: str, | |
| 329 | + voice: str = "zh-CN-XiaoxiaoNeural", | |
| 330 | +) -> dict[str, Any]: | |
| 331 | + """Generate TTS from *text* and send as WeChat voice message to *wxid*. | |
| 332 | + | |
| 333 | + End-to-end: edge-tts → ffmpeg (16kHz PCM) → silk-wasm (SILK V3) → | |
| 334 | + wechathook SEND_VOICE_INTERNAL broadcast → WeChat DB insert, status=4. | |
| 335 | + | |
| 336 | + Args: | |
| 337 | + wxid: talker id (e.g. 'filehelper'). IMPORTANT: test only with 'filehelper'. | |
| 338 | + text: Chinese text to synthesize (or any language supported by the voice). | |
| 339 | + voice: edge-tts voice name. Defaults to zh-CN-XiaoxiaoNeural (female). | |
| 340 | + Other options: zh-CN-YunxiNeural (male), zh-CN-YunyangNeural (news male). | |
| 341 | + """ | |
| 342 | + import tempfile | |
| 343 | + | |
| 344 | + if not wxid or not text: | |
| 345 | + return {"status": "error", "error": "wxid and text required"} | |
| 346 | + | |
| 347 | + if not _wechat_running(): | |
| 348 | + if not _start_wechat(): | |
| 349 | + return {"status": "error", "error": "WeChat not running and failed to start"} | |
| 350 | + time.sleep(3) | |
| 351 | + | |
| 352 | + with tempfile.TemporaryDirectory(prefix="wc_tts_") as tmp: | |
| 353 | + try: | |
| 354 | + silk_path, duration_ms = _tts_to_silk(text, voice, tmp) | |
| 355 | + except RuntimeError as e: | |
| 356 | + return {"status": "error", "error": str(e)} | |
| 357 | + except Exception as e: | |
| 358 | + return {"status": "error", "error": f"TTS/SILK generation failed: {e}"} | |
| 359 | + | |
| 360 | + wechat_cache = "/data/data/com.tencent.mm/cache/hook_voice_src.amr" | |
| 361 | + if _ON_DEVICE: | |
| 362 | + stage = _root_sh( | |
| 363 | + f'cp "{silk_path}" "{wechat_cache}" && ' | |
| 364 | + f'chmod 644 "{wechat_cache}" && ' | |
| 365 | + f'chown u0_a330:u0_a330 "{wechat_cache}"' | |
| 366 | + ) | |
| 367 | + else: | |
| 368 | + adb = _adb_prefix() | |
| 369 | + remote_tmp = "/sdcard/hook_voice_tts_tmp.amr" | |
| 370 | + subprocess.run(adb + ["push", silk_path, remote_tmp], | |
| 371 | + check=True, capture_output=True) | |
| 372 | + stage = _root_sh( | |
| 373 | + f'cp "{remote_tmp}" "{wechat_cache}" && ' | |
| 374 | + f'chmod 644 "{wechat_cache}" && ' | |
| 375 | + f'chown u0_a330:u0_a330 "{wechat_cache}" && ' | |
| 376 | + f'rm -f "{remote_tmp}"' | |
| 377 | + ) | |
| 378 | + | |
| 379 | + if stage.returncode != 0: | |
| 380 | + return {"status": "error", | |
| 381 | + "error": f"failed to stage SILK: {stage.stderr or stage.stdout}"} | |
| 382 | + | |
| 383 | + # TemporaryDirectory is cleaned up; wechat_cache is intact | |
| 384 | + result = send_voice_via_hook(wxid=wxid, silk_path=wechat_cache, | |
| 385 | + duration_ms=duration_ms) | |
| 386 | + result["tts_text"] = text[:100] | |
| 387 | + result["tts_voice"] = voice | |
| 388 | + return result | ... | ... |