feat(bot): add iMessage Desktop setup and bridge (#15228)

 feat(bot): add iMessage Desktop bridge with Labs gate

Desktop-side BlueBubbles bridge for the iMessage channel:

- Bridge runtime (ImessageBridgeCtr/Srv) + gateway message_api_request routing;
  chat-adapter-imessage api lists all webhooks instead of the 500-prone url
  filter (first-time save no longer fails).
- iMessage channel UI: desktopDeviceId + webhookSecret are auto-filled/generated
  (not user fields); a single "Save Configuration" persists both the cloud
  provider and the local bridge via a post-save extension point — no separate
  "Save Bridge" button.
- Gated behind the `enableImessage` Labs preference (off → "Coming Soon").
- Group local-testing bot skills into per-channel folders + add iMessage
  bridge/outbound regression scripts.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-05-28 21:48:44 +08:00
committed by GitHub
parent 109545c3b1
commit 6d94635631
48 changed files with 2179 additions and 128 deletions
+74 -33
View File
@@ -397,35 +397,60 @@ The pattern is the same for every platform:
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
| Platform | Reference | Quick switcher |
| ------------- | -------------------------------------------------- | -------------- |
| Discord | [references/discord.md](./references/discord.md) | `Cmd+K` |
| Slack | [references/slack.md](./references/slack.md) | `Cmd+K` |
| Telegram | [references/telegram.md](./references/telegram.md) | `Cmd+F` |
| WeChat / 微信 | [references/wechat.md](./references/wechat.md) | `Cmd+F` |
| Lark / 飞书 | [references/lark.md](./references/lark.md) | `Cmd+K` |
| QQ | [references/qq.md](./references/qq.md) | `Cmd+F` |
Each channel has its own folder under `bot/<channel>/` containing an `index.md`
(activation, navigation, send-message, and verification snippets specific to
that app) and its test script:
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [references/osascript-common.md](./references/osascript-common.md). Read this first if you're new to osascript automation.
| Platform | Reference | Quick switcher |
| ------------- | ------------------------------------------------ | -------------- |
| Discord | [bot/discord/index.md](./bot/discord/index.md) | `Cmd+K` |
| Slack | [bot/slack/index.md](./bot/slack/index.md) | `Cmd+K` |
| Telegram | [bot/telegram/index.md](./bot/telegram/index.md) | `Cmd+F` |
| WeChat / 微信 | [bot/wechat/index.md](./bot/wechat/index.md) | `Cmd+F` |
| Lark / 飞书 | [bot/lark/index.md](./bot/lark/index.md) | `Cmd+K` |
| QQ | [bot/qq/index.md](./bot/qq/index.md) | `Cmd+F` |
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [bot/osascript-common.md](./bot/osascript-common.md). Read this first if you're new to osascript automation.
## Bridge-based channels (no native app)
Some channels have no native app to drive with osascript — they connect through
a local bridge inside the Desktop app. These are tested with agent-browser
(IPC + UI) plus the bridge's own HTTP/REST endpoints, not osascript:
| Channel | Reference | What it drives |
| -------- | ------------------------------------------------ | -------------------------------------------------------- |
| iMessage | [bot/imessage/index.md](./bot/imessage/index.md) | `imessageBridge.*` IPC + local bridge + BlueBubbles REST |
For iMessage there is a one-shot regression script — see `test-imessage-bridge.sh` below.
---
# Scripts
Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
**App / recording scripts** in `.agents/skills/local-testing/scripts/`:
| Script | Usage |
| ------------------------- | --------------------------------------------------- |
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart) |
| `capture-app-window.sh` | Capture screenshot of a specific app window |
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
| `record-app-screen.sh` | Record app screen (video + screenshots, start/stop) |
| `test-discord-bot.sh` | Send message to Discord bot via osascript |
| `test-slack-bot.sh` | Send message to Slack bot via osascript |
| `test-telegram-bot.sh` | Send message to Telegram bot via osascript |
| `test-wechat-bot.sh` | Send message to WeChat bot via osascript |
| `test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
| `test-qq-bot.sh` | Send message to QQ bot via osascript |
**Bot scripts** live under `.agents/skills/local-testing/bot/`, one folder per
channel (alongside that channel's `index.md`). The shared
`capture-app-window.sh` sits at the `bot/` root:
| Script | Usage |
| ---------------------------------- | ------------------------------------------------------------------- |
| `capture-app-window.sh` | Capture screenshot of a specific app window (used by bot tests) |
| `discord/test-discord-bot.sh` | Send message to Discord bot via osascript |
| `slack/test-slack-bot.sh` | Send message to Slack bot via osascript |
| `telegram/test-telegram-bot.sh` | Send message to Telegram bot via osascript |
| `wechat/test-wechat-bot.sh` | Send message to WeChat bot via osascript |
| `lark/test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
| `qq/test-qq-bot.sh` | Send message to QQ bot via osascript |
| `imessage/test-imessage-bridge.sh` | Regression-test the iMessage BlueBubbles bridge (IPC + HTTP) |
| `imessage/send-imessage-test.sh` | Send one real iMessage (desktop → BB → iMessage) and verify it sent |
### Window Screenshot Utility
@@ -433,9 +458,9 @@ Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
```bash
# Standalone usage
./.agents/skills/local-testing/scripts/capture-app-window.sh "Discord" /tmp/discord.png
./.agents/skills/local-testing/scripts/capture-app-window.sh "Slack" /tmp/slack.png
./.agents/skills/local-testing/scripts/capture-app-window.sh "WeChat" /tmp/wechat.png
./.agents/skills/local-testing/bot/capture-app-window.sh "Discord" /tmp/discord.png
./.agents/skills/local-testing/bot/capture-app-window.sh "Slack" /tmp/slack.png
./.agents/skills/local-testing/bot/capture-app-window.sh "WeChat" /tmp/wechat.png
```
All bot test scripts use this utility automatically for their screenshots.
@@ -452,32 +477,48 @@ Examples:
```bash
# Discord — test a bot in #bot-testing channel
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
# Slack — test a bot in #bot-testing channel
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
# Telegram — test a bot by username
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
# WeChat — test a bot or send to a contact
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
# Lark/飞书 — test a bot in a group chat
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
# QQ — test a bot in a group or direct chat
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
```
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
### iMessage bridge regression script
`test-imessage-bridge.sh` does **not** follow the osascript bot interface — it
drives the Desktop bridge's IPC + HTTP layers and asserts the result, then
self-cleans. Needs BlueBubbles running and Electron up with CDP.
```bash
./.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
# defaults: bb_url=http://127.0.0.1:1234 cdp_port=9222 — exit 0 = all green
```
It guards the connect/configure flow (testConfig happy + reject paths, first-time
`upsertConfig` save, bridge running + webhook registered, local-server secret
enforcement). See [bot/imessage/index.md](./bot/imessage/index.md)
for the full manual UI flow and known bugs.
---
# Screen Recording
@@ -517,4 +558,4 @@ Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/`
### osascript
See [references/osascript-common.md](./references/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
See [bot/osascript-common.md](./bot/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
@@ -2,7 +2,7 @@
**App name:** `Discord` | **Process name:** `Discord`
See [osascript-common.md](./osascript-common.md) for shared patterns.
See [osascript-common.md](../osascript-common.md) for shared patterns.
## Activate & Navigate
@@ -92,6 +92,6 @@ echo "Screenshot saved to /tmp/discord-test-result.png"
## Script
```bash
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
```
@@ -60,5 +60,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -0,0 +1,232 @@
# iMessage Desktop bridge regression test
The iMessage channel is different from the other bot platforms: there is **no
native app to drive with osascript**. Instead the Desktop app runs a local
**BlueBubbles bridge** — a small HTTP server in the Electron main process that
registers a webhook on a local [BlueBubbles](https://bluebubbles.app/) server,
receives iMessage events, and forwards them to LobeHub Cloud.
So the test surface is three layers:
1. **Electron main IPC**`imessageBridge.*` handlers (`getStatus`,
`testConfig`, `upsertConfig`, `removeConfig`, `start`, `stop`)
2. **Local bridge HTTP server**`http://127.0.0.1:<port>/webhooks/bluebubbles/<appId>?secret=<secret>`
3. **BlueBubbles REST API**`http://127.0.0.1:1234/api/v1/*` (webhook + server/info)
## Prerequisites
- A running **BlueBubbles server** (macOS, default `http://127.0.0.1:1234`) with
a known password. Sanity check:
```bash
curl -sS -m4 -o /dev/null -w '%{http_code}\n' \
"http://127.0.0.1:1234/api/v1/server/info?password=<PW>" # expect 200
```
- **Electron dev running with CDP**: `./.agents/skills/local-testing/scripts/electron-dev.sh start`
- The **iMessage Desktop branch** checked out (the `imessageBridge` IPC group
and `@lobechat/chat-adapter-imessage` must be compiled into the main bundle).
Run `pnpm install --ignore-scripts` at the repo root **and** in `apps/desktop/`
after switching branches — the new workspace package must be linked or the
main build fails to resolve `@lobechat/chat-adapter-imessage`.
## Fast path: automated script
```bash
./.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
```
Asserts the whole flow and self-cleans (unique `applicationId` per run, removes
its bridge config + BlueBubbles webhook on exit). Exit 0 = all green. It covers:
- BlueBubbles reachable + password valid; Electron CDP reachable; IPC available
- `testConfig` happy path → success
- `testConfig` wrong password → rejected; unreachable URL → rejected
- `upsertConfig` **first-time save → success** (Bug #1 regression guard, below)
- `getStatus` → `running:true`, config persisted, password redacted (`blueBubblesPasswordSet`)
- BlueBubbles webhook actually registered for the appId
- Local bridge HTTP server: wrong secret → 401; valid secret → past auth
The password is passed as argv (visible in `ps`) — local dev only, don't use a
real secret on a shared machine.
## Layer 1 — IPC probes (no UI)
The renderer exposes the main-process handlers via `window.electronAPI.invoke`.
This is the quickest way to exercise the bridge without clicking:
```bash
# baseline
agent-browser --cdp 9222 eval \
"(async()=>JSON.stringify(await window.electronAPI.invoke('imessageBridge.getStatus',{})))()"
# test a connection (note: password as a JS string)
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(async function () {
try {
var r = await window.electronAPI.invoke('imessageBridge.testConfig', {
applicationId: 'probe',
blueBubblesServerUrl: 'http://127.0.0.1:1234',
blueBubblesPassword: 'PASTE_PW',
enabled: true,
webhookSecret: 'probe-secret',
});
return JSON.stringify(r); // { success: true }
} catch (e) { return 'ERR: ' + (e.message || e); }
})()
EVALEOF
```
`upsertConfig` persists to the Electron store, starts the local HTTP server, and
registers the BlueBubbles webhook. `removeConfig` + `stop` reverse it.
## Layer 2 — full UI flow (agent-browser)
The bridge settings only render in Desktop (`isDesktop` guard) under the agent's
**Channel → iMessage** screen. The platform tile only appears as a real (non
"Coming Soon") entry once the server registers `imessage` **and** the frontend
drops it from `COMING_SOON_PLATFORMS` (`src/routes/(main)/agent/channel/const.ts`).
```bash
agent-browser --cdp 9222 open "http://localhost:5173/agent/<aid>/channel"
agent-browser --cdp 9222 wait --load networkidle && agent-browser --cdp 9222 wait 1500
# confirm the remote backend lists imessage (it must be registered + deployed)
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(async function(){
var url='lobe-backend://lobe/trpc/lambda/agentBotProvider.listPlatforms?input='+encodeURIComponent('{"json":null,"meta":{"values":["undefined"],"v":1}}');
var d=await (await fetch(url,{credentials:'include'})).json();
var p=d.result?.data?.json||d;
return JSON.stringify(p.map(function(x){return x.id;}));
})()
EVALEOF
# click the iMessage tile, then fill the form by ref
agent-browser --cdp 9222 eval "(()=>{var b=[...document.querySelectorAll('aside button')].find(x=>/imessage/i.test(x.textContent));b&&b.click();})()"
agent-browser --cdp 9222 wait 1500
agent-browser --cdp 9222 snapshot -i | grep -iE "127.0.0.1:1234|Application ID|Webhook Secret|Test BlueBubbles|Save Bridge"
```
Field refs (from the snapshot): Application ID, Webhook Secret, BlueBubbles
Server URL (`placeholder="http://127.0.0.1:1234"`), and a **nested** textbox right
under the URL one is the BlueBubbles Password. Fill with `fill` (real input
events — `eval`-setting React inputs won't fire onChange), click **Test
BlueBubbles**, then **Save Bridge**. Read the antd toast immediately (it
auto-dismisses):
```bash
agent-browser --cdp 9222 eval \
"JSON.stringify([...new Set([...document.querySelectorAll('.ant-message-custom-content')].map(n=>n.textContent.trim()))])"
# Test → "BlueBubbles connection passed"
# Save → "iMessage Desktop bridge saved"
```
Verify the end state via BlueBubbles + IPC:
```bash
curl -sS "http://127.0.0.1:1234/api/v1/webhook?password=<PW>" # webhook for the appId present
agent-browser --cdp 9222 eval "(async()=>JSON.stringify(await window.electronAPI.invoke('imessageBridge.getStatus',{})))()"
# running:true, serverUrl: http://127.0.0.1:33270, configs[].blueBubblesPasswordSet:true
```
Cleanup: `removeConfig` + `stop` via IPC, then `DELETE /api/v1/webhook/<id>` on
BlueBubbles.
## Outbound send test (desktop → BlueBubbles → iMessage)
Verifies the leg the bridge uses to _reply_: `BlueBubblesApiClient.sendText`
→ `POST /api/v1/message/text`. Run the helper against your own number:
```bash
./.agents/skills/local-testing/bot/imessage/send-imessage-test.sh '<bb_password>' '+<E164>' # e.g. +15551234567
```
**Gotcha that bites everyone:** with `method=apple-script` and a _new_
conversation, the HTTP POST often **times out** even though the message is
sent. Never judge success by the HTTP response. Instead poll
`POST /api/v1/message/query` and read the matching `isFromMe:true` row's
`error` field:
- `error: 0` (or null) → sent OK
- non-zero `error` → real send failure
The script does exactly this: fires the send, ignores the timeout, then matches
its marker text in the message store and asserts `error == 0`.
Two more notes:
- Use a full E.164 handle (`iMessage;-;+<countrycode><number>`) or an Apple ID
email. Looking the chat up by guid afterwards may 404 if BB filed the message
under a differently-formatted guid — that's a lookup quirk, not a send failure.
- Sending to _your own_ number round-trips: BB records both the outgoing
(`fromMe:true`) and an incoming copy (`fromMe:false`).
## Inbound e2e test (iMessage → cloud agent → reply)
Full inbound chain: a message arrives → BlueBubbles fires its `new-message`
webhook → local bridge (`:33270`) → `forwardWebhook` POSTs to
`<remote>/api/agent/webhooks/imessage/<appId>?secret=…` → cloud agent → reply
flows back via Device Gateway → BB `sendText`.
Prerequisites:
- A cloud bot provider for the same `applicationId` exists and is **connected**
(Save Configuration + the device gateway connected — a _disconnected_ gateway
yields `DEVICE_NOT_FOUND` on connect and blocks the reply leg).
- The `imessage` Labs toggle is on (otherwise the channel is gated to "Coming
Soon"), and `webhookSecret` matches on both ends (auto-generated on save).
Two ways to drive it:
1. **Second device / Apple ID (recommended).** Have _another_ Apple ID message
the BB-hosted number (e.g. "please reply pong"). The bot replies; you see it
on the other device. **No loop risk** — the reply goes to the other party,
not back to itself.
2. **Send to your own number (quick, loop-aware).** `sendText` to the hosted
number; the loopback _incoming_ copy (`isFromMe:false`) triggers the bot.
Watch the reply land in `message/query` as a `fromMe:true` row.
**Loop guard — why a self-send doesn't spin forever:** the Chat SDK adapter
drops any `isFromMe` message before dispatch
(`packages/chat-adapter-imessage/src/adapter.ts`: `if (message.isFromMe) return`).
The bot's own reply (`isFromMe:true`) is never re-processed, so in the normal
case (someone else → bot → reply to them) there is no loop. The self-send case
is a **test-only edge**: the bot's reply also round-trips to your number, and
only the adapter's `isFromMe` check stops a second pass. Keep the prompt
conversational (so the bot doesn't keep finding something to answer), and
**turn the `imessage` lab off / remove the config when done** — never leave a
self-send bot running unattended.
Watch the chain live:
```bash
tail -f /tmp/electron-dev.log | grep -iE "imessage|bridge|forward|Message API"
# the agent reply shows up as a fromMe:true row with the bot's text:
curl -sS -X POST "http://127.0.0.1:1234/api/v1/message/query?password=<PW>" \
-H 'Content-Type: application/json' -d '{"limit":5,"sort":"DESC"}'
```
`startTyping` will log a Private-API error unless BlueBubbles has the Private
API helper set up (needs a jailbroken / SIP-disabled Mac) — it's logged and
ignored; text replies still work.
## Known bugs / gotchas
- **Bug #1 — first-time save (fixed; guarded by the script).** BlueBubbles'
`GET /api/v1/webhook?url=<unregistered>` returns **HTTP 500**
(`Cannot read properties of null (reading 'events')`). The bridge must list
**all** webhooks and match client-side, never pass the `?url=` filter. If you
see `upsertConfig` fail with "An unhandled error has occurred!" originating in
`listWebhooks`, this regressed.
- **Save leaves a half-state on webhook failure.** `upsertConfig` writes the
config + starts the HTTP server _before_ registering the webhook, so a webhook
failure still reports `running:true` with the config persisted but no
BlueBubbles webhook. Always assert the BlueBubbles webhook list, not just IPC
status.
- **Unknown appId / forward failure → 500.** Posting to the local bridge for an
unknown appId, or when no cloud bot is bound, returns 500 (BlueBubbles retries
on 5xx). Auth (wrong secret → 401) is enforced before that.
- **Backend deploy lag.** Desktop dev proxies tRPC through `lobe-backend://` to
the _remote_ server. iMessage only appears in `listPlatforms` once the server
registration is deployed there, regardless of local branch.
- **Restart to load main-process fixes.** Editing `imessageBridgeSrv.ts` /
`@lobechat/chat-adapter-imessage` needs `electron-dev.sh restart` — main isn't
hot-replaced. On restart, enabled configs auto-register their webhook again.
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
#
# send-imessage-test.sh — Verify the outbound leg: desktop → BlueBubbles → iMessage
#
# Sends one real iMessage via the same REST call the Desktop bridge uses
# (`POST /api/v1/message/text`, which BlueBubblesApiClient.sendText wraps) and
# confirms it actually went out.
#
# KEY GOTCHA: with method=apple-script and a NEW conversation, the HTTP request
# often TIMES OUT even though the message is sent. Do NOT treat the timeout as a
# failure — instead poll `POST /api/v1/message/query` and check the message's
# `error` field (0 = sent OK). This script does that for you.
#
# This sends a REAL message, so it has side effects. Target your own number.
#
# Usage:
# ./send-imessage-test.sh <bb_password> <target_e164> [message] [bb_url]
#
# Example (send to your own phone, E.164 with country code):
# ./send-imessage-test.sh 'my-bb-pass' '+15551234567'
#
set -euo pipefail
BB_PASS="${1:?Usage: $0 <bb_password> <target_e164(+countrycode)> [message] [bb_url]}"
TARGET="${2:?Need a target handle in E.164, e.g. +15551234567 (or an Apple ID email)}"
MARKER="lobe-imsg-test-$(date +%s)"
MESSAGE="${3:-[${MARKER}] desktop bridge → BlueBubbles → iMessage outbound check}"
BB_URL="${4:-http://127.0.0.1:1234}"
CHAT_GUID="iMessage;-;${TARGET}"
echo "[send-test] target=${TARGET} marker=${MARKER}"
# 1) Fire the send. apple-script on a new chat may hang the HTTP response, so we
# cap it short and ignore a timeout — step 2 is the source of truth.
python3 - "$BB_PASS" "$BB_URL" "$CHAT_GUID" "$MESSAGE" <<'PY' || true
import json,sys,urllib.request,urllib.parse,uuid
pw,base,guid,msg=sys.argv[1:5]
url=base+"/api/v1/message/text?password="+urllib.parse.quote(pw)
body={"chatGuid":guid,"message":msg,"method":"apple-script","tempGuid":str(uuid.uuid4())}
req=urllib.request.Request(url,data=json.dumps(body).encode("utf-8"),
headers={"Content-Type":"application/json"},method="POST")
try:
r=urllib.request.urlopen(req,timeout=8)
print("[send-test] HTTP",r.status,"(immediate response)")
except urllib.error.HTTPError as e:
print("[send-test] HTTP",e.code,e.read().decode()[:200])
except Exception as e:
print("[send-test] HTTP request returned no body (likely apple-script delay):",type(e).__name__)
PY
# 2) Source of truth: find our marker in the message store and read its error.
echo "[send-test] verifying via message/query (the HTTP timeout above is expected)…"
sleep 3
python3 - "$BB_PASS" "$BB_URL" "$MARKER" <<'PY'
import json,sys,time,urllib.request,urllib.parse
pw,base,marker=sys.argv[1:4]
url=base+"/api/v1/message/query?password="+urllib.parse.quote(pw)
def query():
body={"limit":15,"offset":0,"with":["chats"],"sort":"DESC"}
req=urllib.request.Request(url,data=json.dumps(body).encode(),
headers={"Content-Type":"application/json"},method="POST")
return json.load(urllib.request.urlopen(req,timeout=12)).get("data") or []
hit=None
for _ in range(5):
for m in query():
if marker in (m.get("text") or "") and m.get("isFromMe"):
hit=m; break
if hit: break
time.sleep(2)
if not hit:
print("[send-test] ✗ outbound message not found in BB store — send likely failed")
sys.exit(1)
err=hit.get("error")
if err in (0,None):
print("[send-test] ✓ outbound message sent (fromMe=True, error=%s)"%err)
print("[send-test] → confirm it arrived in the Messages app on the target device")
else:
print("[send-test] ✗ BlueBubbles reported send error=%s"%err)
sys.exit(1)
PY
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
#
# test-imessage-bridge.sh — Regression test for the iMessage Desktop bridge
#
# Drives the Electron main-process `imessageBridge.*` IPC handlers plus the
# local bridge HTTP server and the BlueBubbles server, asserting the full
# connect/configure flow. Use it to regression-test PR work on the iMessage
# channel (BlueBubbles bridge) without clicking through the UI every time.
#
# Prerequisites:
# 1. BlueBubbles server running and reachable (default http://127.0.0.1:1234)
# 2. Electron dev running with CDP — `electron-dev.sh start`
# 3. `agent-browser` on PATH, connected to the same CDP port
#
# Usage:
# ./test-imessage-bridge.sh <bluebubbles_password> [bb_url] [cdp_port]
#
# Example:
# ./test-imessage-bridge.sh 'my-bb-password'
# ./test-imessage-bridge.sh 'my-bb-password' http://127.0.0.1:1234 9222
#
# Notes:
# - The password is passed as an argv, so it is visible in `ps`. This is a
# local dev tool; do not run it on shared machines with a real secret.
# - It uses a unique applicationId per run (imsg-regression-$$) and cleans up
# its own bridge config + BlueBubbles webhook on exit, so it is safe to
# re-run and does not disturb real configs.
set -euo pipefail
BB_PASS="${1:?Usage: $0 <bluebubbles_password> [bb_url] [cdp_port]}"
BB_URL="${2:-http://127.0.0.1:1234}"
CDP_PORT="${3:-9222}"
APP_ID="imsg-regression-$$"
SECRET="regression-secret-$$"
PASS=0
FAIL=0
# ── Output helpers ───────────────────────────────────────────────────
ok() { echo "$1"; PASS=$((PASS + 1)); }
bad() { echo "$1$2"; FAIL=$((FAIL + 1)); }
note() { echo "[imsg-test] $1"; }
# ── BlueBubbles REST helpers ─────────────────────────────────────────
bb_get_webhooks() {
curl -sS -m 8 "${BB_URL}/api/v1/webhook?password=${BB_PASS}"
}
# Delete every webhook whose URL mentions our APP_ID (cleanup is idempotent).
bb_cleanup_webhooks() {
local ids
ids=$(bb_get_webhooks | python3 -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: sys.exit(0)
for w in (d.get("data") or []):
if "'"$APP_ID"'" in (w.get("url") or ""): print(w["id"])
' 2>/dev/null || true)
for id in $ids; do
curl -sS -m 8 -X DELETE "${BB_URL}/api/v1/webhook/${id}?password=${BB_PASS}" >/dev/null 2>&1 || true
done
}
# ── IPC helper (drives the Electron renderer's electronAPI bridge) ───
# Runs a JS snippet that returns a string token; prints the raw token.
# The BlueBubbles password is base64-injected (atob) so special chars in the
# secret never need shell/JS quoting.
ipc_eval() {
local js="$1"
agent-browser --cdp "$CDP_PORT" eval -b "$(printf '%s' "$js" | base64)" 2>/dev/null
}
PASS_B64=$(printf '%s' "$BB_PASS" | base64)
# Emit an inline JS object literal for the bridge config. $1 overrides the
# password expression (defaults to atob of the real password); pass a JS string
# literal like "'wrong'" to test the rejection path.
ipc_config_js() {
local pwexpr="${1:-atob('${PASS_B64}')}"
printf "{applicationId:'%s',blueBubblesServerUrl:'%s',blueBubblesPassword:%s,enabled:true,webhookSecret:'%s'}" \
"$APP_ID" "$BB_URL" "$pwexpr" "$SECRET"
}
# ── Preflight ────────────────────────────────────────────────────────
note "BlueBubbles: ${BB_URL} CDP: ${CDP_PORT} appId: ${APP_ID}"
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' \
"${BB_URL}/api/v1/server/info?password=${BB_PASS}" || echo 000)
if [ "$code" = "200" ]; then ok "BlueBubbles reachable + password valid"; else
bad "BlueBubbles preflight" "HTTP $code (is BlueBubbles running on ${BB_URL}?)"
echo "Aborting — fix BlueBubbles first."; exit 1
fi
if ! curl -sf --max-time 3 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
bad "Electron CDP preflight" "CDP ${CDP_PORT} unreachable — run electron-dev.sh start"
echo "Aborting."; exit 1
fi
ok "Electron CDP reachable"
# Bridge must expose the IPC group (built from this branch's code).
probe=$(ipc_eval "(async()=>{try{var s=await window.electronAPI.invoke('imessageBridge.getStatus',{});return 'OK:'+JSON.stringify(s);}catch(e){return 'ERR:'+(e.message||e);}})()")
case "$probe" in
*OK:*) ok "imessageBridge IPC available" ;;
*) bad "imessageBridge IPC" "got: $probe (is the iMessage Desktop branch checked out?)"; echo "Aborting."; exit 1 ;;
esac
# Start clean: remove any leftover config for this appId + BB webhooks.
ipc_eval "(async()=>{try{await window.electronAPI.invoke('imessageBridge.removeConfig',{applicationId:'${APP_ID}'});}catch(e){}return 'done';})()" >/dev/null
bb_cleanup_webhooks
# ── testConfig: happy path ───────────────────────────────────────────
r=$(ipc_eval "(async()=>{try{var c=$(ipc_config_js);var x=await window.electronAPI.invoke('imessageBridge.testConfig',c);return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
case "$r" in
*OK:*success*true*) ok "testConfig with valid password → success" ;;
*) bad "testConfig (valid)" "got: $r" ;;
esac
# ── testConfig: wrong password rejects ───────────────────────────────
r=$(ipc_eval "(async()=>{try{var c=$(ipc_config_js "'definitely-wrong-password'");var x=await window.electronAPI.invoke('imessageBridge.testConfig',c);return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
case "$r" in
*ERR:*) ok "testConfig with wrong password → rejected" ;;
*) bad "testConfig (wrong password)" "expected rejection, got: $r" ;;
esac
# ── testConfig: unreachable URL rejects ──────────────────────────────
r=$(ipc_eval "(async()=>{try{var x=await window.electronAPI.invoke('imessageBridge.testConfig',{applicationId:'${APP_ID}',blueBubblesServerUrl:'http://127.0.0.1:65530',blueBubblesPassword:atob('${PASS_B64}'),enabled:true,webhookSecret:'${SECRET}'});return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
case "$r" in
*ERR:*) ok "testConfig with unreachable URL → rejected" ;;
*) bad "testConfig (unreachable)" "expected rejection, got: $r" ;;
esac
# ── upsertConfig: FIRST-TIME registration (Bug #1 regression guard) ──
# BlueBubbles' GET /webhook?url=<unregistered> returns HTTP 500. The bridge
# must list ALL webhooks and match client-side, otherwise this first save
# fails. This assertion guards that fix.
r=$(ipc_eval "(async()=>{try{var c=$(ipc_config_js);var x=await window.electronAPI.invoke('imessageBridge.upsertConfig',c);return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
case "$r" in
*OK:*success*true*) ok "upsertConfig first-time save → success (Bug #1 guard)" ;;
*) bad "upsertConfig (first-time)" "got: $r" ;;
esac
# ── getStatus: bridge running + config persisted ─────────────────────
# Return a quote-free token so grep isn't tripped up by agent-browser's
# JSON-string escaping of the eval result.
r=$(ipc_eval "(async()=>{var s=await window.electronAPI.invoke('imessageBridge.getStatus',{});var c=(s.configs||[]).find(function(x){return x.applicationId==='${APP_ID}';});return 'RUN='+(s.running?'Y':'N')+' CFG='+(c?'Y':'N')+' PW='+((c&&c.blueBubblesPasswordSet)?'Y':'N');})()")
echo "$r" | grep -q 'RUN=Y' && ok "bridge running" || bad "bridge running" "got: $r"
echo "$r" | grep -q 'CFG=Y' && ok "config persisted" || bad "config persisted" "got: $r"
echo "$r" | grep -q 'PW=Y' && ok "password stored (redacted in status)" || bad "password stored" "got: $r"
# ── BlueBubbles webhook actually registered ──────────────────────────
if bb_get_webhooks | grep -q "${APP_ID}"; then
ok "BlueBubbles webhook registered for appId"
else
bad "BlueBubbles webhook" "no webhook URL containing ${APP_ID}"
fi
# ── Local bridge HTTP server: secret enforcement ─────────────────────
BRIDGE_URL=$(ipc_eval "(async()=>{var s=await window.electronAPI.invoke('imessageBridge.getStatus',{});return s.serverUrl||'';})()" | tr -d '"')
if [ -n "$BRIDGE_URL" ]; then
# wrong secret → 401
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' \
"${BRIDGE_URL}/webhooks/bluebubbles/${APP_ID}?secret=WRONG" \
-d '{"type":"new-message","data":{"guid":"x"}}' || echo 000)
[ "$code" = "401" ] && ok "local bridge rejects wrong secret (401)" || bad "local bridge wrong secret" "expected 401, got $code"
# right secret → passes auth (reaches forward; without a bound cloud bot it
# returns 5xx — that's fine, we're only asserting auth + routing here)
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' \
"${BRIDGE_URL}/webhooks/bluebubbles/${APP_ID}?secret=${SECRET}" \
-d '{"type":"new-message","data":{"guid":"x","text":"hi"}}' || echo 000)
[ "$code" != "401" ] && ok "local bridge accepts valid secret (HTTP $code, past auth)" || bad "local bridge valid secret" "got 401 with correct secret"
else
bad "local bridge URL" "getStatus returned no serverUrl"
fi
# ── Cleanup ──────────────────────────────────────────────────────────
ipc_eval "(async()=>{try{await window.electronAPI.invoke('imessageBridge.removeConfig',{applicationId:'${APP_ID}'});await window.electronAPI.invoke('imessageBridge.stop',{});}catch(e){}return 'cleaned';})()" >/dev/null
bb_cleanup_webhooks
note "cleaned up config + BlueBubbles webhook for ${APP_ID}"
# ── Summary ──────────────────────────────────────────────────────────
echo ""
echo "[imsg-test] PASS=${PASS} FAIL=${FAIL}"
[ "$FAIL" -eq 0 ] || exit 1
@@ -2,7 +2,7 @@
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
See [osascript-common.md](./osascript-common.md) for shared patterns.
See [osascript-common.md](../osascript-common.md) for shared patterns.
## Activate & Navigate
@@ -56,6 +56,6 @@ screencapture /tmp/lark-bot-response.png
## Script
```bash
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
```
@@ -80,5 +80,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `QQ` | **Process name:** `QQ`
See [osascript-common.md](./osascript-common.md) for shared patterns.
See [osascript-common.md](../osascript-common.md) for shared patterns.
## Activate & Navigate
@@ -57,6 +57,6 @@ screencapture /tmp/qq-bot-response.png
## Script
```bash
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
```
@@ -72,5 +72,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `Slack` | **Process name:** `Slack`
See [osascript-common.md](./osascript-common.md) for shared patterns.
See [osascript-common.md](../osascript-common.md) for shared patterns.
## Activate & Navigate
@@ -68,6 +68,6 @@ screencapture /tmp/slack-bot-response.png
## Script
```bash
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
```
@@ -60,5 +60,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `Telegram` | **Process name:** `Telegram`
See [osascript-common.md](./osascript-common.md) for shared patterns.
See [osascript-common.md](../osascript-common.md) for shared patterns.
## Activate & Navigate
@@ -75,6 +75,6 @@ curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | j
## Script
```bash
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
```
@@ -75,5 +75,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
See [osascript-common.md](./osascript-common.md) for shared patterns.
See [osascript-common.md](../osascript-common.md) for shared patterns.
## Activate & Navigate
@@ -76,6 +76,6 @@ screencapture /tmp/wechat-bot-response.png
## Script
```bash
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
```
@@ -81,5 +81,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"