feat: add bluebubbles plugin
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user