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,34 +797,150 @@ def _build_server(): | ||
| 797 | # ── tool: send_wechat_message ───────────────────────────────────────────── | 797 | # ── tool: send_wechat_message ───────────────────────────────────────────── |
| 798 | 798 | ||
| 799 | @mcp.tool() | 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 | Args: | 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 | try: | 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 | from wechat_send import send_wechat_message_ui | 846 | from wechat_send import send_wechat_message_ui |
| 826 | result = send_wechat_message_ui(to=to, content=content, timeout=timeout) | 847 | result = send_wechat_message_ui(to=to, content=content, timeout=timeout) |
| 827 | return json.dumps(result, ensure_ascii=False) | 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 | except Exception as e: | 944 | except Exception as e: |
| 829 | return json.dumps({"status": "error", "error": str(e)}) | 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 |