Commit 40ff74d1060bcfdc8365b267ee64bf1de7cfe009

Authored by yibo
1 parent 76cb01ea

feat(phoneuse): send_wechat_image MCP 工具改走内部 API(全图 CDN 上传)

图片 CDN 上传打通后,把 MCP 发图工具从旧的 share-intent UI 模拟切到内部 API:

- wechat_hook_send.py: 新增 send_image_via_hook(wxid, image_path) — 先 stage
  到 /sdcard 保证 WeChat 进程可读,再广播 SEND_IMAGE_INTERNAL;hook 内部走
  b80.u.oj(插库+建描述符) + v70.w.oj(提交 CDN 上传),接收方收全分辨率原图。
  新增常量 SEND_IMAGE_ACTION。旧 send_image_via_intent 保留作参考/UI 兜底。
- android_mcp.py: send_wechat_image 工具改调 send_image_via_hook,更新 docstring
  (无 UI 模拟、无选联系人、无发错人风险;nickname 不解析,须传 wxid)。

真机验证(off-device 走 wechat_hook_send.send_image_via_hook 直调):
status=sent + hook oj() DONE + CDN submit DONE items=1。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
phoneuse/android_mcp.py
... ... @@ -855,25 +855,31 @@ def _build_server():
855 855 @mcp.tool()
856 856 def send_wechat_image(to: str, image_path: str) -> str:
857 857 """
858   - Send an image to a WeChat contact via the share intent + UI tap flow.
  858 + Send an image to a WeChat contact via the internal hook API (no UI simulation).
859 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 发送.
  860 + Fires SEND_IMAGE_INTERNAL → WeChat's own image plugin (b80.u.oj inserts the
  861 + message + ImgInfo + builds CDN descriptors) → CDN service (v70.w.oj) uploads
  862 + the full image via WeChat's own CDN. Receiver sees the full-resolution
  863 + original (not a stuck thumbnail). Fire-and-forget: delivery is async after
  864 + the broadcast; confirm via a WeChat UI screenshot if needed.
862 865  
863   - Verified approach (from wechat-image-sender Hermes skill, 2026-06-23).
864   - Coordinates are fraction-based; reference screen 1344×2992.
  866 + Preferred over the old share-intent path — no contact-picker, no wrong-contact
  867 + risk. Verified 2026-07-07 on WeChat 8.0.74 (filehelper + real contact).
865 868  
866 869 Args:
867   - to: Contact wxid or search query. Use 'filehelper' for safe testing.
868   - image_path: Absolute device path to PNG or JPEG image.
  870 + to: Contact wxid as stored in rcontact.username (e.g. 'filehelper',
  871 + 'wxid_xxx', 'yibooo'). Nicknames are NOT resolved. Test with
  872 + 'filehelper' first to avoid account risk.
  873 + image_path: Absolute device path to a PNG or JPEG. Staged into /sdcard
  874 + automatically so root-only source paths also work.
869 875 """
870 876 import sys
871 877 _base = os.path.dirname(os.path.abspath(__file__))
872 878 if _base not in sys.path:
873 879 sys.path.insert(0, _base)
874 880 try:
875   - from wechat_hook_send import send_image_via_intent
876   - result = send_image_via_intent(to_wxid=to, image_path=image_path)
  881 + from wechat_hook_send import send_image_via_hook
  882 + result = send_image_via_hook(wxid=to, image_path=image_path)
877 883 return json.dumps(result, ensure_ascii=False)
878 884 except Exception as e:
879 885 return json.dumps({"status": "error", "error": str(e)})
... ...
phoneuse/wechat_hook_send.py
... ... @@ -23,6 +23,7 @@ from typing import Any
23 23 from ui_control_tools import _run, _adb_prefix, _ON_DEVICE
24 24  
25 25 SEND_ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL"
  26 +SEND_IMAGE_ACTION = "com.hermes.wechat.SEND_IMAGE_INTERNAL"
26 27 _WECHAT_PKG = "com.tencent.mm"
27 28 _WECHAT_MAIN = "com.tencent.mm/.ui.LauncherUI"
28 29  
... ... @@ -183,6 +184,71 @@ def send_image_via_intent(to_wxid: str, image_path: str) -&gt; dict[str, Any]:
183 184 }
184 185  
185 186  
  187 +def send_image_via_hook(wxid: str, image_path: str) -> dict[str, Any]:
  188 + """Send an image to *wxid* via the wechathook internal API (no UI simulation).
  189 +
  190 + Fires SEND_IMAGE_INTERNAL. The hook calls WeChat's own image plugin
  191 + b80.u.oj (insert message f9 + ImgInfo b0 + build CDN upload descriptors)
  192 + then submits each descriptor to the CDN service v70.w.oj — the full image
  193 + uploads via WeChat's own CDN (receiver sees full-resolution original, not a
  194 + stuck thumbnail). Fire-and-forget: delivery is async after the broadcast.
  195 +
  196 + This is the fast path — no share sheet, no contact search, no wrong-contact
  197 + risk. Preferred over send_image_via_intent. Verified 2026-07-07 on WeChat
  198 + 8.0.74 (filehelper + real contact, full-res delivery).
  199 +
  200 + Args:
  201 + wxid: talker id as stored in rcontact.username (e.g. 'filehelper',
  202 + 'wxid_xxx', 'yibooo'). Nicknames are NOT resolved here.
  203 + image_path: absolute device path to a PNG/JPEG. Must be readable by the
  204 + WeChat process; this fn stages it into /sdcard first so
  205 + root-only source paths work too.
  206 + """
  207 + import os
  208 + if not wxid or not image_path:
  209 + return {"status": "error", "error": "wxid and image_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) # allow App.onCreate + BroadcastReceiver registration
  215 +
  216 + # Stage into /sdcard so the WeChat process (which runs the hook) can read it,
  217 + # regardless of whether the source path is root-only. No-op-ish if already there.
  218 + ext = os.path.splitext(image_path)[1].lower() or ".jpg"
  219 + share_path = f"/sdcard/wechat_send_tmp{ext}"
  220 + cp = _root_sh(f'cp "{image_path}" "{share_path}" && chmod 644 "{share_path}"')
  221 + if cp.returncode != 0:
  222 + return {"status": "error",
  223 + "error": f"failed to stage image to /sdcard: {cp.stderr or cp.stdout}"}
  224 +
  225 + b64_wxid = base64.b64encode(wxid.encode()).decode()
  226 + cmd = (
  227 + f'TO=$(printf "%s" "{b64_wxid}" | base64 -d); '
  228 + f'am broadcast -a {SEND_IMAGE_ACTION} --es to "$TO" --es path "{share_path}"'
  229 + )
  230 + try:
  231 + r = _root_sh(cmd)
  232 + except subprocess.TimeoutExpired:
  233 + return {"status": "error", "error": "broadcast timed out"}
  234 +
  235 + stdout = (r.stdout or "").strip()
  236 + stderr = (r.stderr or "").strip()
  237 + if r.returncode == 0 and ("result=" in stdout or "Broadcast completed" in stdout):
  238 + return {
  239 + "status": "sent",
  240 + "to": wxid,
  241 + "image": image_path,
  242 + "method": "hook_broadcast",
  243 + "verified": False, # CDN upload is async; confirm via UI screenshot
  244 + "detail": stdout[:200],
  245 + }
  246 + return {
  247 + "status": "error",
  248 + "error": f"broadcast rc={r.returncode}: {stderr or stdout or '(no output)'}",
  249 + }
  250 +
  251 +
186 252 def send_voice_via_hook(wxid: str, silk_path: str, duration_ms: int = 3000) -> dict[str, Any]:
187 253 """Send a voice message to *wxid* via the wechathook internal API.
188 254  
... ...