feat: add bluebubbles plugin
This commit is contained in:
@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||||
- Docs: document plugin slots and memory plugin behavior.
|
- Docs: document plugin slots and memory plugin behavior.
|
||||||
|
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||||
|
|
||||||
## 2026.1.17-5
|
## 2026.1.17-5
|
||||||
|
|||||||
64
docs/channels/bluebubbles.md
Normal file
64
docs/channels/bluebubbles.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
|
||||||
|
read_when:
|
||||||
|
- Setting up BlueBubbles channel
|
||||||
|
- Troubleshooting webhook pairing
|
||||||
|
---
|
||||||
|
# BlueBubbles (macOS REST)
|
||||||
|
|
||||||
|
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
|
||||||
|
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||||
|
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||||
|
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||||
|
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||||
|
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
||||||
|
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
|
||||||
|
3. Configure Clawdbot:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
enabled: true,
|
||||||
|
serverUrl: "http://bluebubbles-host:1234",
|
||||||
|
password: "example-password",
|
||||||
|
webhookPath: "/bluebubbles-webhook",
|
||||||
|
actions: { reactions: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
|
||||||
|
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||||
|
|
||||||
|
## Configuration notes
|
||||||
|
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
||||||
|
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
||||||
|
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
||||||
|
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
|
||||||
|
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
|
||||||
|
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
|
||||||
|
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
|
||||||
|
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
|
||||||
|
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
|
||||||
|
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
||||||
|
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||||
|
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
||||||
|
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
||||||
|
|
||||||
|
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
|
||||||
@@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||||
|
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
|
|||||||
18
extensions/bluebubbles/index.ts
Normal file
18
extensions/bluebubbles/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { bluebubblesPlugin } from "./src/channel.js";
|
||||||
|
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
||||||
|
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "bluebubbles",
|
||||||
|
name: "BlueBubbles",
|
||||||
|
description: "BlueBubbles channel plugin (macOS app)",
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setBlueBubblesRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: bluebubblesPlugin });
|
||||||
|
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
9
extensions/bluebubbles/package.json
Normal file
9
extensions/bluebubbles/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/bluebubbles",
|
||||||
|
"version": "2026.1.15",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
79
extensions/bluebubbles/src/accounts.ts
Normal file
79
extensions/bluebubbles/src/accounts.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
|
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||||
|
|
||||||
|
export type ResolvedBlueBubblesAccount = {
|
||||||
|
accountId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name?: string;
|
||||||
|
config: BlueBubblesAccountConfig;
|
||||||
|
configured: boolean;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") return [];
|
||||||
|
return Object.keys(accounts).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBlueBubblesAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
|
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||||
|
return ids.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultBlueBubblesAccountId(cfg: ClawdbotConfig): string {
|
||||||
|
const ids = listBlueBubblesAccountIds(cfg);
|
||||||
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||||
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccountConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string,
|
||||||
|
): BlueBubblesAccountConfig | undefined {
|
||||||
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") return undefined;
|
||||||
|
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeBlueBubblesAccountConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string,
|
||||||
|
): BlueBubblesAccountConfig {
|
||||||
|
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
|
||||||
|
accounts?: unknown;
|
||||||
|
};
|
||||||
|
const { accounts: _ignored, ...rest } = base;
|
||||||
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
|
return { ...rest, ...account };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBlueBubblesAccount(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): ResolvedBlueBubblesAccount {
|
||||||
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
|
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
||||||
|
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
||||||
|
const accountEnabled = merged.enabled !== false;
|
||||||
|
const serverUrl = merged.serverUrl?.trim();
|
||||||
|
const password = merged.password?.trim();
|
||||||
|
const configured = Boolean(serverUrl && password);
|
||||||
|
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
enabled: baseEnabled !== false && accountEnabled,
|
||||||
|
name: merged.name?.trim() || undefined,
|
||||||
|
config: merged,
|
||||||
|
configured,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEnabledBlueBubblesAccounts(cfg: ClawdbotConfig): ResolvedBlueBubblesAccount[] {
|
||||||
|
return listBlueBubblesAccountIds(cfg)
|
||||||
|
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
|
||||||
|
.filter((account) => account.enabled);
|
||||||
|
}
|
||||||
121
extensions/bluebubbles/src/actions.ts
Normal file
121
extensions/bluebubbles/src/actions.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
createActionGate,
|
||||||
|
jsonResult,
|
||||||
|
readNumberParam,
|
||||||
|
readReactionParams,
|
||||||
|
readStringParam,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
|
type ChannelMessageActionName,
|
||||||
|
type ChannelToolSend,
|
||||||
|
type ClawdbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||||
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
|
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||||
|
import type { BlueBubblesSendTarget } from "./types.js";
|
||||||
|
|
||||||
|
const providerId = "bluebubbles";
|
||||||
|
|
||||||
|
function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||||
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
|
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
||||||
|
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId };
|
||||||
|
if (parsed.kind === "chat_identifier") {
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "handle",
|
||||||
|
address: normalizeBlueBubblesHandle(parsed.to),
|
||||||
|
service: parsed.service,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||||
|
listActions: ({ cfg }) => {
|
||||||
|
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
|
||||||
|
if (!account.enabled || !account.configured) return [];
|
||||||
|
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
||||||
|
const actions = new Set<ChannelMessageActionName>();
|
||||||
|
if (gate("reactions")) actions.add("react");
|
||||||
|
return Array.from(actions);
|
||||||
|
},
|
||||||
|
supportsAction: ({ action }) => action === "react",
|
||||||
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
|
if (action !== "sendMessage") return null;
|
||||||
|
const to = typeof args.to === "string" ? args.to : undefined;
|
||||||
|
if (!to) return null;
|
||||||
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
|
return { to, accountId };
|
||||||
|
},
|
||||||
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||||
|
if (action !== "react") {
|
||||||
|
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||||
|
}
|
||||||
|
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||||
|
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
||||||
|
});
|
||||||
|
if (isEmpty && !remove) {
|
||||||
|
throw new Error("Emoji is required to send a BlueBubbles reaction.");
|
||||||
|
}
|
||||||
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
|
const chatGuid = readStringParam(params, "chatGuid");
|
||||||
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||||
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
|
const to = readStringParam(params, "to");
|
||||||
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
const baseUrl = account.config.serverUrl?.trim();
|
||||||
|
const password = account.config.password?.trim();
|
||||||
|
|
||||||
|
let resolvedChatGuid = chatGuid?.trim() || "";
|
||||||
|
if (!resolvedChatGuid) {
|
||||||
|
const target =
|
||||||
|
chatIdentifier?.trim()
|
||||||
|
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
|
||||||
|
: typeof chatId === "number"
|
||||||
|
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||||
|
: to
|
||||||
|
? mapTarget(to)
|
||||||
|
: null;
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
|
||||||
|
}
|
||||||
|
if (!baseUrl || !password) {
|
||||||
|
throw new Error("BlueBubbles reaction requires serverUrl and password.");
|
||||||
|
}
|
||||||
|
resolvedChatGuid =
|
||||||
|
(await resolveChatGuidForTarget({
|
||||||
|
baseUrl,
|
||||||
|
password,
|
||||||
|
target,
|
||||||
|
})) ?? "";
|
||||||
|
}
|
||||||
|
if (!resolvedChatGuid) {
|
||||||
|
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendBlueBubblesReaction({
|
||||||
|
chatGuid: resolvedChatGuid,
|
||||||
|
messageGuid: messageId,
|
||||||
|
emoji,
|
||||||
|
remove: remove || undefined,
|
||||||
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
|
opts: {
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!remove) {
|
||||||
|
return jsonResult({ ok: true, added: emoji });
|
||||||
|
}
|
||||||
|
return jsonResult({ ok: true, removed: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
57
extensions/bluebubbles/src/attachments.ts
Normal file
57
extensions/bluebubbles/src/attachments.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import {
|
||||||
|
blueBubblesFetchWithTimeout,
|
||||||
|
buildBlueBubblesApiUrl,
|
||||||
|
type BlueBubblesAttachment,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesAttachmentOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: params.cfg ?? {},
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
return { baseUrl, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBlueBubblesAttachment(
|
||||||
|
attachment: BlueBubblesAttachment,
|
||||||
|
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
||||||
|
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
||||||
|
const guid = attachment.guid?.trim();
|
||||||
|
if (!guid) throw new Error("BlueBubbles attachment guid is required");
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get("content-type") ?? undefined;
|
||||||
|
const buf = new Uint8Array(await res.arrayBuffer());
|
||||||
|
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||||
|
if (buf.byteLength > maxBytes) {
|
||||||
|
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
|
||||||
|
}
|
||||||
|
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
||||||
|
}
|
||||||
284
extensions/bluebubbles/src/channel.ts
Normal file
284
extensions/bluebubbles/src/channel.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
normalizeAccountId,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listBlueBubblesAccountIds,
|
||||||
|
type ResolvedBlueBubblesAccount,
|
||||||
|
resolveBlueBubblesAccount,
|
||||||
|
resolveDefaultBlueBubblesAccountId,
|
||||||
|
} from "./accounts.js";
|
||||||
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
|
import { probeBlueBubbles } from "./probe.js";
|
||||||
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
|
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id: "bluebubbles",
|
||||||
|
label: "BlueBubbles",
|
||||||
|
selectionLabel: "BlueBubbles (macOS app)",
|
||||||
|
docsPath: "/channels/bluebubbles",
|
||||||
|
docsLabel: "bluebubbles",
|
||||||
|
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||||
|
order: 75,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||||
|
id: "bluebubbles",
|
||||||
|
meta,
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: false,
|
||||||
|
reactions: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||||
|
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
|
||||||
|
resolveAccount: (cfg, accountId) =>
|
||||||
|
resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||||
|
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as ClawdbotConfig),
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
|
setAccountEnabledInConfigSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
sectionKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
enabled,
|
||||||
|
allowTopLevel: true,
|
||||||
|
}),
|
||||||
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
|
deleteAccountFromConfigSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
sectionKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||||
|
}),
|
||||||
|
isConfigured: (account) => account.configured,
|
||||||
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ??
|
||||||
|
[]).map(
|
||||||
|
(entry) => String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
|
||||||
|
.map((entry) => normalizeBlueBubblesHandle(entry)),
|
||||||
|
},
|
||||||
|
actions: bluebubblesMessageActions,
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const useAccountPath = Boolean(
|
||||||
|
(cfg as ClawdbotConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
|
||||||
|
);
|
||||||
|
const basePath = useAccountPath
|
||||||
|
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
||||||
|
: "channels.bluebubbles.";
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: formatPairingApproveHint("bluebubbles"),
|
||||||
|
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collectWarnings: ({ account }) => {
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||||
|
if (groupPolicy !== "open") return [];
|
||||||
|
return [
|
||||||
|
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
validateInput: ({ input }) => {
|
||||||
|
if (!input.httpUrl && !input.password) {
|
||||||
|
return "BlueBubbles requires --http-url and --password.";
|
||||||
|
}
|
||||||
|
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
|
||||||
|
if (!input.password) return "BlueBubbles requires --password.";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
name: input.name,
|
||||||
|
});
|
||||||
|
const next =
|
||||||
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? migrateBaseNameToDefaultAccount({
|
||||||
|
cfg: namedConfig,
|
||||||
|
channelKey: "bluebubbles",
|
||||||
|
})
|
||||||
|
: namedConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
bluebubbles: {
|
||||||
|
...next.channels?.bluebubbles,
|
||||||
|
enabled: true,
|
||||||
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
bluebubbles: {
|
||||||
|
...next.channels?.bluebubbles,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...(next.channels?.bluebubbles?.accounts ?? {}),
|
||||||
|
[accountId]: {
|
||||||
|
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pairing: {
|
||||||
|
idLabel: "bluebubblesSenderId",
|
||||||
|
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
resolveTarget: ({ to }) => {
|
||||||
|
const trimmed = to?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, to: trimmed };
|
||||||
|
},
|
||||||
|
sendText: async ({ cfg, to, text, accountId }) => {
|
||||||
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
return { channel: "bluebubbles", ...result };
|
||||||
|
},
|
||||||
|
sendMedia: async () => {
|
||||||
|
throw new Error("BlueBubbles media delivery is not supported yet.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: (accounts) =>
|
||||||
|
accounts.flatMap((account) => {
|
||||||
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
|
if (!lastError) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
channel: "bluebubbles",
|
||||||
|
accountId: account.accountId,
|
||||||
|
kind: "runtime",
|
||||||
|
message: `Channel error: ${lastError}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
baseUrl: snapshot.baseUrl ?? null,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
|
probeBlueBubbles({
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
password: account.config.password ?? null,
|
||||||
|
timeoutMs,
|
||||||
|
}),
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
probe,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
||||||
|
ctx.setStatus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
});
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||||
|
return monitorBlueBubblesProvider({
|
||||||
|
account,
|
||||||
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||||
|
webhookPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
66
extensions/bluebubbles/src/chat.ts
Normal file
66
extensions/bluebubbles/src/chat.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesChatOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAccount(params: BlueBubblesChatOpts) {
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: params.cfg ?? {},
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
return { baseUrl, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markBlueBubblesChatRead(
|
||||||
|
chatGuid: string,
|
||||||
|
opts: BlueBubblesChatOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const trimmed = chatGuid.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBlueBubblesTyping(
|
||||||
|
chatGuid: string,
|
||||||
|
typing: boolean,
|
||||||
|
opts: BlueBubblesChatOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const trimmed = chatGuid.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{ method: typing ? "POST" : "DELETE" },
|
||||||
|
opts.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
extensions/bluebubbles/src/config-schema.ts
Normal file
30
extensions/bluebubbles/src/config-schema.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
|
|
||||||
|
const bluebubblesActionSchema = z
|
||||||
|
.object({
|
||||||
|
reactions: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const bluebubblesAccountSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
serverUrl: z.string().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
webhookPath: z.string().optional(),
|
||||||
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||||
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||||
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||||
|
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
||||||
|
actions: bluebubblesActionSchema,
|
||||||
|
});
|
||||||
1105
extensions/bluebubbles/src/monitor.ts
Normal file
1105
extensions/bluebubbles/src/monitor.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
extensions/bluebubbles/src/probe.ts
Normal file
36
extensions/bluebubbles/src/probe.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesProbe = {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function probeBlueBubbles(params: {
|
||||||
|
baseUrl?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<BlueBubblesProbe> {
|
||||||
|
const baseUrl = params.baseUrl?.trim();
|
||||||
|
const password = params.password?.trim();
|
||||||
|
if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
|
||||||
|
if (!password) return { ok: false, error: "password not configured" };
|
||||||
|
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
||||||
|
try {
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{ method: "GET" },
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
return { ok: true, status: res.status };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: null,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
114
extensions/bluebubbles/src/reactions.ts
Normal file
114
extensions/bluebubbles/src/reactions.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesReactionOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REACTION_TYPES = new Set([
|
||||||
|
"love",
|
||||||
|
"like",
|
||||||
|
"dislike",
|
||||||
|
"laugh",
|
||||||
|
"emphasize",
|
||||||
|
"question",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REACTION_ALIASES = new Map<string, string>([
|
||||||
|
["heart", "love"],
|
||||||
|
["thumbs_up", "like"],
|
||||||
|
["thumbs-down", "dislike"],
|
||||||
|
["thumbs_down", "dislike"],
|
||||||
|
["haha", "laugh"],
|
||||||
|
["lol", "laugh"],
|
||||||
|
["emphasis", "emphasize"],
|
||||||
|
["exclaim", "emphasize"],
|
||||||
|
["question", "question"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REACTION_EMOJIS = new Map<string, string>([
|
||||||
|
["❤️", "love"],
|
||||||
|
["❤", "love"],
|
||||||
|
["♥️", "love"],
|
||||||
|
["😍", "love"],
|
||||||
|
["👍", "like"],
|
||||||
|
["👎", "dislike"],
|
||||||
|
["😂", "laugh"],
|
||||||
|
["🤣", "laugh"],
|
||||||
|
["😆", "laugh"],
|
||||||
|
["‼️", "emphasize"],
|
||||||
|
["‼", "emphasize"],
|
||||||
|
["❗", "emphasize"],
|
||||||
|
["❓", "question"],
|
||||||
|
["❔", "question"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: params.cfg ?? {},
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
return { baseUrl, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReactionInput(emoji: string, remove?: boolean): string {
|
||||||
|
const trimmed = emoji.trim();
|
||||||
|
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
|
||||||
|
let raw = trimmed.toLowerCase();
|
||||||
|
if (raw.startsWith("-")) raw = raw.slice(1);
|
||||||
|
const aliased = REACTION_ALIASES.get(raw) ?? raw;
|
||||||
|
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
|
||||||
|
if (!REACTION_TYPES.has(mapped)) {
|
||||||
|
throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
|
||||||
|
}
|
||||||
|
return remove ? `-${mapped}` : mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBlueBubblesReaction(params: {
|
||||||
|
chatGuid: string;
|
||||||
|
messageGuid: string;
|
||||||
|
emoji: string;
|
||||||
|
remove?: boolean;
|
||||||
|
partIndex?: number;
|
||||||
|
opts?: BlueBubblesReactionOpts;
|
||||||
|
}): Promise<void> {
|
||||||
|
const chatGuid = params.chatGuid.trim();
|
||||||
|
const messageGuid = params.messageGuid.trim();
|
||||||
|
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
|
||||||
|
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
|
||||||
|
const reaction = normalizeReactionInput(params.emoji, params.remove);
|
||||||
|
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: "/api/v1/message/react",
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
chatGuid,
|
||||||
|
selectedMessageGuid: messageGuid,
|
||||||
|
reaction,
|
||||||
|
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
||||||
|
};
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
params.opts?.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extensions/bluebubbles/src/runtime.ts
Normal file
14
extensions/bluebubbles/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setBlueBubblesRuntime(next: PluginRuntime): void {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlueBubblesRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("BlueBubbles runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
263
extensions/bluebubbles/src/send.ts
Normal file
263
extensions/bluebubbles/src/send.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
blueBubblesFetchWithTimeout,
|
||||||
|
buildBlueBubblesApiUrl,
|
||||||
|
type BlueBubblesSendTarget,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesSendOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueBubblesSendResult = {
|
||||||
|
messageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||||
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
|
if (parsed.kind === "handle") {
|
||||||
|
return {
|
||||||
|
kind: "handle",
|
||||||
|
address: normalizeBlueBubblesHandle(parsed.to),
|
||||||
|
service: parsed.service,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed.kind === "chat_id") {
|
||||||
|
return { kind: "chat_id", chatId: parsed.chatId };
|
||||||
|
}
|
||||||
|
if (parsed.kind === "chat_guid") {
|
||||||
|
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
||||||
|
}
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessageId(payload: unknown): string {
|
||||||
|
if (!payload || typeof payload !== "object") return "unknown";
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
|
||||||
|
const candidates = [
|
||||||
|
record.messageId,
|
||||||
|
record.guid,
|
||||||
|
record.id,
|
||||||
|
data?.messageId,
|
||||||
|
data?.guid,
|
||||||
|
data?.id,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlueBubblesChatRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
||||||
|
const candidates = [
|
||||||
|
chat.chatGuid,
|
||||||
|
chat.guid,
|
||||||
|
chat.chat_guid,
|
||||||
|
chat.identifier,
|
||||||
|
chat.chatIdentifier,
|
||||||
|
chat.chat_identifier,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
||||||
|
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
||||||
|
const raw =
|
||||||
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||||
|
(Array.isArray(chat.handles) ? chat.handles : null) ??
|
||||||
|
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
|
||||||
|
if (!raw) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
out.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry && typeof entry === "object") {
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const candidate =
|
||||||
|
(typeof record.address === "string" && record.address) ||
|
||||||
|
(typeof record.handle === "string" && record.handle) ||
|
||||||
|
(typeof record.id === "string" && record.id) ||
|
||||||
|
(typeof record.identifier === "string" && record.identifier);
|
||||||
|
if (candidate) out.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryChats(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<BlueBubblesChatRecord[]> {
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
path: "/api/v1/chat/query",
|
||||||
|
password: params.password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
with: ["participants"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
|
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
||||||
|
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveChatGuidForTarget(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
target: BlueBubblesSendTarget;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
if (params.target.kind === "chat_guid") return params.target.chatGuid;
|
||||||
|
|
||||||
|
const normalizedHandle =
|
||||||
|
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
|
||||||
|
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
|
||||||
|
const targetChatIdentifier =
|
||||||
|
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
||||||
|
|
||||||
|
const limit = 500;
|
||||||
|
for (let offset = 0; offset < 5000; offset += limit) {
|
||||||
|
const chats = await queryChats({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
password: params.password,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
if (chats.length === 0) break;
|
||||||
|
for (const chat of chats) {
|
||||||
|
if (targetChatId != null) {
|
||||||
|
const chatId = extractChatId(chat);
|
||||||
|
if (chatId != null && chatId === targetChatId) {
|
||||||
|
return extractChatGuid(chat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetChatIdentifier) {
|
||||||
|
const guid = extractChatGuid(chat);
|
||||||
|
if (guid && guid === targetChatIdentifier) return guid;
|
||||||
|
const identifier =
|
||||||
|
typeof chat.identifier === "string"
|
||||||
|
? chat.identifier
|
||||||
|
: typeof chat.chatIdentifier === "string"
|
||||||
|
? chat.chatIdentifier
|
||||||
|
: typeof chat.chat_identifier === "string"
|
||||||
|
? chat.chat_identifier
|
||||||
|
: "";
|
||||||
|
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
||||||
|
}
|
||||||
|
if (normalizedHandle) {
|
||||||
|
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||||
|
normalizeBlueBubblesHandle(entry),
|
||||||
|
);
|
||||||
|
if (participants.includes(normalizedHandle)) {
|
||||||
|
return extractChatGuid(chat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessageBlueBubbles(
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts: BlueBubblesSendOpts = {},
|
||||||
|
): Promise<BlueBubblesSendResult> {
|
||||||
|
const trimmedText = text ?? "";
|
||||||
|
if (!trimmedText.trim()) {
|
||||||
|
throw new Error("BlueBubbles send requires text");
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: opts.cfg ?? {},
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = opts.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
|
||||||
|
const target = resolveSendTarget(to);
|
||||||
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
|
baseUrl,
|
||||||
|
password,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
if (!chatGuid) {
|
||||||
|
throw new Error(
|
||||||
|
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
chatGuid,
|
||||||
|
tempGuid: crypto.randomUUID(),
|
||||||
|
message: trimmedText,
|
||||||
|
method: "apple-script",
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: "/api/v1/message/text",
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
opts.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
const body = await res.text();
|
||||||
|
if (!body) return { messageId: "ok" };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as unknown;
|
||||||
|
return { messageId: extractMessageId(parsed) };
|
||||||
|
} catch {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
|
}
|
||||||
191
extensions/bluebubbles/src/targets.ts
Normal file
191
extensions/bluebubbles/src/targets.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
||||||
|
|
||||||
|
export type BlueBubblesTarget =
|
||||||
|
| { kind: "chat_id"; chatId: number }
|
||||||
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
|
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||||
|
| { kind: "handle"; to: string; service: BlueBubblesService };
|
||||||
|
|
||||||
|
export type BlueBubblesAllowTarget =
|
||||||
|
| { kind: "chat_id"; chatId: number }
|
||||||
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
|
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||||
|
| { kind: "handle"; handle: string };
|
||||||
|
|
||||||
|
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
|
||||||
|
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
|
||||||
|
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
|
||||||
|
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
|
||||||
|
{ prefix: "imessage:", service: "imessage" },
|
||||||
|
{ prefix: "sms:", service: "sms" },
|
||||||
|
{ prefix: "auto:", service: "auto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stripPrefix(value: string, prefix: string): string {
|
||||||
|
return value.slice(prefix.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBlueBubblesHandle(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const lowered = trimmed.toLowerCase();
|
||||||
|
if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9));
|
||||||
|
if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4));
|
||||||
|
if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5));
|
||||||
|
if (trimmed.includes("@")) return trimmed.toLowerCase();
|
||||||
|
return trimmed.replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) throw new Error("BlueBubbles target is required");
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const remainder = stripPrefix(trimmed, prefix);
|
||||||
|
if (!remainder) throw new Error(`${prefix} target is required`);
|
||||||
|
const remainderLower = remainder.toLowerCase();
|
||||||
|
const isChatTarget =
|
||||||
|
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
|
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
|
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
|
remainderLower.startsWith("group:");
|
||||||
|
if (isChatTarget) {
|
||||||
|
return parseBlueBubblesTarget(remainder);
|
||||||
|
}
|
||||||
|
return { kind: "handle", to: remainder, service };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_ID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(chatId)) {
|
||||||
|
throw new Error(`Invalid chat_id: ${value}`);
|
||||||
|
}
|
||||||
|
return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (!value) throw new Error("chat_guid is required");
|
||||||
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (!value) throw new Error("chat_identifier is required");
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith("group:")) {
|
||||||
|
const value = stripPrefix(trimmed, "group:");
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(chatId)) {
|
||||||
|
return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
if (!value) throw new Error("group target is required");
|
||||||
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "handle", to: trimmed, service: "auto" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { kind: "handle", handle: "" };
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
for (const { prefix } of SERVICE_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const remainder = stripPrefix(trimmed, prefix);
|
||||||
|
if (!remainder) return { kind: "handle", handle: "" };
|
||||||
|
return parseBlueBubblesAllowTarget(remainder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_ID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (value) return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (value) return { kind: "chat_identifier", chatIdentifier: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith("group:")) {
|
||||||
|
const value = stripPrefix(trimmed, "group:");
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
||||||
|
if (value) return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedBlueBubblesSender(params: {
|
||||||
|
allowFrom: Array<string | number>;
|
||||||
|
sender: string;
|
||||||
|
chatId?: number | null;
|
||||||
|
chatGuid?: string | null;
|
||||||
|
chatIdentifier?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
||||||
|
if (allowFrom.length === 0) return true;
|
||||||
|
if (allowFrom.includes("*")) return true;
|
||||||
|
|
||||||
|
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
|
||||||
|
const chatId = params.chatId ?? undefined;
|
||||||
|
const chatGuid = params.chatGuid?.trim();
|
||||||
|
const chatIdentifier = params.chatIdentifier?.trim();
|
||||||
|
|
||||||
|
for (const entry of allowFrom) {
|
||||||
|
if (!entry) continue;
|
||||||
|
const parsed = parseBlueBubblesAllowTarget(entry);
|
||||||
|
if (parsed.kind === "chat_id" && chatId !== undefined) {
|
||||||
|
if (parsed.chatId === chatId) return true;
|
||||||
|
} else if (parsed.kind === "chat_guid" && chatGuid) {
|
||||||
|
if (parsed.chatGuid === chatGuid) return true;
|
||||||
|
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
|
||||||
|
if (parsed.chatIdentifier === chatIdentifier) return true;
|
||||||
|
} else if (parsed.kind === "handle" && senderNormalized) {
|
||||||
|
if (parsed.handle === senderNormalized) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBlueBubblesChatTarget(params: {
|
||||||
|
chatId?: number | null;
|
||||||
|
chatGuid?: string | null;
|
||||||
|
chatIdentifier?: string | null;
|
||||||
|
}): string {
|
||||||
|
if (params.chatId && Number.isFinite(params.chatId)) {
|
||||||
|
return `chat_id:${params.chatId}`;
|
||||||
|
}
|
||||||
|
const guid = params.chatGuid?.trim();
|
||||||
|
if (guid) return `chat_guid:${guid}`;
|
||||||
|
const identifier = params.chatIdentifier?.trim();
|
||||||
|
if (identifier) return `chat_identifier:${identifier}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
105
extensions/bluebubbles/src/types.ts
Normal file
105
extensions/bluebubbles/src/types.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||||
|
|
||||||
|
export type BlueBubblesAccountConfig = {
|
||||||
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
|
name?: string;
|
||||||
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
|
capabilities?: string[];
|
||||||
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
|
configWrites?: boolean;
|
||||||
|
/** If false, do not start this BlueBubbles account. Default: true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Base URL for the BlueBubbles API. */
|
||||||
|
serverUrl?: string;
|
||||||
|
/** Password for BlueBubbles API authentication. */
|
||||||
|
password?: string;
|
||||||
|
/** Webhook path for the gateway HTTP server. */
|
||||||
|
webhookPath?: string;
|
||||||
|
/** Direct message access policy (default: pairing). */
|
||||||
|
dmPolicy?: DmPolicy;
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
/** Optional allowlist for group senders. */
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
|
/** Group message handling policy. */
|
||||||
|
groupPolicy?: GroupPolicy;
|
||||||
|
/** Max group messages to keep as history context (0 disables). */
|
||||||
|
historyLimit?: number;
|
||||||
|
/** Max DM turns to keep as history context. */
|
||||||
|
dmHistoryLimit?: number;
|
||||||
|
/** Per-DM config overrides keyed by user ID. */
|
||||||
|
dms?: Record<string, unknown>;
|
||||||
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
|
textChunkLimit?: number;
|
||||||
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: Record<string, unknown>;
|
||||||
|
/** Max outbound media size in MB. */
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueBubblesActionConfig = {
|
||||||
|
reactions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueBubblesConfig = {
|
||||||
|
/** Optional per-account BlueBubbles configuration (multi-account). */
|
||||||
|
accounts?: Record<string, BlueBubblesAccountConfig>;
|
||||||
|
/** Per-action tool gating (default: true for all). */
|
||||||
|
actions?: BlueBubblesActionConfig;
|
||||||
|
} & BlueBubblesAccountConfig;
|
||||||
|
|
||||||
|
export type BlueBubblesSendTarget =
|
||||||
|
| { kind: "chat_id"; chatId: number }
|
||||||
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
|
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||||
|
| { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" };
|
||||||
|
|
||||||
|
export type BlueBubblesAttachment = {
|
||||||
|
guid?: string;
|
||||||
|
uti?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
transferName?: string;
|
||||||
|
totalBytes?: number;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
originalROWID?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export function normalizeBlueBubblesServerUrl(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
}
|
||||||
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||||
|
return withScheme.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBlueBubblesApiUrl(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
path: string;
|
||||||
|
password?: string;
|
||||||
|
}): string {
|
||||||
|
const normalized = normalizeBlueBubblesServerUrl(params.baseUrl);
|
||||||
|
const url = new URL(params.path, `${normalized}/`);
|
||||||
|
if (params.password) {
|
||||||
|
url.searchParams.set("password", params.password);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blueBubblesFetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
skills/bluebubbles/SKILL.md
Normal file
39
skills/bluebubbles/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: bluebubbles
|
||||||
|
description: Build or update the BlueBubbles external channel plugin for Clawdbot (extension package, REST send/probe, webhook inbound).
|
||||||
|
---
|
||||||
|
|
||||||
|
# BlueBubbles plugin
|
||||||
|
|
||||||
|
Use this skill when working on the BlueBubbles channel plugin.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
||||||
|
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
||||||
|
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
|
||||||
|
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
|
||||||
|
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
|
||||||
|
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
|
||||||
|
|
||||||
|
## Internal helpers (use these, not raw API calls)
|
||||||
|
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
|
||||||
|
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
|
||||||
|
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
|
||||||
|
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
|
||||||
|
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
|
||||||
|
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
|
||||||
|
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
- BlueBubbles posts JSON to the gateway HTTP server.
|
||||||
|
- Normalize sender/chat IDs defensively (payloads vary by version).
|
||||||
|
- Skip messages marked as from self.
|
||||||
|
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `clawdbot/plugin-sdk` helpers.
|
||||||
|
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
|
||||||
|
|
||||||
|
## Config (core)
|
||||||
|
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
|
||||||
|
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
|
||||||
|
|
||||||
|
## Message tool notes
|
||||||
|
- **Reactions:** The `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`. Example: `action=react target=+15551234567 messageId=ABC123 emoji=❤️`
|
||||||
@@ -47,6 +47,23 @@ const CATALOG: ChannelPluginCatalogEntry[] = [
|
|||||||
defaultChoice: "npm",
|
defaultChoice: "npm",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "bluebubbles",
|
||||||
|
meta: {
|
||||||
|
id: "bluebubbles",
|
||||||
|
label: "BlueBubbles",
|
||||||
|
selectionLabel: "BlueBubbles (macOS app)",
|
||||||
|
docsPath: "/channels/bluebubbles",
|
||||||
|
docsLabel: "bluebubbles",
|
||||||
|
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||||
|
order: 75,
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
npmSpec: "@clawdbot/bluebubbles",
|
||||||
|
localPath: "extensions/bluebubbles",
|
||||||
|
defaultChoice: "npm",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "zalo",
|
id: "zalo",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type {
|
|||||||
} from "../channels/plugins/types.js";
|
} from "../channels/plugins/types.js";
|
||||||
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||||
export type { ClawdbotPluginApi } from "../plugins/types.js";
|
export type { ClawdbotPluginApi } from "../plugins/types.js";
|
||||||
|
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||||
export type { ClawdbotConfig } from "../config/config.js";
|
export type { ClawdbotConfig } from "../config/config.js";
|
||||||
export type { ChannelDock } from "../channels/dock.js";
|
export type { ChannelDock } from "../channels/dock.js";
|
||||||
export type {
|
export type {
|
||||||
@@ -129,7 +130,7 @@ export {
|
|||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "../channels/plugins/config-helpers.js";
|
} from "../channels/plugins/config-helpers.js";
|
||||||
export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js";
|
export { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js";
|
||||||
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||||
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user