diff --git a/phoneuse/android_mcp.py b/phoneuse/android_mcp.py index 5476cc8..1dd8fc4 100644 --- a/phoneuse/android_mcp.py +++ b/phoneuse/android_mcp.py @@ -855,25 +855,31 @@ def _build_server(): @mcp.tool() def send_wechat_image(to: str, image_path: str) -> str: """ - Send an image to a WeChat contact via the share intent + UI tap flow. + Send an image to a WeChat contact via the internal hook API (no UI simulation). - Copies the image to /sdcard, fires android.intent.action.SEND into WeChat, - searches for the contact in the select-contact sheet, and taps 发送. + Fires SEND_IMAGE_INTERNAL → WeChat's own image plugin (b80.u.oj inserts the + message + ImgInfo + builds CDN descriptors) → CDN service (v70.w.oj) uploads + the full image via WeChat's own CDN. Receiver sees the full-resolution + original (not a stuck thumbnail). Fire-and-forget: delivery is async after + the broadcast; confirm via a WeChat UI screenshot if needed. - Verified approach (from wechat-image-sender Hermes skill, 2026-06-23). - Coordinates are fraction-based; reference screen 1344×2992. + Preferred over the old share-intent path — no contact-picker, no wrong-contact + risk. Verified 2026-07-07 on WeChat 8.0.74 (filehelper + real contact). Args: - to: Contact wxid or search query. Use 'filehelper' for safe testing. - image_path: Absolute device path to PNG or JPEG image. + to: Contact wxid as stored in rcontact.username (e.g. 'filehelper', + 'wxid_xxx', 'yibooo'). Nicknames are NOT resolved. Test with + 'filehelper' first to avoid account risk. + image_path: Absolute device path to a PNG or JPEG. Staged into /sdcard + automatically so root-only source paths also work. """ import sys _base = os.path.dirname(os.path.abspath(__file__)) if _base not in sys.path: sys.path.insert(0, _base) try: - from wechat_hook_send import send_image_via_intent - result = send_image_via_intent(to_wxid=to, image_path=image_path) + from wechat_hook_send import send_image_via_hook + result = send_image_via_hook(wxid=to, image_path=image_path) return json.dumps(result, ensure_ascii=False) except Exception as e: return json.dumps({"status": "error", "error": str(e)}) diff --git a/phoneuse/wechat_hook_send.py b/phoneuse/wechat_hook_send.py index 880ab7f..6e03a15 100644 --- a/phoneuse/wechat_hook_send.py +++ b/phoneuse/wechat_hook_send.py @@ -23,6 +23,7 @@ from typing import Any from ui_control_tools import _run, _adb_prefix, _ON_DEVICE SEND_ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL" +SEND_IMAGE_ACTION = "com.hermes.wechat.SEND_IMAGE_INTERNAL" _WECHAT_PKG = "com.tencent.mm" _WECHAT_MAIN = "com.tencent.mm/.ui.LauncherUI" @@ -183,6 +184,71 @@ def send_image_via_intent(to_wxid: str, image_path: str) -> dict[str, Any]: } +def send_image_via_hook(wxid: str, image_path: str) -> dict[str, Any]: + """Send an image to *wxid* via the wechathook internal API (no UI simulation). + + Fires SEND_IMAGE_INTERNAL. The hook calls WeChat's own image plugin + b80.u.oj (insert message f9 + ImgInfo b0 + build CDN upload descriptors) + then submits each descriptor to the CDN service v70.w.oj — the full image + uploads via WeChat's own CDN (receiver sees full-resolution original, not a + stuck thumbnail). Fire-and-forget: delivery is async after the broadcast. + + This is the fast path — no share sheet, no contact search, no wrong-contact + risk. Preferred over send_image_via_intent. Verified 2026-07-07 on WeChat + 8.0.74 (filehelper + real contact, full-res delivery). + + Args: + wxid: talker id as stored in rcontact.username (e.g. 'filehelper', + 'wxid_xxx', 'yibooo'). Nicknames are NOT resolved here. + image_path: absolute device path to a PNG/JPEG. Must be readable by the + WeChat process; this fn stages it into /sdcard first so + root-only source paths work too. + """ + import os + if not wxid or not image_path: + return {"status": "error", "error": "wxid and image_path required"} + + if not _wechat_running(): + if not _start_wechat(): + return {"status": "error", "error": "WeChat not running and failed to start"} + time.sleep(3) # allow App.onCreate + BroadcastReceiver registration + + # Stage into /sdcard so the WeChat process (which runs the hook) can read it, + # regardless of whether the source path is root-only. No-op-ish if already there. + ext = os.path.splitext(image_path)[1].lower() or ".jpg" + share_path = f"/sdcard/wechat_send_tmp{ext}" + cp = _root_sh(f'cp "{image_path}" "{share_path}" && chmod 644 "{share_path}"') + if cp.returncode != 0: + return {"status": "error", + "error": f"failed to stage image to /sdcard: {cp.stderr or cp.stdout}"} + + b64_wxid = base64.b64encode(wxid.encode()).decode() + cmd = ( + f'TO=$(printf "%s" "{b64_wxid}" | base64 -d); ' + f'am broadcast -a {SEND_IMAGE_ACTION} --es to "$TO" --es path "{share_path}"' + ) + try: + r = _root_sh(cmd) + except subprocess.TimeoutExpired: + return {"status": "error", "error": "broadcast timed out"} + + stdout = (r.stdout or "").strip() + stderr = (r.stderr or "").strip() + if r.returncode == 0 and ("result=" in stdout or "Broadcast completed" in stdout): + return { + "status": "sent", + "to": wxid, + "image": image_path, + "method": "hook_broadcast", + "verified": False, # CDN upload is async; confirm via UI screenshot + "detail": stdout[:200], + } + return { + "status": "error", + "error": f"broadcast rc={r.returncode}: {stderr or stdout or '(no output)'}", + } + + def send_voice_via_hook(wxid: str, silk_path: str, duration_ms: int = 3000) -> dict[str, Any]: """Send a voice message to *wxid* via the wechathook internal API.