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,25 +855,31 @@ def _build_server(): | ||
| 855 | @mcp.tool() | 855 | @mcp.tool() |
| 856 | def send_wechat_image(to: str, image_path: str) -> str: | 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 | Args: | 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 | import sys | 876 | import sys |
| 871 | _base = os.path.dirname(os.path.abspath(__file__)) | 877 | _base = os.path.dirname(os.path.abspath(__file__)) |
| 872 | if _base not in sys.path: | 878 | if _base not in sys.path: |
| 873 | sys.path.insert(0, _base) | 879 | sys.path.insert(0, _base) |
| 874 | try: | 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 | return json.dumps(result, ensure_ascii=False) | 883 | return json.dumps(result, ensure_ascii=False) |
| 878 | except Exception as e: | 884 | except Exception as e: |
| 879 | return json.dumps({"status": "error", "error": str(e)}) | 885 | return json.dumps({"status": "error", "error": str(e)}) |
phoneuse/wechat_hook_send.py
| @@ -23,6 +23,7 @@ from typing import Any | @@ -23,6 +23,7 @@ from typing import Any | ||
| 23 | from ui_control_tools import _run, _adb_prefix, _ON_DEVICE | 23 | from ui_control_tools import _run, _adb_prefix, _ON_DEVICE |
| 24 | 24 | ||
| 25 | SEND_ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL" | 25 | SEND_ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL" |
| 26 | +SEND_IMAGE_ACTION = "com.hermes.wechat.SEND_IMAGE_INTERNAL" | ||
| 26 | _WECHAT_PKG = "com.tencent.mm" | 27 | _WECHAT_PKG = "com.tencent.mm" |
| 27 | _WECHAT_MAIN = "com.tencent.mm/.ui.LauncherUI" | 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,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 | def send_voice_via_hook(wxid: str, silk_path: str, duration_ms: int = 3000) -> dict[str, Any]: | 252 | 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. | 253 | """Send a voice message to *wxid* via the wechathook internal API. |
| 188 | 254 |