diff --git a/.agents/skills/local-testing/SKILL.md b/.agents/skills/local-testing/SKILL.md index 9affb73d43..3524aa4680 100644 --- a/.agents/skills/local-testing/SKILL.md +++ b/.agents/skills/local-testing/SKILL.md @@ -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//` 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 '' [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): `.mp4` (video) + `/` ### 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.). diff --git a/.agents/skills/local-testing/scripts/capture-app-window.sh b/.agents/skills/local-testing/bot/capture-app-window.sh similarity index 100% rename from .agents/skills/local-testing/scripts/capture-app-window.sh rename to .agents/skills/local-testing/bot/capture-app-window.sh diff --git a/.agents/skills/local-testing/references/discord.md b/.agents/skills/local-testing/bot/discord/index.md similarity index 88% rename from .agents/skills/local-testing/references/discord.md rename to .agents/skills/local-testing/bot/discord/index.md index d10d7df93f..8dd3789f40 100644 --- a/.agents/skills/local-testing/references/discord.md +++ b/.agents/skills/local-testing/bot/discord/index.md @@ -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 ``` diff --git a/.agents/skills/local-testing/scripts/test-discord-bot.sh b/.agents/skills/local-testing/bot/discord/test-discord-bot.sh similarity index 96% rename from .agents/skills/local-testing/scripts/test-discord-bot.sh rename to .agents/skills/local-testing/bot/discord/test-discord-bot.sh index 44b6fed04e..6eeff16408 100755 --- a/.agents/skills/local-testing/scripts/test-discord-bot.sh +++ b/.agents/skills/local-testing/bot/discord/test-discord-bot.sh @@ -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" diff --git a/.agents/skills/local-testing/bot/imessage/index.md b/.agents/skills/local-testing/bot/imessage/index.md new file mode 100644 index 0000000000..481e61a557 --- /dev/null +++ b/.agents/skills/local-testing/bot/imessage/index.md @@ -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:/webhooks/bluebubbles/?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=" # 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 '' [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//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=" # 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/` 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 '' '+' # 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;-;+`) 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 +`/api/agent/webhooks/imessage/?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=" \ + -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=` 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. diff --git a/.agents/skills/local-testing/bot/imessage/send-imessage-test.sh b/.agents/skills/local-testing/bot/imessage/send-imessage-test.sh new file mode 100755 index 0000000000..10b73e5b26 --- /dev/null +++ b/.agents/skills/local-testing/bot/imessage/send-imessage-test.sh @@ -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 [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 [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 diff --git a/.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh b/.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh new file mode 100755 index 0000000000..bc05db8f1d --- /dev/null +++ b/.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh @@ -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 [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 [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= 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 diff --git a/.agents/skills/local-testing/references/lark.md b/.agents/skills/local-testing/bot/lark/index.md similarity index 82% rename from .agents/skills/local-testing/references/lark.md rename to .agents/skills/local-testing/bot/lark/index.md index 69183a7db8..9ec25caf9e 100644 --- a/.agents/skills/local-testing/references/lark.md +++ b/.agents/skills/local-testing/bot/lark/index.md @@ -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 ``` diff --git a/.agents/skills/local-testing/scripts/test-lark-bot.sh b/.agents/skills/local-testing/bot/lark/test-lark-bot.sh similarity index 97% rename from .agents/skills/local-testing/scripts/test-lark-bot.sh rename to .agents/skills/local-testing/bot/lark/test-lark-bot.sh index e0494b0437..a62903cdd1 100755 --- a/.agents/skills/local-testing/scripts/test-lark-bot.sh +++ b/.agents/skills/local-testing/bot/lark/test-lark-bot.sh @@ -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" diff --git a/.agents/skills/local-testing/references/osascript-common.md b/.agents/skills/local-testing/bot/osascript-common.md similarity index 100% rename from .agents/skills/local-testing/references/osascript-common.md rename to .agents/skills/local-testing/bot/osascript-common.md diff --git a/.agents/skills/local-testing/references/qq.md b/.agents/skills/local-testing/bot/qq/index.md similarity index 81% rename from .agents/skills/local-testing/references/qq.md rename to .agents/skills/local-testing/bot/qq/index.md index 826760e928..e5c3815b72 100644 --- a/.agents/skills/local-testing/references/qq.md +++ b/.agents/skills/local-testing/bot/qq/index.md @@ -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 ``` diff --git a/.agents/skills/local-testing/scripts/test-qq-bot.sh b/.agents/skills/local-testing/bot/qq/test-qq-bot.sh similarity index 97% rename from .agents/skills/local-testing/scripts/test-qq-bot.sh rename to .agents/skills/local-testing/bot/qq/test-qq-bot.sh index 0d8cabcac2..58896a52c8 100755 --- a/.agents/skills/local-testing/scripts/test-qq-bot.sh +++ b/.agents/skills/local-testing/bot/qq/test-qq-bot.sh @@ -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" diff --git a/.agents/skills/local-testing/references/slack.md b/.agents/skills/local-testing/bot/slack/index.md similarity index 83% rename from .agents/skills/local-testing/references/slack.md rename to .agents/skills/local-testing/bot/slack/index.md index 0a238b5064..929b6bbdc5 100644 --- a/.agents/skills/local-testing/references/slack.md +++ b/.agents/skills/local-testing/bot/slack/index.md @@ -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 ``` diff --git a/.agents/skills/local-testing/scripts/test-slack-bot.sh b/.agents/skills/local-testing/bot/slack/test-slack-bot.sh similarity index 96% rename from .agents/skills/local-testing/scripts/test-slack-bot.sh rename to .agents/skills/local-testing/bot/slack/test-slack-bot.sh index e26a19356e..8841def381 100755 --- a/.agents/skills/local-testing/scripts/test-slack-bot.sh +++ b/.agents/skills/local-testing/bot/slack/test-slack-bot.sh @@ -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" diff --git a/.agents/skills/local-testing/references/telegram.md b/.agents/skills/local-testing/bot/telegram/index.md similarity index 85% rename from .agents/skills/local-testing/references/telegram.md rename to .agents/skills/local-testing/bot/telegram/index.md index f93f596a1a..9c5435141b 100644 --- a/.agents/skills/local-testing/references/telegram.md +++ b/.agents/skills/local-testing/bot/telegram/index.md @@ -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 ``` diff --git a/.agents/skills/local-testing/scripts/test-telegram-bot.sh b/.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh similarity index 97% rename from .agents/skills/local-testing/scripts/test-telegram-bot.sh rename to .agents/skills/local-testing/bot/telegram/test-telegram-bot.sh index 9c4d58c705..02e5a059c4 100755 --- a/.agents/skills/local-testing/scripts/test-telegram-bot.sh +++ b/.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh @@ -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" diff --git a/.agents/skills/local-testing/references/wechat.md b/.agents/skills/local-testing/bot/wechat/index.md similarity index 86% rename from .agents/skills/local-testing/references/wechat.md rename to .agents/skills/local-testing/bot/wechat/index.md index 2cc8d23f77..d5a88969f7 100644 --- a/.agents/skills/local-testing/references/wechat.md +++ b/.agents/skills/local-testing/bot/wechat/index.md @@ -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 ``` diff --git a/.agents/skills/local-testing/scripts/test-wechat-bot.sh b/.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh similarity index 97% rename from .agents/skills/local-testing/scripts/test-wechat-bot.sh rename to .agents/skills/local-testing/bot/wechat/test-wechat-bot.sh index 2a3d961093..44d322fddf 100755 --- a/.agents/skills/local-testing/scripts/test-wechat-bot.sh +++ b/.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh @@ -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" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bfcacdc666..3bab33c4c6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,6 +54,7 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/utils": "^4.0.0", + "@lobechat/chat-adapter-imessage": "workspace:*", "@lobechat/desktop-bridge": "workspace:*", "@lobechat/device-gateway-client": "workspace:*", "@lobechat/electron-client-ipc": "workspace:*", diff --git a/apps/desktop/pnpm-workspace.yaml b/apps/desktop/pnpm-workspace.yaml index e0c5fb9b34..1c203f8076 100644 --- a/apps/desktop/pnpm-workspace.yaml +++ b/apps/desktop/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - '../cli' - '../../packages/agent-gateway-client' + - '../../packages/chat-adapter-imessage' - '../../packages/heterogeneous-agents' - '../../packages/const' - '../../packages/electron-server-ipc' diff --git a/apps/desktop/src/main/const/store.ts b/apps/desktop/src/main/const/store.ts index bc42268de6..f6b9443b02 100644 --- a/apps/desktop/src/main/const/store.ts +++ b/apps/desktop/src/main/const/store.ts @@ -34,6 +34,7 @@ export const STORE_DEFAULTS: ElectronMainStore = { gatewayDeviceName: '', gatewayEnabled: true, gatewayUrl: 'https://device-gateway.lobehub.com', + imessageBridgeConfigs: [], locale: 'auto', localFileWorkspaceRoots: [], networkProxy: defaultProxySettings, diff --git a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts index fe831dbde8..949d19002b 100644 --- a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts +++ b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts @@ -7,6 +7,7 @@ import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client'; import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc'; import GatewayConnectionService from '@/services/gatewayConnectionSrv'; +import ImessageBridgeService from '@/services/imessageBridgeSrv'; import HeterogeneousAgentCtr from './HeterogeneousAgentCtr'; import { ControllerModule, IpcMethod } from './index'; @@ -54,6 +55,9 @@ interface PlatformTaskEntry { topicId: string; } +type ToolCallHandler = () => Promise; +type ToolCallHandlerMap = Record; + /** * GatewayConnectionCtr * @@ -86,6 +90,10 @@ export default class GatewayConnectionCtr extends ControllerModule { return this.app.getController(ShellCommandCtr); } + private get imessageBridgeSrv() { + return this.app.getService(ImessageBridgeService); + } + private get heterogeneousAgentCtr() { return this.app.getController(HeterogeneousAgentCtr); } @@ -104,6 +112,11 @@ export default class GatewayConnectionCtr extends ControllerModule { // Wire up tool call handler srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args)); + // Wire up message API handler + srv.setMessageApiHandler((platform, apiName, payload) => + this.executeMessageApi(platform, apiName, payload), + ); + // Wire up agent run handler srv.setAgentRunHandler((request) => this.executeAgentRun(request)); @@ -203,6 +216,37 @@ export default class GatewayConnectionCtr extends ControllerModule { // ─── Tool Call Routing ─── private async executeToolCall(apiName: string, args: any): Promise { + const methodMap = { + ...this.getLocalFileToolHandlers(args), + ...this.getShellCommandToolHandlers(args), + ...this.getPlatformAgentToolHandlers(args), + } satisfies ToolCallHandlerMap; + + const handler = methodMap[apiName]; + if (!handler) { + throw new Error( + `Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`, + ); + } + + return handler(); + } + + private async executeMessageApi( + platform: string, + apiName: string, + payload: Record, + ): Promise { + if (platform === 'imessage') { + return this.imessageBridgeSrv.handleGatewayMessageApi(apiName, payload); + } + + throw new Error( + `Message API "${platform}/${apiName}" is not available on this device. It may not be supported in the current desktop version.`, + ); + } + + private getLocalFileToolHandlers(args: any): ToolCallHandlerMap { const editFile = () => this.localFileCtr.handleEditFile(args); const globFiles = () => this.localFileCtr.handleGlobFiles(args); const listFiles = () => this.localFileCtr.listLocalFiles(args); @@ -211,7 +255,7 @@ export default class GatewayConnectionCtr extends ControllerModule { const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args); const writeFile = () => this.localFileCtr.handleWriteFile(args); - const methodMap: Record Promise> = { + return { editFile, globFiles, grepContent: () => this.localFileCtr.handleGrepContent(args), @@ -221,10 +265,6 @@ export default class GatewayConnectionCtr extends ControllerModule { searchFiles, writeFile, - getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args), - killCommand: () => this.shellCommandCtr.handleKillCommand(args), - runCommand: () => this.shellCommandCtr.handleRunCommand(args), - // Legacy aliases — keep these so older Gateway versions sending the long // names continue to route correctly. `renameLocalFile` is also kept even // though the new surface drops rename (it's now handled by `moveFiles`). @@ -236,7 +276,19 @@ export default class GatewayConnectionCtr extends ControllerModule { renameLocalFile: () => this.localFileCtr.handleRenameFile(args), searchLocalFiles: searchFiles, writeLocalFile: writeFile, + }; + } + private getShellCommandToolHandlers(args: any): ToolCallHandlerMap { + return { + getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args), + killCommand: () => this.shellCommandCtr.handleKillCommand(args), + runCommand: () => this.shellCommandCtr.handleRunCommand(args), + }; + } + + private getPlatformAgentToolHandlers(args: any): ToolCallHandlerMap { + return { // Platform agent capability probing checkPlatformCapability: () => this.checkPlatformCapability(args), getAgentProfile: () => this.getAgentProfile(args), @@ -245,15 +297,6 @@ export default class GatewayConnectionCtr extends ControllerModule { cancelHeteroTask: () => this.cancelHeteroTask(args), runHeteroTask: () => this.runHeteroTask(args), }; - - const handler = methodMap[apiName]; - if (!handler) { - throw new Error( - `Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`, - ); - } - - return handler(); } // ─── Platform Capability Probing ─── diff --git a/apps/desktop/src/main/controllers/ImessageBridgeCtr.ts b/apps/desktop/src/main/controllers/ImessageBridgeCtr.ts new file mode 100644 index 0000000000..73a7a7a648 --- /dev/null +++ b/apps/desktop/src/main/controllers/ImessageBridgeCtr.ts @@ -0,0 +1,68 @@ +import type { + ImessageBridgeConfig, + ImessageBridgeSaveResult, + ImessageBridgeStatus, +} from '@lobechat/electron-client-ipc'; + +import ImessageBridgeService from '@/services/imessageBridgeSrv'; +import { createLogger } from '@/utils/logger'; + +import { ControllerModule, IpcMethod } from './index'; +import RemoteServerConfigCtr from './RemoteServerConfigCtr'; + +const logger = createLogger('controllers:ImessageBridgeCtr'); + +export default class ImessageBridgeCtr extends ControllerModule { + static override readonly groupName = 'imessageBridge'; + + private get service() { + return this.app.getService(ImessageBridgeService); + } + + private get remoteServerConfigCtr() { + return this.app.getController(RemoteServerConfigCtr); + } + + afterAppReady() { + this.service.setRemoteServerProvider({ + getAccessToken: () => this.remoteServerConfigCtr.getAccessToken(), + getServerUrl: async () => (await this.remoteServerConfigCtr.getRemoteServerUrl()) ?? null, + }); + + this.service.start().catch((error) => { + // The user can fix BlueBubbles or remote-server settings from the UI and start again. + logger.warn('Failed to auto-start iMessage bridge:', error); + }); + } + + @IpcMethod() + async getStatus(): Promise { + return this.service.getStatus(); + } + + @IpcMethod() + async upsertConfig(config: ImessageBridgeConfig): Promise { + const saved = await this.service.upsertConfig(config); + return { config: saved, success: true }; + } + + @IpcMethod() + async removeConfig(params: { applicationId: string }): Promise<{ success: boolean }> { + return this.service.removeConfig(params.applicationId); + } + + @IpcMethod() + async start(): Promise { + return this.service.start(); + } + + @IpcMethod() + async stop(): Promise<{ success: boolean }> { + return this.service.stop(); + } + + @IpcMethod() + async testConfig(config: ImessageBridgeConfig): Promise<{ success: boolean }> { + return this.service.testConfig(config); + } +} diff --git a/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts index a5ba76d490..eb223befba 100644 --- a/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { App } from '@/core/App'; import GatewayConnectionService from '@/services/gatewayConnectionSrv'; +import ImessageBridgeService from '@/services/imessageBridgeSrv'; import GatewayConnectionCtr from '../GatewayConnectionCtr'; import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr'; @@ -34,6 +35,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => { }); sendToolCallResponse = vi.fn(); + sendMessageApiResponse = vi.fn(); sendAgentRunAck = vi.fn(); constructor(options: any) { @@ -67,6 +69,19 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => { }); } + simulateMessageApiRequest( + platform: string, + apiName: string, + payload: Record, + requestId = 'msg-req-1', + ) { + this.emit('message_api_request', { + api: { apiName, payload, platform }, + requestId, + type: 'message_api_request', + }); + } + simulateAuthExpired() { this.emit('auth_expired'); } @@ -160,6 +175,10 @@ vi.mock('@lobechat/device-gateway-client', () => ({ GatewayClient: MockGatewayClient, })); +vi.mock('@/services/imessageBridgeSrv', () => ({ + default: class ImessageBridgeService {}, +})); + vi.mock('execa', () => ({ execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), })); @@ -204,6 +223,10 @@ const mockHeterogeneousAgentCtr = { startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }), } as unknown as HeterogeneousAgentCtr; +const mockImessageBridgeSrv = { + handleGatewayMessageApi: vi.fn().mockResolvedValue({ ok: true }), +} as unknown as ImessageBridgeService; + const mockRemoteServerConfigCtr = { getAccessToken: vi.fn().mockResolvedValue('mock-access-token'), getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'), @@ -226,6 +249,7 @@ const mockApp = { }), getService: vi.fn((Cls) => { if (Cls === GatewayConnectionService) return mockGatewayConnectionSrv; + if (Cls === ImessageBridgeService) return mockImessageBridgeSrv; return null; }), storeManager: { get: mockStoreGet, set: mockStoreSet }, @@ -582,6 +606,66 @@ describe('GatewayConnectionCtr', () => { }); }); + describe('message API routing', () => { + async function connectAndOpen() { + ctr.afterAppReady(); + await vi.advanceTimersByTimeAsync(0); + const client = MockGatewayClient.lastInstance!; + client.simulateConnected(); + return client; + } + + it('should route iMessage message API requests to the iMessage bridge service', async () => { + vi.mocked(mockImessageBridgeSrv.handleGatewayMessageApi).mockResolvedValueOnce({ + guid: 'sent-1', + }); + const client = await connectAndOpen(); + + client.simulateMessageApiRequest( + 'imessage', + 'sendText', + { + applicationId: 'home-mac-mini', + chatGuid: 'iMessage;-;chat-1', + message: 'hello', + }, + 'msg-req-42', + ); + await vi.advanceTimersByTimeAsync(0); + + expect(mockImessageBridgeSrv.handleGatewayMessageApi).toHaveBeenCalledWith('sendText', { + applicationId: 'home-mac-mini', + chatGuid: 'iMessage;-;chat-1', + message: 'hello', + }); + expect(client.sendMessageApiResponse).toHaveBeenCalledWith({ + requestId: 'msg-req-42', + result: { + content: JSON.stringify({ guid: 'sent-1' }), + success: true, + }, + }); + }); + + it('should send message_api_response with error for unsupported platforms', async () => { + const client = await connectAndOpen(); + + client.simulateMessageApiRequest('unsupported', 'sendText', {}, 'msg-req-err'); + await vi.advanceTimersByTimeAsync(0); + + const errorMsg = + 'Message API "unsupported/sendText" is not available on this device. It may not be supported in the current desktop version.'; + expect(client.sendMessageApiResponse).toHaveBeenCalledWith({ + requestId: 'msg-req-err', + result: { + content: errorMsg, + error: errorMsg, + success: false, + }, + }); + }); + }); + // ─── Auth Expired ─── describe('auth_expired handling', () => { diff --git a/apps/desktop/src/main/controllers/registry.ts b/apps/desktop/src/main/controllers/registry.ts index c344a9a7a2..b32ba30e62 100644 --- a/apps/desktop/src/main/controllers/registry.ts +++ b/apps/desktop/src/main/controllers/registry.ts @@ -7,6 +7,7 @@ import DevtoolsCtr from './DevtoolsCtr'; import GatewayConnectionCtr from './GatewayConnectionCtr'; import GitCtr from './GitCtr'; import HeterogeneousAgentCtr from './HeterogeneousAgentCtr'; +import ImessageBridgeCtr from './ImessageBridgeCtr'; import LocalFileCtr from './LocalFileCtr'; import McpCtr from './McpCtr'; import McpInstallCtr from './McpInstallCtr'; @@ -33,6 +34,7 @@ export const controllerIpcConstructors = [ GatewayConnectionCtr, GitCtr, LocalFileCtr, + ImessageBridgeCtr, McpCtr, McpInstallCtr, MenuController, diff --git a/apps/desktop/src/main/services/__tests__/imessageBridgeSrv.test.ts b/apps/desktop/src/main/services/__tests__/imessageBridgeSrv.test.ts new file mode 100644 index 0000000000..0cdcc79805 --- /dev/null +++ b/apps/desktop/src/main/services/__tests__/imessageBridgeSrv.test.ts @@ -0,0 +1,222 @@ +import { request } from 'node:http'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { App } from '@/core/App'; + +import ImessageBridgeService from '../imessageBridgeSrv'; + +const { MockBlueBubblesApiClient, getPortMock } = vi.hoisted(() => { + class _MockBlueBubblesApiClient { + static instances: _MockBlueBubblesApiClient[] = []; + + getMessage = vi.fn().mockResolvedValue({ + chats: [{ guid: 'iMessage;-;chat-1' }], + guid: 'msg-1', + text: 'hello', + }); + listWebhooks = vi.fn().mockResolvedValue([]); + ping = vi.fn().mockResolvedValue(undefined); + registerWebhook = vi.fn().mockResolvedValue({ events: ['new-message'], id: 1 }); + sendText = vi.fn().mockResolvedValue({ guid: 'sent-1', text: 'hello' }); + + constructor(public options: unknown) { + _MockBlueBubblesApiClient.instances.push(this); + } + } + + return { + MockBlueBubblesApiClient: _MockBlueBubblesApiClient, + getPortMock: vi.fn().mockResolvedValue(43_210), + }; +}); + +vi.mock('@lobechat/chat-adapter-imessage', () => ({ + BlueBubblesApiClient: MockBlueBubblesApiClient, +})); + +vi.mock('get-port-please', () => ({ + getPort: getPortMock, +})); + +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +const config = { + applicationId: 'home-mac-mini', + blueBubblesPassword: 'local-password', + blueBubblesServerUrl: 'http://127.0.0.1:1234', + enabled: true, + webhookSecret: 'shared-secret', +}; + +function createService() { + const store = new Map([['imessageBridgeConfigs', []]]); + const app = { + storeManager: { + get: vi.fn((key: string, fallback?: unknown) => store.get(key) ?? fallback), + set: vi.fn((key: string, value: unknown) => store.set(key, value)), + }, + } as unknown as App; + + const service = new ImessageBridgeService(app); + service.setRemoteServerProvider({ + getAccessToken: vi.fn().mockResolvedValue('access-token'), + getServerUrl: vi.fn().mockResolvedValue('https://lobehub.example.com'), + }); + + return { app, service, store }; +} + +function postLocal(path: string, body: unknown): Promise<{ body: string; status: number }> { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const req = request( + { + headers: { + 'Content-Length': Buffer.byteLength(payload), + 'Content-Type': 'application/json', + }, + hostname: '127.0.0.1', + method: 'POST', + path, + port: 43_210, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + res.on('end', () => + resolve({ + body: Buffer.concat(chunks).toString('utf8'), + status: res.statusCode ?? 0, + }), + ); + }, + ); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +describe('ImessageBridgeService', () => { + let fetchSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + MockBlueBubblesApiClient.instances = []; + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok', { status: 200 })); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('stores local BlueBubbles credentials and registers a loopback webhook', async () => { + const { service, store } = createService(); + + const saved = await service.upsertConfig(config); + + expect(saved).toMatchObject({ + applicationId: 'home-mac-mini', + blueBubblesPasswordSet: true, + blueBubblesServerUrl: 'http://127.0.0.1:1234', + enabled: true, + }); + expect(store.get('imessageBridgeConfigs')).toEqual([config]); + expect(MockBlueBubblesApiClient.instances.at(-1)?.registerWebhook).toHaveBeenCalledWith( + 'http://127.0.0.1:43210/webhooks/bluebubbles/home-mac-mini?secret=shared-secret', + ['new-message'], + ); + + await service.stop(); + }); + + it('keeps the saved BlueBubbles password when updating bridge metadata', async () => { + const { service, store } = createService(); + await service.upsertConfig(config); + + await service.upsertConfig({ + applicationId: 'home-mac-mini', + blueBubblesServerUrl: 'http://127.0.0.1:5678', + enabled: true, + webhookSecret: 'new-secret', + }); + + expect(store.get('imessageBridgeConfigs')).toEqual([ + { + applicationId: 'home-mac-mini', + blueBubblesPassword: 'local-password', + blueBubblesServerUrl: 'http://127.0.0.1:5678', + enabled: true, + webhookSecret: 'new-secret', + }, + ]); + + await service.stop(); + }); + + it('executes outbound iMessage sends from device-gateway message API calls', async () => { + const { service } = createService(); + await service.upsertConfig(config); + + const result = await service.handleGatewayMessageApi('sendText', { + applicationId: 'home-mac-mini', + chatGuid: 'iMessage;-;chat-1', + message: 'hello', + }); + + expect(result).toEqual({ guid: 'sent-1', text: 'hello' }); + expect(MockBlueBubblesApiClient.instances.at(-1)?.sendText).toHaveBeenCalledWith( + 'iMessage;-;chat-1', + 'hello', + undefined, + ); + + await service.stop(); + }); + + it('receives BlueBubbles webhook locally and forwards the enriched event to LobeHub', async () => { + const { service } = createService(); + await service.upsertConfig(config); + + const response = await postLocal('/webhooks/bluebubbles/home-mac-mini?secret=shared-secret', { + data: { guid: 'msg-1' }, + type: 'new-message', + }); + + expect(response.status).toBe(200); + expect(String(fetchSpy.mock.calls[0][0])).toBe( + 'https://lobehub.example.com/api/agent/webhooks/imessage/home-mac-mini?secret=shared-secret', + ); + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ + headers: { + 'Authorization': 'Bearer access-token', + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const forwarded = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(forwarded.data.chats[0].guid).toBe('iMessage;-;chat-1'); + + await service.stop(); + }); + + it('stops the loopback server when the last enabled config is disabled', async () => { + const { service } = createService(); + await service.upsertConfig(config); + expect(service.getStatus().running).toBe(true); + + await service.upsertConfig({ ...config, enabled: false }); + + const status = service.getStatus(); + expect(status.running).toBe(false); + expect(status.configs[0]).toMatchObject({ applicationId: 'home-mac-mini', enabled: false }); + }); +}); diff --git a/apps/desktop/src/main/services/gatewayConnectionSrv.ts b/apps/desktop/src/main/services/gatewayConnectionSrv.ts index 7416d0a990..99c3eeb669 100644 --- a/apps/desktop/src/main/services/gatewayConnectionSrv.ts +++ b/apps/desktop/src/main/services/gatewayConnectionSrv.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import type { AgentRunRequestMessage, + MessageApiRequestMessage, SystemInfoRequestMessage, ToolCallRequestMessage, } from '@lobechat/device-gateway-client'; @@ -22,6 +23,10 @@ interface ToolCallHandler { (apiName: string, args: any): Promise; } +interface MessageApiHandler { + (platform: string, apiName: string, payload: Record): Promise; +} + interface AgentRunHandler { (request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>; } @@ -41,6 +46,7 @@ export default class GatewayConnectionService extends ServiceModule { private tokenProvider: (() => Promise) | null = null; private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null; private toolCallHandler: ToolCallHandler | null = null; + private messageApiHandler: MessageApiHandler | null = null; private agentRunHandler: AgentRunHandler | null = null; // ─── Configuration ─── @@ -66,6 +72,10 @@ export default class GatewayConnectionService extends ServiceModule { this.toolCallHandler = handler; } + setMessageApiHandler(handler: MessageApiHandler) { + this.messageApiHandler = handler; + } + setAgentRunHandler(handler: AgentRunHandler) { this.agentRunHandler = handler; } @@ -185,6 +195,10 @@ export default class GatewayConnectionService extends ServiceModule { this.handleToolCallRequest(request, client); }); + client.on('message_api_request', (request) => { + this.handleMessageApiRequest(request, client); + }); + client.on('system_info_request', (request) => { this.handleSystemInfoRequest(client, request); }); @@ -319,6 +333,50 @@ export default class GatewayConnectionService extends ServiceModule { } }; + // ─── Message API Routing ─── + + private handleMessageApiRequest = async ( + request: MessageApiRequestMessage, + client: GatewayClient, + ) => { + const { requestId, api } = request; + const { apiName, payload, platform } = api; + + logger.info( + `Received message API request: platform=${platform}, apiName=${apiName}, requestId=${requestId}`, + ); + + try { + if (!this.messageApiHandler) { + throw new Error('No message API handler configured'); + } + + const result = await this.messageApiHandler(platform, apiName, payload); + + client.sendMessageApiResponse({ + requestId, + result: { + content: typeof result === 'string' ? result : JSON.stringify(result), + success: true, + }, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error( + `Message API request failed: platform=${platform}, apiName=${apiName}, error=${errorMsg}`, + ); + + client.sendMessageApiResponse({ + requestId, + result: { + content: errorMsg, + error: errorMsg, + success: false, + }, + }); + } + }; + // ─── Power Save Blocker ─── /** diff --git a/apps/desktop/src/main/services/imessageBridgeSrv.ts b/apps/desktop/src/main/services/imessageBridgeSrv.ts new file mode 100644 index 0000000000..0daee5b2d2 --- /dev/null +++ b/apps/desktop/src/main/services/imessageBridgeSrv.ts @@ -0,0 +1,408 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; + +import { + BlueBubblesApiClient, + type BlueBubblesMessage, + type BlueBubblesOutboundAttachment, + type BlueBubblesSendOptions, + type BlueBubblesWebhookEvent, +} from '@lobechat/chat-adapter-imessage'; +import type { + ImessageBridgeConfig, + ImessageBridgePublicConfig, + ImessageBridgeStatus, +} from '@lobechat/electron-client-ipc'; +import { getPort } from 'get-port-please'; + +import { createLogger } from '@/utils/logger'; + +import { ServiceModule } from './index'; + +const logger = createLogger('services:ImessageBridgeSrv'); + +const STORE_KEY = 'imessageBridgeConfigs'; +const LOCAL_HOST = '127.0.0.1'; +const MAX_WEBHOOK_BYTES = 25 * 1024 * 1024; + +interface RemoteServerProvider { + getAccessToken: () => Promise; + getServerUrl: () => Promise; +} + +type StoredImessageBridgeConfig = ImessageBridgeConfig & { blueBubblesPassword: string }; + +interface ChatMessagesOptions { + after?: number | string; + before?: number | string; + limit?: number; + offset?: number; + sort?: 'ASC' | 'DESC'; + withParts?: string[]; +} + +function toPublicConfig(config: StoredImessageBridgeConfig): ImessageBridgePublicConfig { + const { blueBubblesPassword, ...rest } = config; + return { + ...rest, + blueBubblesPasswordSet: Boolean(blueBubblesPassword), + }; +} + +function assertString(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${field} is required`); + } + return value.trim(); +} + +export default class ImessageBridgeService extends ServiceModule { + private httpServer: Server | null = null; + private remoteServerProvider: RemoteServerProvider | null = null; + private serverPort = 0; + + setRemoteServerProvider(provider: RemoteServerProvider) { + this.remoteServerProvider = provider; + } + + getConfigs(): ImessageBridgePublicConfig[] { + return this.readConfigs().map(toPublicConfig); + } + + getStatus(): ImessageBridgeStatus { + return { + configs: this.getConfigs(), + running: Boolean(this.httpServer), + serverUrl: this.httpServer ? this.getLocalServerUrl() : undefined, + }; + } + + async upsertConfig(config: ImessageBridgeConfig): Promise { + const configs = this.readConfigs(); + const index = configs.findIndex((item) => item.applicationId === config.applicationId?.trim()); + const normalized = this.normalizeConfig(config, index >= 0 ? configs[index] : undefined); + if (index >= 0) { + configs[index] = normalized; + } else { + configs.push(normalized); + } + + this.writeConfigs(configs); + + if (normalized.enabled) { + await this.ensureServer(); + await this.registerWebhook(normalized); + } else if (configs.every((item) => !item.enabled)) { + // Disabling the last enabled config must tear the loopback server down, + // otherwise getStatus() keeps reporting running:true (mirrors removeConfig). + await this.stop(); + } + + return toPublicConfig(normalized); + } + + async removeConfig(applicationId: string): Promise<{ success: boolean }> { + const id = applicationId.trim(); + this.writeConfigs(this.readConfigs().filter((config) => config.applicationId !== id)); + if (this.readConfigs().every((config) => !config.enabled)) { + await this.stop(); + } + return { success: true }; + } + + async start(): Promise { + const enabled = this.readConfigs().filter((config) => config.enabled); + if (enabled.length === 0) return this.getStatus(); + + await this.ensureServer(); + await Promise.all(enabled.map((config) => this.registerWebhook(config))); + return this.getStatus(); + } + + async stop(): Promise<{ success: boolean }> { + if (!this.httpServer) return { success: true }; + + await new Promise((resolve, reject) => { + this.httpServer?.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + + this.httpServer = null; + this.serverPort = 0; + return { success: true }; + } + + async testConfig(config: ImessageBridgeConfig): Promise<{ success: boolean }> { + const existing = this.readConfigs().find( + (item) => item.applicationId === config.applicationId?.trim(), + ); + await this.createApiClient(this.normalizeConfig(config, existing)).ping(); + return { success: true }; + } + + async handleGatewayMessageApi(apiName: string, args: Record): Promise { + const applicationId = assertString(args.applicationId, 'applicationId'); + const config = this.findConfig(applicationId); + const api = this.createApiClient(config); + + switch (apiName) { + case 'ping': { + await api.ping(); + return { ok: true }; + } + case 'sendText': { + const chatGuid = assertString(args.chatGuid, 'chatGuid'); + const message = assertString(args.message, 'message'); + return api.sendText(chatGuid, message, args.options as BlueBubblesSendOptions | undefined); + } + case 'sendAttachment': { + const chatGuid = assertString(args.chatGuid, 'chatGuid'); + return api.sendAttachment( + chatGuid, + args.attachment as BlueBubblesOutboundAttachment, + args.options as BlueBubblesSendOptions | undefined, + ); + } + case 'startTyping': { + const chatGuid = assertString(args.chatGuid, 'chatGuid'); + await api.startTyping(chatGuid); + return { ok: true }; + } + case 'downloadAttachment': { + const guid = assertString(args.guid, 'guid'); + const attachment = await api.downloadAttachment(guid); + return { + data: attachment.buffer.toString('base64'), + mimeType: attachment.mimeType, + }; + } + case 'getChat': { + const guid = assertString(args.guid, 'guid'); + return api.getChat(guid, args.withParts as string[] | undefined); + } + case 'getChatMessages': { + const chatGuid = assertString(args.chatGuid, 'chatGuid'); + return api.getChatMessages( + chatGuid, + (args.options as ChatMessagesOptions | undefined) ?? {}, + ); + } + case 'queryMessages': { + return api.queryMessages((args.body as Record) ?? {}); + } + case 'queryChats': { + return api.queryChats((args.body as Record) ?? {}); + } + default: { + throw new Error(`Unsupported iMessage bridge action: ${apiName}`); + } + } + } + + private readConfigs(): StoredImessageBridgeConfig[] { + return (this.app.storeManager.get(STORE_KEY, []) as StoredImessageBridgeConfig[]) ?? []; + } + + private writeConfigs(configs: StoredImessageBridgeConfig[]) { + this.app.storeManager.set(STORE_KEY, configs); + } + + private normalizeConfig( + config: ImessageBridgeConfig, + existing?: StoredImessageBridgeConfig, + ): StoredImessageBridgeConfig { + const blueBubblesPassword = + config.blueBubblesPassword?.trim() || existing?.blueBubblesPassword?.trim(); + if (!blueBubblesPassword) throw new Error('blueBubblesPassword is required'); + + return { + applicationId: assertString(config.applicationId, 'applicationId'), + blueBubblesPassword, + blueBubblesServerUrl: assertString(config.blueBubblesServerUrl, 'blueBubblesServerUrl'), + enabled: config.enabled, + webhookSecret: assertString(config.webhookSecret, 'webhookSecret'), + }; + } + + private findConfig(applicationId: string): StoredImessageBridgeConfig { + const config = this.readConfigs().find((item) => item.applicationId === applicationId); + if (!config) throw new Error(`iMessage bridge config not found: ${applicationId}`); + if (!config.enabled) throw new Error(`iMessage bridge config is disabled: ${applicationId}`); + return config; + } + + private createApiClient(config: StoredImessageBridgeConfig): BlueBubblesApiClient { + return new BlueBubblesApiClient({ + password: config.blueBubblesPassword, + serverUrl: config.blueBubblesServerUrl, + }); + } + + private async ensureServer(): Promise { + if (this.httpServer) return; + + this.serverPort = await getPort({ + host: LOCAL_HOST, + port: 33_270, + ports: [33_271, 33_272, 33_273, 33_274, 33_275], + }); + + await new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + try { + await this.handleHttpRequest(req, res); + } catch (error) { + logger.error('Unhandled iMessage bridge request error:', error); + writeText(res, 500, 'Internal Server Error'); + } + }); + + server.listen(this.serverPort, LOCAL_HOST, () => { + this.httpServer = server; + logger.info(`iMessage local bridge started on ${this.getLocalServerUrl()}`); + resolve(); + }); + server.on('error', reject); + }); + } + + private async registerWebhook(config: StoredImessageBridgeConfig): Promise { + const webhookUrl = this.getLocalWebhookUrl(config); + const api = this.createApiClient(config); + const existing = await api.listWebhooks(); + if (existing.some((webhook) => webhook.url === webhookUrl)) { + return; + } + await api.registerWebhook(webhookUrl, ['new-message']); + logger.info('Registered BlueBubbles local webhook for iMessage appId=%s', config.applicationId); + } + + private getLocalServerUrl(): string { + return `http://${LOCAL_HOST}:${this.serverPort}`; + } + + private getLocalWebhookUrl(config: ImessageBridgeConfig): string { + const url = new URL( + `/webhooks/bluebubbles/${encodeURIComponent(config.applicationId)}`, + this.getLocalServerUrl(), + ); + url.searchParams.set('secret', config.webhookSecret); + return url.toString(); + } + + private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method === 'OPTIONS') { + writeText(res, 204, ''); + return; + } + + if (req.method !== 'POST') { + writeText(res, 405, 'Method Not Allowed'); + return; + } + + const url = new URL(req.url ?? '/', this.getLocalServerUrl()); + const match = url.pathname.match(/^\/webhooks\/bluebubbles\/([^/]+)$/); + if (!match) { + writeText(res, 404, 'Not Found'); + return; + } + + const applicationId = decodeURIComponent(match[1]); + const config = this.findConfig(applicationId); + if (url.searchParams.get('secret') !== config.webhookSecret) { + writeText(res, 401, 'Invalid secret'); + return; + } + + const event = (await readJson(req)) as BlueBubblesWebhookEvent; + const enriched = await this.enrichWebhookEvent(config, event); + await this.forwardWebhook(config, enriched); + writeJson(res, 200, { ok: true }); + } + + private async enrichWebhookEvent( + config: StoredImessageBridgeConfig, + event: BlueBubblesWebhookEvent, + ): Promise { + const message = event.data; + if (event.type !== 'new-message' || !message?.guid) return event; + + try { + const enriched = await this.createApiClient(config).getMessage(message.guid, [ + 'chats', + 'attachments', + ]); + return { ...event, data: { ...message, ...enriched } as BlueBubblesMessage }; + } catch (error) { + logger.warn('Failed to enrich iMessage webhook message=%s: %O', message.guid, error); + return event; + } + } + + private async forwardWebhook( + config: ImessageBridgeConfig, + event: BlueBubblesWebhookEvent, + ): Promise { + if (!this.remoteServerProvider) { + throw new Error('Remote server provider is not configured'); + } + + const [serverUrl, accessToken] = await Promise.all([ + this.remoteServerProvider.getServerUrl(), + this.remoteServerProvider.getAccessToken(), + ]); + if (!serverUrl) throw new Error('Remote server URL is not configured'); + + const target = new URL( + `/api/agent/webhooks/imessage/${encodeURIComponent(config.applicationId)}`, + serverUrl.endsWith('/') ? serverUrl : `${serverUrl}/`, + ); + target.searchParams.set('secret', config.webhookSecret); + + const response = await fetch(target, { + body: JSON.stringify(event), + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + + if (!response.ok) { + let detail = ''; + try { + detail = await response.text(); + } catch (error) { + logger.warn('Failed to read LobeHub webhook error response:', error); + } + throw new Error(detail || `LobeHub webhook failed with HTTP ${response.status}`); + } + } +} + +async function readJson(req: IncomingMessage): Promise { + let size = 0; + const chunks: Buffer[] = []; + + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_WEBHOOK_BYTES) throw new Error('Webhook payload is too large'); + chunks.push(buffer); + } + + const text = Buffer.concat(chunks).toString('utf8'); + return text ? JSON.parse(text) : {}; +} + +function writeJson(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function writeText(res: ServerResponse, status: number, body: string) { + res.writeHead(status, { 'Content-Type': 'text/plain' }); + res.end(body); +} diff --git a/apps/desktop/src/main/types/store.ts b/apps/desktop/src/main/types/store.ts index 799d48662d..b9b5432980 100644 --- a/apps/desktop/src/main/types/store.ts +++ b/apps/desktop/src/main/types/store.ts @@ -1,5 +1,6 @@ import type { DataSyncConfig, + ImessageBridgeConfig, NetworkProxySettings, UpdateChannel, } from '@lobechat/electron-client-ipc'; @@ -18,6 +19,7 @@ export interface ElectronMainStore { gatewayDeviceName: string; gatewayEnabled: boolean; gatewayUrl: string; + imessageBridgeConfigs: ImessageBridgeConfig[]; locale: string; localFileWorkspaceRoots: string[]; networkProxy: NetworkProxySettings; diff --git a/docs/usage/channels/imessage.mdx b/docs/usage/channels/imessage.mdx new file mode 100644 index 0000000000..596305a730 --- /dev/null +++ b/docs/usage/channels/imessage.mdx @@ -0,0 +1,106 @@ +--- +title: Connect LobeHub to iMessage +description: >- + Learn how to connect iMessage to your LobeHub agent through the local LobeHub Desktop BlueBubbles bridge. + +tags: + - iMessage + - BlueBubbles + - Message Channels + - Bot Setup + - Integration +--- + +# Connect LobeHub to iMessage + +LobeHub connects to iMessage through [BlueBubbles](https://bluebubbles.app/) running on a Mac signed into Messages. The LobeHub Desktop app runs a local bridge on that same Mac: it receives BlueBubbles webhooks on `127.0.0.1`, forwards them to LobeHub Cloud, and relays the agent's replies back to the local BlueBubbles REST API. BlueBubbles never needs to be exposed to the public internet. + +```text +iMessage user -> macOS Messages -> BlueBubbles -> LobeHub Desktop bridge -> LobeHub Cloud +LobeHub agent -> Device Gateway -> LobeHub Desktop bridge -> BlueBubbles -> iMessage user +``` + +> **Labs feature:** iMessage is gated behind a Labs toggle. The channel stays a "Coming Soon" placeholder until you enable it in **Settings → Advanced → Labs**. + +## Prerequisites + +- A Mac signed into the Apple ID you want the bot to use +- [BlueBubbles Server](https://bluebubbles.app/) installed on that Mac, with a server password set +- LobeHub Desktop signed in and connected to the Device Gateway (Settings shows the gateway as connected) + +> **Private API note:** BlueBubbles sends basic text and attachments through AppleScript out of the box. Advanced features such as typing indicators require the BlueBubbles Private API, which needs SIP disabled / a jailbroken Mac. LobeHub only depends on basic text and attachment send/receive — typing-indicator failures are logged and ignored. + +## Step 1: Set Up BlueBubbles + + + ### Install BlueBubbles Server + + Install [BlueBubbles Server](https://bluebubbles.app/) on the Mac that hosts the iMessage account. Keep the Mac awake and on the network. + + ### Set a server password + + In BlueBubbles Server, set a strong password. The LobeHub Desktop bridge uses it locally to call the BlueBubbles REST API. + + ### Keep it local + + A local address such as `http://127.0.0.1:1234` (or a private LAN address) is all you need — no public HTTPS URL required. + + +## Step 2: Enable the iMessage Lab + + + ### Open Labs + + In LobeHub, go to **Settings → Advanced** and find the **Labs** section. + + ### Turn on "iMessage Channel" + + Toggle it on. The iMessage entry in your agent's channel list switches from a "Coming Soon" placeholder to a configurable channel. + + +## Step 3: Configure iMessage in Your Agent + + + ### Open the channel + + Go to your agent's settings → **Channels** → **iMessage**. + + ### Fill in the three fields + + 1. **Application ID** — a stable identifier for this connection, e.g. `home-mac-mini`. + 2. **BlueBubbles Server URL** — your local BlueBubbles address, e.g. `http://127.0.0.1:1234`. + 3. **BlueBubbles Password** — the server password from Step 1. + + The Desktop Device ID and webhook secret are filled in and generated automatically — you don't need to manage them. + + ### Test the connection (optional) + + Click **Test BlueBubbles** to verify the URL and password reach your local BlueBubbles server. + + ### Save + + Click **Save Configuration**. A single save persists the cloud channel **and** the local Desktop bridge: it starts the loopback listener, registers the BlueBubbles `new-message` webhook, and connects the bot. + + +## Step 4: Test the Bot + +Have **another person or a second Apple ID** send an iMessage to the Apple ID / phone number signed into the BlueBubbles Mac. BlueBubbles fires a local `new-message` webhook, the Desktop bridge forwards it to LobeHub, and the agent replies in the same conversation. + +> **Why a different sender?** Messages the hosted account sends itself are ignored by the `isFromMe` loop guard (so the bot never replies to its own messages). Testing from your own number won't trigger a reply — use a different sender. + +## Feature Notes + +- **Markdown** — iMessage receives plain text; LobeHub strips Markdown before sending. +- **Attachments** — inbound and outbound attachments are relayed through the bridge and the BlueBubbles attachment APIs. +- **Typing indicators** — attempted only when the BlueBubbles Private API is available; otherwise they fail silently and don't affect replies. +- **Group chats** — supported when BlueBubbles includes the `chatGuid` in events; use the `chatGuid` as the allowed-channel ID when scoping group access. +- **Loop prevention** — messages from the hosted account itself are dropped before dispatch. + +## Troubleshooting + +- **iMessage still shows "Coming Soon".** The iMessage Channel lab isn't enabled — turn it on in Settings → Advanced → Labs. +- **Connect fails with `DEVICE_NOT_FOUND`.** LobeHub Desktop isn't reachable through the Device Gateway. Make sure Desktop is open, signed in, and the gateway shows as connected, then save again. +- **Test / save fails with a BlueBubbles error.** Recheck the local BlueBubbles URL and password. +- **Bot never replies.** Confirm the BlueBubbles `new-message` webhook points at `127.0.0.1` and that the sender is not the hosted account itself. +- **Typing indicator fails but text works.** Expected without the BlueBubbles Private API — safe to ignore. +- **Attachments fail.** Confirm the attachment finished downloading on the Mac and that BlueBubbles can serve it locally. diff --git a/docs/usage/channels/imessage.zh-CN.mdx b/docs/usage/channels/imessage.zh-CN.mdx new file mode 100644 index 0000000000..a0609af12e --- /dev/null +++ b/docs/usage/channels/imessage.zh-CN.mdx @@ -0,0 +1,106 @@ +--- +title: 将 LobeHub 连接到 iMessage +description: >- + 了解如何通过 LobeHub Desktop 本地 BlueBubbles 桥接,将 iMessage 连接到你的 LobeHub 智能体。 + +tags: + - iMessage + - BlueBubbles + - 消息渠道 + - Bot 设置 + - 集成 +--- + +# 将 LobeHub 连接到 iMessage + +LobeHub 通过运行在已登录 Messages 的 Mac 上的 [BlueBubbles](https://bluebubbles.app/) 连接 iMessage。LobeHub Desktop 在同一台 Mac 上运行一个本地桥接:它在 `127.0.0.1` 接收 BlueBubbles 的 webhook,转发到 LobeHub 云端,并把智能体的回复经本地 BlueBubbles REST API 发回。BlueBubbles 无需暴露到公网。 + +```text +iMessage 用户 -> macOS Messages -> BlueBubbles -> LobeHub Desktop 桥接 -> LobeHub 云端 +LobeHub 智能体 -> Device Gateway -> LobeHub Desktop 桥接 -> BlueBubbles -> iMessage 用户 +``` + +> **Labs 功能:** iMessage 由 Labs 开关控制。在你于 **设置 → 高级 → Labs** 中开启之前,该渠道会一直显示为 “即将推出” 占位。 + +## 前置条件 + +- 一台已登录目标 Apple ID 的 Mac +- 该 Mac 上安装了 [BlueBubbles Server](https://bluebubbles.app/) 并设置了服务器密码 +- LobeHub Desktop 已登录,且已连接 Device Gateway(设置中显示网关已连接) + +> **Private API 说明:** BlueBubbles 默认通过 AppleScript 发送基础文本和附件。打字指示等高级功能需要 BlueBubbles Private API(需关闭 SIP / 越狱的 Mac)。LobeHub 只依赖基础的文本与附件收发 —— 打字指示失败会被记录并忽略,不影响回复。 + +## 第 1 步:配置 BlueBubbles + + + ### 安装 BlueBubbles Server + + 在用于托管 iMessage 账号的 Mac 上安装 [BlueBubbles Server](https://bluebubbles.app/)。保持 Mac 唤醒并联网。 + + ### 设置服务器密码 + + 在 BlueBubbles Server 中设置一个强密码。LobeHub Desktop 桥接会在本地用它调用 BlueBubbles REST API。 + + ### 保持本地访问 + + 使用 `http://127.0.0.1:1234` 这样的本地地址(或私有局域网地址)即可,无需公网 HTTPS 地址。 + + +## 第 2 步:开启 iMessage Lab + + + ### 打开 Labs + + 在 LobeHub 中进入 **设置 → 高级**,找到 **Labs** 区域。 + + ### 打开 “iMessage Channel” + + 开启后,智能体渠道列表里的 iMessage 会从 “即将推出” 占位切换为可配置的渠道。 + + +## 第 3 步:在智能体中配置 iMessage + + + ### 打开渠道 + + 进入智能体设置 → **渠道** → **iMessage**。 + + ### 填写三个字段 + + 1. **Application ID** —— 本次连接的稳定标识,例如 `home-mac-mini`。 + 2. **BlueBubbles Server URL** —— 你的本地 BlueBubbles 地址,例如 `http://127.0.0.1:1234`。 + 3. **BlueBubbles Password** —— 第 1 步设置的服务器密码。 + + Desktop Device ID 和 webhook secret 会自动填充与生成,无需手动管理。 + + ### 测试连接(可选) + + 点击 **Test BlueBubbles**,验证 URL 和密码能否连上你的本地 BlueBubbles 服务。 + + ### 保存 + + 点击 **Save Configuration**。一次保存会同时落地云端渠道**和**本地 Desktop 桥接:启动本地回环监听、注册 BlueBubbles 的 `new-message` webhook,并连接 Bot。 + + +## 第 4 步:测试 Bot + +让**另一个人或第二个 Apple ID** 给托管在 BlueBubbles Mac 上的 Apple ID / 手机号发一条 iMessage。BlueBubbles 触发本地 `new-message` webhook,Desktop 桥接转发到 LobeHub,智能体在同一会话里回复。 + +> **为什么要用其他发送方?** 托管账号自己发的消息会被 `isFromMe` 防循环逻辑忽略(这样 Bot 不会回复自己发的消息)。用你自己的号码测试不会触发回复 —— 请用其他发送方。 + +## 功能说明 + +- **Markdown** —— iMessage 接收纯文本;LobeHub 在发送前会去除 Markdown 标记。 +- **附件** —— 入站和出站附件通过桥接与 BlueBubbles 附件 API 中转。 +- **打字指示** —— 仅在 BlueBubbles Private API 可用时尝试;否则静默失败,不影响回复。 +- **群聊** —— 当 BlueBubbles 在事件中包含 `chatGuid` 时支持群聊;限定群组访问时用 `chatGuid` 作为允许渠道 ID。 +- **防循环** —— 托管账号自身发出的消息在分发前被丢弃。 + +## 故障排查 + +- **iMessage 仍显示 “即将推出”。** iMessage Channel lab 未开启 —— 在 设置 → 高级 → Labs 中打开。 +- **连接报 `DEVICE_NOT_FOUND`。** LobeHub Desktop 未通过 Device Gateway 可达。确认 Desktop 已打开、已登录、网关显示已连接,然后重新保存。 +- **测试 / 保存报 BlueBubbles 错误。** 重新检查本地 BlueBubbles URL 和密码。 +- **Bot 始终不回复。** 确认 BlueBubbles 的 `new-message` webhook 指向 `127.0.0.1`,且发送方不是托管账号本身。 +- **打字指示失败但文本正常。** 没有 BlueBubbles Private API 时属预期 —— 可忽略。 +- **附件失败。** 确认附件已在 Mac 上下载完成,且 BlueBubbles 能在本地提供该文件。 diff --git a/docs/usage/channels/overview.mdx b/docs/usage/channels/overview.mdx index b0e22d6dcb..b5f210b790 100644 --- a/docs/usage/channels/overview.mdx +++ b/docs/usage/channels/overview.mdx @@ -34,6 +34,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform | [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations | | [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations | | [LINE](/docs/usage/channels/line) | Connect to LINE Messaging API for direct and group chats | +| [iMessage](/docs/usage/channels/imessage) | Connect to iMessage through the local LobeHub Desktop BlueBubbles bridge (Labs) | | [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages | | [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) | | [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) | diff --git a/docs/usage/channels/overview.zh-CN.mdx b/docs/usage/channels/overview.zh-CN.mdx index 53c8da1bc8..2e3ee2f8ab 100644 --- a/docs/usage/channels/overview.zh-CN.mdx +++ b/docs/usage/channels/overview.zh-CN.mdx @@ -27,16 +27,17 @@ tags: ## 支持的平台 -| 平台 | 描述 | -| ----------------------------------------- | -------------------------------------- | -| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 | -| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 | -| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 | -| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 | -| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 | -| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) | -| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) | -| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) | +| 平台 | 描述 | +| ----------------------------------------- | ----------------------------------------------------- | +| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 | +| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 | +| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 | +| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 | +| [iMessage](/docs/usage/channels/imessage) | 通过 LobeHub Desktop 本地 BlueBubbles 桥接连接 iMessage(Labs) | +| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 | +| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) | +| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) | +| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) | ## 工作原理 diff --git a/packages/chat-adapter-imessage/src/api.ts b/packages/chat-adapter-imessage/src/api.ts index d0785ea9d5..4e8fd95d5c 100644 --- a/packages/chat-adapter-imessage/src/api.ts +++ b/packages/chat-adapter-imessage/src/api.ts @@ -112,10 +112,11 @@ export class BlueBubblesApiClient { }); } - async listWebhooks(url?: string): Promise { - const response = await this.request('webhook', { - query: { url }, - }); + // Always lists all webhooks: BlueBubbles' `GET /webhook?url=` throws a + // server-side 500 when the URL isn't already registered, so callers must + // match client-side instead of relying on the url query filter. + async listWebhooks(): Promise { + const response = await this.request('webhook'); return response.data ?? []; } diff --git a/packages/electron-client-ipc/src/types/imessageBridge.ts b/packages/electron-client-ipc/src/types/imessageBridge.ts new file mode 100644 index 0000000000..44849bb6fe --- /dev/null +++ b/packages/electron-client-ipc/src/types/imessageBridge.ts @@ -0,0 +1,25 @@ +export interface ImessageBridgeConfig { + applicationId: string; + blueBubblesPassword?: string; + blueBubblesServerUrl: string; + enabled: boolean; + webhookSecret: string; +} + +export interface ImessageBridgePublicConfig extends Omit< + ImessageBridgeConfig, + 'blueBubblesPassword' +> { + blueBubblesPasswordSet: boolean; +} + +export interface ImessageBridgeStatus { + configs: ImessageBridgePublicConfig[]; + running: boolean; + serverUrl?: string; +} + +export interface ImessageBridgeSaveResult { + config: ImessageBridgePublicConfig; + success: boolean; +} diff --git a/packages/electron-client-ipc/src/types/index.ts b/packages/electron-client-ipc/src/types/index.ts index 217c1d841d..510f9b4036 100644 --- a/packages/electron-client-ipc/src/types/index.ts +++ b/packages/electron-client-ipc/src/types/index.ts @@ -1,6 +1,7 @@ export * from './dataSync'; export * from './git'; export * from './heterogeneousAgent'; +export * from './imessageBridge'; export * from './localSystem'; export * from './mcpInstall'; export * from './notification'; diff --git a/packages/types/src/user/preference.ts b/packages/types/src/user/preference.ts index 9ffaded241..2bbbf366a6 100644 --- a/packages/types/src/user/preference.ts +++ b/packages/types/src/user/preference.ts @@ -59,6 +59,10 @@ export const UserLabSchema = z.object({ * enable multi-agent group chat mode */ enableGroupChat: z.boolean().optional(), + /** + * enable the iMessage channel (BlueBubbles Desktop bridge) + */ + enableImessage: z.boolean().optional(), /** * enable markdown rendering in chat input editor */ diff --git a/src/locales/default/agent.ts b/src/locales/default/agent.ts index 5430e557af..eb668fd468 100644 --- a/src/locales/default/agent.ts +++ b/src/locales/default/agent.ts @@ -57,6 +57,42 @@ export default { 'channel.feishu.webhookMigrationTitle': 'Consider migrating to WebSocket mode', 'channel.feishu.webhookMigrationDesc': 'WebSocket mode provides real-time event delivery without needing a public callback URL. To migrate, switch the Connection Mode to WebSocket in Advanced Settings. No additional configuration is needed on the Feishu/Lark Open Platform.', + 'channel.imessage.description': + 'Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.', + 'channel.imessage.applicationIdHint': + 'A stable identifier shared by the cloud channel and the Desktop bridge.', + 'channel.imessage.applicationIdPlaceholder': 'e.g. home-mac-mini', + 'channel.imessage.blueBubblesPassword': 'BlueBubbles Password', + 'channel.imessage.blueBubblesPasswordHint': + 'Stored locally in LobeHub Desktop and used only to call the local BlueBubbles server.', + 'channel.imessage.blueBubblesServerUrl': 'BlueBubbles Server URL', + 'channel.imessage.blueBubblesServerUrlHint': + 'The local BlueBubbles server URL reachable from this Desktop app.', + 'channel.imessage.bridgeEnabled': 'Enable Bridge', + 'channel.imessage.bridgeEnabledHint': + 'When enabled, LobeHub Desktop receives local BlueBubbles webhooks and forwards them to LobeHub.', + 'channel.imessage.bridgeMissingApplicationId': 'Enter the Application ID first.', + 'channel.imessage.bridgeMissingPassword': 'Enter the BlueBubbles password first.', + 'channel.imessage.bridgeMissingServerUrl': 'Enter the BlueBubbles Server URL first.', + 'channel.imessage.bridgeMissingWebhookSecret': 'Enter the Webhook Secret first.', + 'channel.imessage.bridgePasswordSavedPlaceholder': 'Leave blank to keep the saved password', + 'channel.imessage.bridgeRefresh': 'Refresh', + 'channel.imessage.bridgeRefreshFailed': 'Failed to refresh iMessage Desktop bridge', + 'channel.imessage.bridgeRunning': 'Running', + 'channel.imessage.bridgeSave': 'Save Bridge', + 'channel.imessage.bridgeSaveFailed': 'Failed to save iMessage Desktop bridge', + 'channel.imessage.bridgeSaved': 'iMessage Desktop bridge saved', + 'channel.imessage.bridgeStopped': 'Stopped', + 'channel.imessage.bridgeTest': 'Test BlueBubbles', + 'channel.imessage.bridgeTestFailed': 'BlueBubbles test failed', + 'channel.imessage.bridgeTestSuccess': 'BlueBubbles connection passed', + 'channel.imessage.desktopDeviceId': 'Desktop Device ID', + 'channel.imessage.desktopDeviceIdHint': + 'The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.', + 'channel.imessage.desktopBridge': 'Desktop Bridge', + 'channel.imessage.webhookSecret': 'Webhook Secret', + 'channel.imessage.webhookSecretHint': + 'A shared secret used between LobeHub Desktop and the cloud webhook. Use the same value in the Desktop bridge config.', 'channel.lark.description': 'Connect this assistant to Lark for private and group chats.', 'channel.line.description': 'Connect this assistant to LINE Messaging API for direct and group chats.', @@ -234,6 +270,8 @@ export default { 'Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.', 'channel.userIdHint.feishu': 'Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.', + 'channel.userIdHint.imessage': + 'Use your iMessage handle as seen in BlueBubbles, usually an email address or E.164 phone number.', 'channel.userIdHint.line': 'Open the LINE Developers Console → your channel → Basic settings tab, and copy "Your user ID" (starts with U, 33 chars).', 'channel.userIdHint.qq': 'Your QQ number, shown on your QQ profile page.', diff --git a/src/locales/default/labs.ts b/src/locales/default/labs.ts index c99782afc5..3bc62c44eb 100644 --- a/src/locales/default/labs.ts +++ b/src/locales/default/labs.ts @@ -14,6 +14,9 @@ export default { 'features.gatewayMode.desc': 'Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.', 'features.gatewayMode.title': 'Server-Side Agent Execution (Gateway)', + 'features.imessage.desc': + 'Connect agents to iMessage through the local LobeHub Desktop BlueBubbles bridge.', + 'features.imessage.title': 'iMessage Channel', 'features.groupChat.desc': 'Enable multi-agent group chat coordination.', 'features.groupChat.title': 'Group Chat (Multi-Agent)', 'features.inputMarkdown.desc': diff --git a/src/routes/(main)/agent/channel/const.ts b/src/routes/(main)/agent/channel/const.ts index 7b9730bfdc..bb80ad4fc7 100644 --- a/src/routes/(main)/agent/channel/const.ts +++ b/src/routes/(main)/agent/channel/const.ts @@ -58,6 +58,8 @@ export const COMING_SOON_PLATFORMS: ChannelPlatformDefinition[] = [ name: 'WhatsApp', schema: [], }, + // iMessage is registered server-side but lab-gated: shown as a placeholder + // unless the `imessage` feature flag is on (see channel/index.tsx). { comingSoon: true, connectionMode: 'webhook', diff --git a/src/routes/(main)/agent/channel/detail/index.tsx b/src/routes/(main)/agent/channel/detail/index.tsx index 51076ab1e2..bd166db4ea 100644 --- a/src/routes/(main)/agent/channel/detail/index.tsx +++ b/src/routes/(main)/agent/channel/detail/index.tsx @@ -3,7 +3,7 @@ import { confirmModal } from '@lobehub/ui/base-ui'; import { App, Form } from 'antd'; import { createStaticStyles } from 'antd-style'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types'; @@ -19,6 +19,7 @@ import Body from './Body'; import Footer from './Footer'; import { getChannelFormValues, mergeSettingsWithDefaults } from './formState'; import Header from './Header'; +import { type ChannelPostSave, ChannelPostSaveContext } from './postSaveContext'; const styles = createStaticStyles(({ css, cssVar }) => ({ main: css` @@ -103,6 +104,19 @@ const PlatformDetail = memo( const [refreshingStatus, setRefreshingStatus] = useState(false); const connectPollingTimerRef = useRef | null>(null); + // Platform-specific extras (e.g. iMessage's BlueBubbles bridge) register a + // side-effect here so it runs as part of the single "Save Configuration" + // click instead of a separate button. + const postSaveRef = useRef(null); + const postSaveRegistry = useMemo( + () => ({ + register: (fn: ChannelPostSave | null) => { + postSaveRef.current = fn; + }, + }), + [], + ); + const stopConnectPolling = useCallback(() => { if (!connectPollingTimerRef.current) return; clearTimeout(connectPollingTimerRef.current); @@ -325,6 +339,10 @@ const PlatformDetail = memo( }); } + // Run any platform-specific post-save side-effect (e.g. iMessage's + // local BlueBubbles bridge) as part of the same save. + await postSaveRef.current?.({ applicationId }); + setSaveResult({ type: 'success' }); setTimeout(() => setSaveResult(undefined), 3000); setSaving(false); @@ -464,41 +482,43 @@ const PlatformDetail = memo( }, [currentConfig, platformDef.id, testConnection, msg, t]); return ( -
-
- -
msg.success(t('channel.copied'))} - onDelete={handleDelete} - onSave={handleSave} - onTestConnection={handleTestConnection} - /> -
+ +
+
+ +
msg.success(t('channel.copied'))} + onDelete={handleDelete} + onSave={handleSave} + onTestConnection={handleTestConnection} + /> +
+
); }, ); diff --git a/src/routes/(main)/agent/channel/detail/postSaveContext.ts b/src/routes/(main)/agent/channel/detail/postSaveContext.ts new file mode 100644 index 0000000000..a97d6cec32 --- /dev/null +++ b/src/routes/(main)/agent/channel/detail/postSaveContext.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; + +/** + * A side-effect a platform-specific extras component wants to run as part of + * the main "Save Configuration" flow (after the cloud bot provider is saved). + * Used by iMessage to persist its Desktop-only BlueBubbles bridge config in the + * same click instead of a separate "Save Bridge" button. Throwing aborts the + * save and surfaces the error to the user. + */ +export type ChannelPostSave = (ctx: { applicationId: string }) => Promise; + +export interface ChannelPostSaveRegistry { + register: (fn: ChannelPostSave | null) => void; +} + +export const ChannelPostSaveContext = createContext(null); diff --git a/src/routes/(main)/agent/channel/index.tsx b/src/routes/(main)/agent/channel/index.tsx index 598da17b7f..b73a78a79c 100644 --- a/src/routes/(main)/agent/channel/index.tsx +++ b/src/routes/(main)/agent/channel/index.tsx @@ -8,6 +8,8 @@ import { useParams } from 'react-router-dom'; import Loading from '@/components/Loading/BrandTextLoading'; import NavHeader from '@/features/NavHeader'; import { useAgentStore } from '@/store/agent'; +import { useUserStore } from '@/store/user'; +import { labPreferSelectors } from '@/store/user/selectors'; import { BOT_RUNTIME_STATUSES, type BotRuntimeStatus } from '../../../../types/botRuntimeStatus'; import { type ChannelPlatformDefinition, COMING_SOON_PLATFORMS } from './const'; @@ -37,6 +39,7 @@ const ChannelPage = memo(() => { s.useFetchBotProviders(aid), ); const triggerRefreshAllBotStatuses = useAgentStore((s) => s.triggerRefreshAllBotStatuses); + const enableImessage = useUserStore(labPreferSelectors.enableImessage); // Fire-and-forget a live gateway status refresh on entry. The list renders // from cached statuses immediately; SWR revalidates once Redis is updated. @@ -48,14 +51,18 @@ const ChannelPage = memo(() => { const isLoading = platformsLoading || providersLoading; // Merge server-side platforms with frontend-only coming-soon entries. - // Coming-soon wins over a server-registered platform with the same id: this - // lets the server roll out a platform safely (registering it on the server - // first) while the frontend keeps the placeholder until it's ready to ship — - // dropping the entry from COMING_SOON_PLATFORMS reveals the real platform. + // Coming-soon entries shadow a server-registered platform of the same id, so a + // platform can be registered server-side first and stay a placeholder until + // the frontend reveals it. iMessage additionally honors the Labs + // `enableImessage` preference: off keeps the placeholder, on drops it so the + // real platform shows. const allPlatforms = useMemo(() => { - const comingSoonIds = new Set(COMING_SOON_PLATFORMS.map((p) => p.id)); - return [...(platforms ?? []).filter((p) => !comingSoonIds.has(p.id)), ...COMING_SOON_PLATFORMS]; - }, [platforms]); + const comingSoon = enableImessage + ? COMING_SOON_PLATFORMS.filter((p) => p.id !== 'imessage') + : COMING_SOON_PLATFORMS; + const comingSoonIds = new Set(comingSoon.map((p) => p.id)); + return [...(platforms ?? []).filter((p) => !comingSoonIds.has(p.id)), ...comingSoon]; + }, [platforms, enableImessage]); // Default to first platform once loaded const effectiveActiveId = activeProviderId || allPlatforms[0]?.id || ''; diff --git a/src/routes/(main)/agent/channel/platform/imessage/CredentialExtras.tsx b/src/routes/(main)/agent/channel/platform/imessage/CredentialExtras.tsx new file mode 100644 index 0000000000..383970b320 --- /dev/null +++ b/src/routes/(main)/agent/channel/platform/imessage/CredentialExtras.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { isDesktop } from '@lobechat/const'; +import type { + ImessageBridgeConfig, + ImessageBridgePublicConfig, +} from '@lobechat/electron-client-ipc'; +import { Flexbox, FormItem, Tag, Text } from '@lobehub/ui'; +import { App, Button, Form as AntdForm, Switch } from 'antd'; +import { RefreshCw, TestTube2 } from 'lucide-react'; +import { memo, use, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormInput, FormPassword } from '@/components/FormInput'; +import { gatewayConnectionService } from '@/services/electron/gatewayConnection'; +import { imessageBridgeService } from '@/services/electron/imessageBridge'; + +import { ChannelPostSaveContext } from '../../detail/postSaveContext'; + +interface BridgeFormState { + blueBubblesPassword: string; + blueBubblesServerUrl: string; + enabled: boolean; +} + +const DEFAULT_BRIDGE_FORM: BridgeFormState = { + blueBubblesPassword: '', + blueBubblesServerUrl: '', + enabled: true, +}; + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const CredentialExtras = memo(() => { + const { t: _t } = useTranslation('agent'); + const t = _t as (key: string) => string; + const { message } = App.useApp(); + const form = AntdForm.useFormInstance(); + const applicationId = AntdForm.useWatch('applicationId', form) as string | undefined; + const postSave = use(ChannelPostSaveContext); + + const [bridgeForm, setBridgeForm] = useState(DEFAULT_BRIDGE_FORM); + const [loading, setLoading] = useState(false); + const [passwordSet, setPasswordSet] = useState(false); + const [running, setRunning] = useState(false); + const [serverUrl, setServerUrl] = useState(); + const [testing, setTesting] = useState(false); + + const fillDesktopDeviceId = useCallback(async () => { + const deviceInfo = await gatewayConnectionService.getDeviceInfo(); + form.setFieldValue(['credentials', 'desktopDeviceId'], deviceInfo.deviceId); + void form.validateFields([['credentials', 'desktopDeviceId']]).catch(() => undefined); + }, [form]); + + // The webhook secret is shared between the cloud provider and the local + // bridge but is not a user-facing field — generate one on demand and reuse + // whatever is already stored on the form (saved config or a prior generation). + const ensureWebhookSecret = useCallback((): string => { + const existing = ( + form.getFieldValue(['credentials', 'webhookSecret']) as string | undefined + )?.trim(); + if (existing) return existing; + const generated = globalThis.crypto.randomUUID(); + form.setFieldValue(['credentials', 'webhookSecret'], generated); + return generated; + }, [form]); + + const refreshStatus = useCallback(async () => { + if (!isDesktop) return; + + setLoading(true); + try { + await fillDesktopDeviceId(); + const status = await imessageBridgeService.getStatus(); + const savedConfig = status.configs.find( + (config: ImessageBridgePublicConfig) => config.applicationId === applicationId?.trim(), + ); + + setBridgeForm( + savedConfig + ? { + blueBubblesPassword: '', + blueBubblesServerUrl: savedConfig.blueBubblesServerUrl, + enabled: savedConfig.enabled, + } + : DEFAULT_BRIDGE_FORM, + ); + setPasswordSet(Boolean(savedConfig?.blueBubblesPasswordSet)); + setRunning(status.running); + setServerUrl(status.serverUrl); + } catch (error) { + message.error(`${t('channel.imessage.bridgeRefreshFailed')}: ${getErrorMessage(error)}`); + } finally { + setLoading(false); + } + }, [applicationId, fillDesktopDeviceId, message, t]); + + // Build + validate the bridge config. Throws (rather than warning + returning) + // so the unified save flow and the Test button can each surface the error. + const buildBridgeConfig = useCallback((): ImessageBridgeConfig => { + const appId = applicationId?.trim(); + const blueBubblesServerUrl = bridgeForm.blueBubblesServerUrl.trim(); + const blueBubblesPassword = bridgeForm.blueBubblesPassword.trim(); + + if (!appId) throw new Error(t('channel.imessage.bridgeMissingApplicationId')); + if (!blueBubblesServerUrl) throw new Error(t('channel.imessage.bridgeMissingServerUrl')); + if (!blueBubblesPassword && !passwordSet) { + throw new Error(t('channel.imessage.bridgeMissingPassword')); + } + + return { + applicationId: appId, + blueBubblesPassword: blueBubblesPassword || undefined, + blueBubblesServerUrl, + enabled: bridgeForm.enabled, + webhookSecret: ensureWebhookSecret(), + }; + }, [applicationId, bridgeForm, passwordSet, ensureWebhookSecret, t]); + + // Persist the Desktop-only bridge config. Registered as a post-save effect so + // it runs as part of the single "Save Configuration" click. + const saveBridge = useCallback(async () => { + const config = buildBridgeConfig(); + await fillDesktopDeviceId(); + await imessageBridgeService.upsertConfig(config); + await refreshStatus(); + }, [buildBridgeConfig, fillDesktopDeviceId, refreshStatus]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + // Seed a webhook secret as soon as the form is ready so the saved cloud + // provider always carries one. + useEffect(() => { + if (!isDesktop) return; + ensureWebhookSecret(); + }, [applicationId, ensureWebhookSecret]); + + // Hook the bridge save into the main "Save Configuration" flow. + useEffect(() => { + if (!isDesktop || !postSave) return; + postSave.register(saveBridge); + return () => postSave.register(null); + }, [postSave, saveBridge]); + + if (!isDesktop) return null; + + const handleTest = async () => { + setTesting(true); + try { + const config = buildBridgeConfig(); + await imessageBridgeService.testConfig(config); + message.success(t('channel.imessage.bridgeTestSuccess')); + } catch (error) { + message.error(`${t('channel.imessage.bridgeTestFailed')}: ${getErrorMessage(error)}`); + } finally { + setTesting(false); + } + }; + + return ( + <> + + + setBridgeForm((previous) => ({ ...previous, blueBubblesServerUrl: value })) + } + /> + + + + setBridgeForm((previous) => ({ ...previous, blueBubblesPassword: value })) + } + /> + + + setBridgeForm((previous) => ({ ...previous, enabled }))} + /> + + + + {running ? t('channel.imessage.bridgeRunning') : t('channel.imessage.bridgeStopped')} + + {serverUrl && ( + + {serverUrl} + + )} + + + + + + + ); +}); + +export default CredentialExtras; diff --git a/src/routes/(main)/agent/channel/platform/registry.ts b/src/routes/(main)/agent/channel/platform/registry.ts index df9e9e3119..e3cd1a11a8 100644 --- a/src/routes/(main)/agent/channel/platform/registry.ts +++ b/src/routes/(main)/agent/channel/platform/registry.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react'; +import ImessageCredentialExtras from './imessage/CredentialExtras'; import LineCredentialExtras from './line/CredentialExtras'; import type { PlatformCredentialBodyProps } from './types'; import WechatCredentialBody from './wechat/CredentialBody'; @@ -19,5 +20,6 @@ export const platformCredentialBodyMap: Record< * without replacing it wholesale. */ export const platformCredentialExtrasMap: Record = { + imessage: ImessageCredentialExtras, line: LineCredentialExtras, }; diff --git a/src/routes/(main)/settings/advanced/index.tsx b/src/routes/(main)/settings/advanced/index.tsx index 54d28f8e90..31742f3bc3 100644 --- a/src/routes/(main)/settings/advanced/index.tsx +++ b/src/routes/(main)/settings/advanced/index.tsx @@ -42,6 +42,7 @@ const Page = memo(() => { enableGatewayMode, enablePlatformAgent, enableExecutionDeviceSwitcher, + enableImessage, updateLab, ] = useUserStore((s) => [ preferenceSelectors.isPreferenceInit(s), @@ -50,6 +51,7 @@ const Page = memo(() => { labPreferSelectors.enableGatewayMode(s), labPreferSelectors.enablePlatformAgent(s), labPreferSelectors.enableExecutionDeviceSwitcher(s), + labPreferSelectors.enableImessage(s), s.updateLab, ]); @@ -145,6 +147,23 @@ const Page = memo(() => { label: tLabs('features.executionDeviceSwitcher.title'), minWidth: undefined, }, + ...(isDesktop + ? [ + { + children: ( + updateLab({ enableImessage: checked })} + /> + ), + className: styles.labItem, + desc: tLabs('features.imessage.desc'), + label: tLabs('features.imessage.title'), + minWidth: undefined, + } satisfies FormItemProps, + ] + : []), ...(hasGatewayUrl ? [ { diff --git a/src/services/electron/imessageBridge.ts b/src/services/electron/imessageBridge.ts new file mode 100644 index 0000000000..af1cbb277a --- /dev/null +++ b/src/services/electron/imessageBridge.ts @@ -0,0 +1,31 @@ +import type { ImessageBridgeConfig } from '@lobechat/electron-client-ipc'; + +import { ensureElectronIpc } from '@/utils/electron/ipc'; + +class ImessageBridgeService { + getStatus = async () => { + return ensureElectronIpc().imessageBridge.getStatus(); + }; + + removeConfig = async (applicationId: string) => { + return ensureElectronIpc().imessageBridge.removeConfig({ applicationId }); + }; + + start = async () => { + return ensureElectronIpc().imessageBridge.start(); + }; + + stop = async () => { + return ensureElectronIpc().imessageBridge.stop(); + }; + + testConfig = async (config: ImessageBridgeConfig) => { + return ensureElectronIpc().imessageBridge.testConfig(config); + }; + + upsertConfig = async (config: ImessageBridgeConfig) => { + return ensureElectronIpc().imessageBridge.upsertConfig(config); + }; +} + +export const imessageBridgeService = new ImessageBridgeService(); diff --git a/src/store/user/slices/preference/selectors/labPrefer.ts b/src/store/user/slices/preference/selectors/labPrefer.ts index af2377a34d..9cfe8eedc6 100644 --- a/src/store/user/slices/preference/selectors/labPrefer.ts +++ b/src/store/user/slices/preference/selectors/labPrefer.ts @@ -12,6 +12,7 @@ export const labPreferSelectors = { enableExecutionDeviceSwitcher: (s: UserState): boolean => s.preference.lab?.enableExecutionDeviceSwitcher ?? false, enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false, + enableImessage: (s: UserState): boolean => s.preference.lab?.enableImessage ?? false, enableInputMarkdown: (s: UserState): boolean => s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab?.enableInputMarkdown ?? true, enablePlatformAgent: (s: UserState): boolean => s.preference.lab?.enablePlatformAgent ?? false,