Commit 40ff74d1060bcfdc8365b267ee64bf1de7cfe009
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>
Showing
2 changed files
with
81 additions
and
9 deletions
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) -> 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 | ... | ... |