Files
elizaos--eliza/plugins/plugin-bluebubbles/src/service.ts
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1540 lines
40 KiB
TypeScript

/**
* Core connector service: connects to the BlueBubbles REST server on a macOS
* host, registers the `bluebubbles`/`imessage` message-connector pair, and
* turns inbound webhook events into agent message memories.
*
* Inbound `new-message` webhooks are gated by DM/group allowlist policy, mapped
* to entity/room UUIDs, persisted, then dispatched through the runtime message
* service; outbound sends, reactions, edits, and unsends resolve a chat GUID via
* BlueBubblesClient. Multi-account aware — binds to the resolved default account
* (see accounts.ts). Phone/email handles are normalized before use.
*/
import * as childProcess from "node:child_process";
import {
ChannelType,
type Content,
type ContentType,
createMessageMemory,
createUniqueUuid,
type Entity,
type HandlerCallback,
type IAgentRuntime,
logger,
type Memory,
MemoryType,
Service,
type UUID,
} from "@elizaos/core";
import {
DEFAULT_ACCOUNT_ID as BLUEBUBBLES_DEFAULT_ACCOUNT_ID,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts";
import { BlueBubblesClient } from "./client";
import { BLUEBUBBLES_SERVICE_NAME, DEFAULT_WEBHOOK_PATH } from "./constants";
import { isHandleAllowed, normalizeHandle } from "./environment";
import { renderBlueBubblesInteractionText } from "./interactions";
import type {
BlueBubblesChat,
BlueBubblesChatState,
BlueBubblesConfig,
BlueBubblesIncomingEvent,
BlueBubblesMessage,
BlueBubblesProbeResult,
BlueBubblesWebhookPayload,
} from "./types";
const AUTOSTART_PROBE_INTERVAL_MS = 1000;
const DEFAULT_AUTOSTART_WAIT_MS = 15000;
const DEFAULT_AUTOSTART_COMMAND = "open";
const DEFAULT_AUTOSTART_ARGS = ["-a", "BlueBubbles"];
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveInteractionAppBaseUrl(
runtime: IAgentRuntime,
): string | undefined {
const rawAppUrl =
runtime.getSetting?.("ELIZA_APP_URL") ||
runtime.getSetting?.("ELIZA_CLOUD_URL");
return typeof rawAppUrl === "string" ? rawAppUrl : undefined;
}
type BlueBubblesAutoStartConfig = {
command: string;
args: string[];
cwd?: string;
waitMs: number;
};
function isLoopbackHostname(hostname: string): boolean {
return (
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
);
}
export function resolveBlueBubblesAutoStartConfig(
config: BlueBubblesConfig | null,
platform = process.platform,
): BlueBubblesAutoStartConfig | null {
if (!config) {
return null;
}
const explicitCommand = config.autoStartCommand?.trim();
const explicitArgs = Array.isArray(config.autoStartArgs)
? config.autoStartArgs
.map((arg) => arg.trim())
.filter((arg) => arg.length > 0)
: [];
const cwd = config.autoStartCwd?.trim() || undefined;
const waitMs =
typeof config.autoStartWaitMs === "number" &&
Number.isFinite(config.autoStartWaitMs) &&
config.autoStartWaitMs >= 0
? config.autoStartWaitMs
: DEFAULT_AUTOSTART_WAIT_MS;
if (explicitCommand) {
return {
command: explicitCommand,
args: explicitArgs,
cwd,
waitMs,
};
}
if (platform !== "darwin") {
return null;
}
try {
const serverUrl = new URL(config.serverUrl);
if (!isLoopbackHostname(serverUrl.hostname)) {
return null;
}
} catch {
return null;
}
return {
command: DEFAULT_AUTOSTART_COMMAND,
args: explicitArgs.length > 0 ? explicitArgs : [...DEFAULT_AUTOSTART_ARGS],
cwd,
waitMs,
};
}
type MessageService = {
handleMessage: (
runtime: IAgentRuntime,
message: Memory,
callback: HandlerCallback,
) => Promise<void>;
};
function getMessageService(runtime: IAgentRuntime): MessageService | null {
if ("messageService" in runtime) {
const withMessageService = runtime as IAgentRuntime & {
messageService?: MessageService | null;
};
return withMessageService.messageService ?? null;
}
return null;
}
type RuntimeWithOptionalConnectorRegistry = IAgentRuntime & {
registerMessageConnector?: (
registration: MessageConnectorRegistration,
) => void;
getMessageConnectors?: () => Array<{ source?: string }>;
};
type RuntimeSendHandler = Parameters<IAgentRuntime["registerSendHandler"]>[1];
type ConnectorTargetInfo = Parameters<RuntimeSendHandler>[1];
type ConnectorContent = Parameters<RuntimeSendHandler>[2];
type MessageConnectorRegistration = Parameters<
IAgentRuntime["registerMessageConnector"]
>[0];
type MessageConnectorTarget = Awaited<
ReturnType<NonNullable<MessageConnectorRegistration["resolveTargets"]>>
>[number];
type MessageConnectorQueryContext = Parameters<
NonNullable<MessageConnectorRegistration["resolveTargets"]>
>[1];
type MessageConnectorChatContext = NonNullable<
Awaited<
ReturnType<NonNullable<MessageConnectorRegistration["getChatContext"]>>
>
>;
type MessageConnectorUserContext = NonNullable<
Awaited<
ReturnType<NonNullable<MessageConnectorRegistration["getUserContext"]>>
>
>;
type ConnectorReadParams = {
target?: ConnectorTargetInfo;
limit?: number;
query?: string;
};
type ConnectorMutationParams = {
target?: ConnectorTargetInfo;
chatGuid?: string;
messageGuid?: string;
messageId?: string;
id?: string;
emoji?: string;
reaction?: string;
text?: string;
content?: ConnectorContent;
};
type AdditiveMessageConnectorHooks = {
fetchMessages?: (
context: MessageConnectorQueryContext,
params?: ConnectorReadParams,
) => Promise<Memory[]>;
searchMessages?: (
context: MessageConnectorQueryContext,
params: ConnectorReadParams & { query: string },
) => Promise<Memory[]>;
reactHandler?: (
runtime: IAgentRuntime,
params: ConnectorMutationParams,
) => Promise<void>;
editHandler?: (
runtime: IAgentRuntime,
params: ConnectorMutationParams,
) => Promise<void>;
deleteHandler?: (
runtime: IAgentRuntime,
params: ConnectorMutationParams,
) => Promise<void>;
};
type ExtendedMessageConnectorRegistration = MessageConnectorRegistration &
AdditiveMessageConnectorHooks;
function registerMessageConnectorIfAvailable(
runtime: IAgentRuntime,
registration: MessageConnectorRegistration,
): void {
const withRegistry = runtime as RuntimeWithOptionalConnectorRegistry;
if (typeof withRegistry.registerMessageConnector === "function") {
withRegistry.registerMessageConnector(registration);
return;
}
if (registration.sendHandler) {
runtime.registerSendHandler(registration.source, registration.sendHandler);
}
}
function hasRegisteredConnector(
runtime: IAgentRuntime,
source: string,
): boolean {
const withRegistry = runtime as RuntimeWithOptionalConnectorRegistry & {
sendHandlers?: unknown;
};
if (typeof withRegistry.getMessageConnectors === "function") {
return withRegistry
.getMessageConnectors()
.some((connector) => connector.source === source);
}
return (
withRegistry.sendHandlers instanceof Map &&
withRegistry.sendHandlers.has(source)
);
}
function normalizedSearchText(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
.replace(/[^a-z0-9@+._;-]+/g, " ")
.trim();
}
function matchesQuery(
query: string,
...values: Array<string | undefined>
): boolean {
const normalizedQuery = normalizedSearchText(query);
if (!normalizedQuery) return true;
const normalizedHandleQuery = normalizedSearchText(normalizeHandle(query));
return values.some((value) => {
const normalizedValue = normalizedSearchText(value);
return (
normalizedValue.includes(normalizedQuery) ||
(normalizedHandleQuery.length > 0 &&
normalizedValue.includes(normalizedHandleQuery))
);
});
}
function normalizeConnectorLimit(
limit: number | undefined,
fallback = 50,
): number {
if (!Number.isFinite(limit) || !limit || limit <= 0) {
return fallback;
}
return Math.min(Math.floor(limit), 200);
}
function filterMemoriesByQuery(
memories: Memory[],
query: string,
limit: number,
): Memory[] {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return memories.slice(0, limit);
}
return memories
.filter((memory) => {
const text =
typeof memory.content?.text === "string" ? memory.content.text : "";
return text.toLowerCase().includes(normalized);
})
.slice(0, limit);
}
function isBlueBubblesChatGuid(value: string): boolean {
return /^(?:iMessage|SMS|RCS);/i.test(value);
}
function normalizeBlueBubblesTarget(value: string): string {
const trimmed = value
.trim()
.replace(/^bluebubbles:/i, "")
.trim();
if (!trimmed) return "";
if (isBlueBubblesChatGuid(trimmed)) return trimmed;
return normalizeHandle(trimmed);
}
function targetKindForBlueBubbles(
value: string,
): "phone" | "email" | "group" | "contact" {
if (isBlueBubblesChatGuid(value) && value.includes(";+;")) return "group";
if (value.includes("@") && !value.endsWith("@g.us")) return "email";
if (/^\+?\d{7,}$/.test(value)) return "phone";
return "contact";
}
function chatLabel(chat: BlueBubblesChat): string {
if (chat.displayName?.trim()) return chat.displayName.trim();
if (chat.participants.length === 1) {
return normalizeBlueBubblesTarget(
chat.participants[0]?.address ?? chat.chatIdentifier,
);
}
return chat.participants
.map((participant) => normalizeBlueBubblesTarget(participant.address))
.filter(Boolean)
.join(", ");
}
function chatToTarget(
chat: BlueBubblesChat,
score = 0.74,
): MessageConnectorTarget {
const label = chatLabel(chat) || chat.chatIdentifier || chat.guid;
const isGroup = chat.participants.length > 1 || chat.guid.includes(";+;");
const directHandle = !isGroup
? normalizeBlueBubblesTarget(
chat.participants[0]?.address ?? chat.chatIdentifier,
)
: "";
const target: ConnectorTargetInfo = {
source: "bluebubbles",
channelId: isGroup ? chat.guid : directHandle || chat.guid,
};
if (!isGroup && directHandle) {
target.entityId = directHandle as UUID;
}
return {
target,
label,
kind: isGroup ? "group" : targetKindForBlueBubbles(directHandle),
description: isGroup
? "BlueBubbles iMessage group chat"
: "BlueBubbles iMessage contact",
score,
metadata: {
chatGuid: chat.guid,
chatIdentifier: chat.chatIdentifier,
participants: chat.participants
.map((participant) => normalizeBlueBubblesTarget(participant.address))
.filter(Boolean)
.join(", "),
},
};
}
function directTarget(
value: string,
score = 0.7,
): MessageConnectorTarget | null {
const normalized = normalizeBlueBubblesTarget(value);
if (!normalized) return null;
return {
target: {
source: "bluebubbles",
channelId: normalized,
entityId: normalized as UUID,
},
label: normalized,
kind: targetKindForBlueBubbles(normalized),
score,
metadata: {
handle: normalized,
},
};
}
async function resolveBlueBubblesSendTarget(
runtime: IAgentRuntime,
target: ConnectorTargetInfo,
): Promise<string | null> {
if (target.channelId?.trim()) {
return normalizeBlueBubblesTarget(target.channelId);
}
if (target.entityId?.trim()) {
return normalizeBlueBubblesTarget(target.entityId);
}
if (target.roomId) {
const room = await runtime.getRoom(target.roomId);
if (room?.channelId) {
return normalizeBlueBubblesTarget(room.channelId);
}
}
return null;
}
function blueBubblesMessageToMemory({
runtime,
message,
chatGuid,
roomId,
source,
config,
accountId = BLUEBUBBLES_DEFAULT_ACCOUNT_ID,
}: {
runtime: IAgentRuntime;
message: BlueBubblesMessage;
chatGuid: string;
roomId: UUID;
source: "bluebubbles" | "imessage";
config: BlueBubblesConfig | null;
accountId?: string;
}): Memory {
const senderHandle = normalizeBlueBubblesTarget(
message.handle?.address ?? message.chats[0]?.chatIdentifier ?? chatGuid,
);
const entityId = message.isFromMe
? runtime.agentId
: (createUniqueUuid(runtime, `bluebubbles-entity:${senderHandle}`) as UUID);
const isGroup =
message.chats[0]?.participants.length > 1 || chatGuid.includes(";+;");
const attachments = message.attachments.map((attachment) => ({
id: attachment.guid,
url: config
? `${config.serverUrl}/api/v1/attachment/${encodeURIComponent(attachment.guid)}?password=${encodeURIComponent(config.password)}`
: "",
title: attachment.transferName,
description: attachment.mimeType ?? undefined,
contentType: (attachment.mimeType ??
"application/octet-stream") as ContentType,
}));
const memory = createMessageMemory({
id: createUniqueUuid(runtime, `bluebubbles:${message.guid}`) as UUID,
agentId: runtime.agentId,
entityId,
roomId,
content: {
text: message.text ?? "",
source,
channelType: isGroup ? ChannelType.GROUP : ChannelType.DM,
...(attachments.length > 0 ? { attachments } : {}),
},
}) as Memory;
memory.createdAt = message.dateCreated;
memory.metadata = {
...(memory.metadata ?? {}),
type: MemoryType.MESSAGE,
source,
provider: "bluebubbles",
// Top-level accountId per MessageMetadata contract. Inbound connector
// stamps this so outbound resolution can route replies back through the
// same connector account.
accountId,
timestamp: message.dateCreated,
entityName: message.handle?.address ?? senderHandle,
entityUserName: senderHandle,
fromBot: message.isFromMe,
fromId: message.isFromMe ? runtime.agentId : senderHandle,
sourceId: entityId,
chatType: isGroup ? ChannelType.GROUP : ChannelType.DM,
messageIdFull: message.guid,
sender: {
id: message.isFromMe ? runtime.agentId : senderHandle,
name: message.handle?.address ?? senderHandle,
username: senderHandle,
},
bluebubbles: {
id: senderHandle,
userId: senderHandle,
username: senderHandle,
userName: senderHandle,
name: message.handle?.address ?? senderHandle,
chatGuid,
messageGuid: message.guid,
},
bluebubblesChatGuid: chatGuid,
bluebubblesMessageGuid: message.guid,
};
return memory;
}
export class BlueBubblesService extends Service {
static serviceType = BLUEBUBBLES_SERVICE_NAME;
capabilityDescription =
"The agent is able to send and receive iMessages via BlueBubbles";
private client: BlueBubblesClient | null = null;
private blueBubblesConfig: BlueBubblesConfig | null = null;
private accountId: string = BLUEBUBBLES_DEFAULT_ACCOUNT_ID;
private knownChats: Map<string, BlueBubblesChat> = new Map();
private entityCache: Map<string, UUID> = new Map();
private roomCache: Map<string, UUID> = new Map();
private webhookPath: string = DEFAULT_WEBHOOK_PATH;
private isRunning = false;
constructor(runtime?: IAgentRuntime) {
super(runtime);
if (!runtime) return;
this.accountId = resolveDefaultBlueBubblesAccountId(runtime);
const account = resolveBlueBubblesAccount(runtime, this.accountId);
this.blueBubblesConfig = account.config;
if (!this.blueBubblesConfig) {
logger.warn(
`BlueBubbles account ${this.accountId} is not configured - BlueBubbles functionality will be unavailable`,
);
return;
}
if (!this.blueBubblesConfig.enabled) {
logger.info("BlueBubbles plugin is disabled via configuration");
return;
}
this.webhookPath =
this.blueBubblesConfig.webhookPath ?? DEFAULT_WEBHOOK_PATH;
this.client = new BlueBubblesClient(this.blueBubblesConfig);
}
static async start(runtime: IAgentRuntime): Promise<BlueBubblesService> {
const service = new BlueBubblesService(runtime);
if (!service.client) {
logger.warn(
"BlueBubbles service started without client functionality - no configuration provided",
);
return service;
}
try {
// Probe the server to verify connectivity
let probeResult = await service.client.probe();
if (!probeResult.ok) {
probeResult = await service.tryAutoStartServer(probeResult);
}
if (!probeResult.ok) {
logger.warn(
`BlueBubbles server unavailable at startup: ${probeResult.error}. Continuing without BlueBubbles connectivity.`,
);
return service;
}
logger.success(
`Connected to BlueBubbles server v${probeResult.serverVersion} on macOS ${probeResult.osVersion}`,
);
if (probeResult.privateApiEnabled) {
logger.info(
"BlueBubbles Private API is enabled - edit and unsend features available",
);
}
// Initialize known chats
await service.initializeChats();
service.isRunning = true;
logger.success(
`BlueBubbles service started for ${runtime.character.name}`,
);
} catch (error) {
logger.error(
`Failed to start BlueBubbles service: ${error instanceof Error ? error.message : String(error)}`,
);
}
return service;
}
private getAutoStartConfig(): BlueBubblesAutoStartConfig | null {
return resolveBlueBubblesAutoStartConfig(this.blueBubblesConfig);
}
private async spawnAutoStartProcess(
command: string,
args: string[],
cwd?: string,
): Promise<void> {
await new Promise<void>((resolve, reject) => {
let settled = false;
const child = childProcess.spawn(command, args, {
cwd,
stdio: "ignore",
detached: process.platform !== "win32",
});
const cleanup = () => {
child.removeListener("error", onError);
child.removeListener("spawn", onSpawn);
};
const onError = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onSpawn = () => {
if (settled) return;
settled = true;
cleanup();
child.unref();
resolve();
};
child.once("error", onError);
child.once("spawn", onSpawn);
});
}
private async tryAutoStartServer(
initialProbe: BlueBubblesProbeResult,
): Promise<BlueBubblesProbeResult> {
if (!this.client) {
return initialProbe;
}
const autoStart = this.getAutoStartConfig();
if (!autoStart) {
return initialProbe;
}
const commandPreview = [autoStart.command, ...autoStart.args]
.map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
.join(" ");
logger.info(
`Attempting to auto-start BlueBubbles server: ${commandPreview}`,
);
try {
await this.spawnAutoStartProcess(
autoStart.command,
autoStart.args,
autoStart.cwd,
);
} catch (error) {
return {
ok: false,
error: `auto-start command failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
let probeResult = await this.client.probe();
const deadline = Date.now() + autoStart.waitMs;
while (!probeResult.ok && Date.now() < deadline) {
const sleepMs = Math.min(
AUTOSTART_PROBE_INTERVAL_MS,
Math.max(0, deadline - Date.now()),
);
if (sleepMs <= 0) {
break;
}
await delay(sleepMs);
probeResult = await this.client.probe();
}
if (!probeResult.ok && autoStart.waitMs > 0) {
return {
ok: false,
error:
`auto-start did not make BlueBubbles reachable within ${autoStart.waitMs}ms` +
(probeResult.error ? `: ${probeResult.error}` : ""),
};
}
return probeResult;
}
static registerSendHandlers(
runtime: IAgentRuntime,
service: BlueBubblesService,
): void {
const register = (source: "bluebubbles" | "imessage") => {
const registration = {
source,
label: source === "imessage" ? "iMessage (BlueBubbles)" : "BlueBubbles",
capabilities: [
"send_message",
"reply",
"reactions",
"effects",
"chat_context",
],
supportedTargetKinds: [
"phone",
"email",
"contact",
"user",
"group",
"room",
],
contexts: ["phone", "social", "connectors"],
description:
"Send iMessage/SMS through the BlueBubbles bridge using contact handles or chat GUIDs.",
metadata: {
aliases:
source === "imessage"
? ["imessage", "sms", "text", "messages", "bluebubbles"]
: ["bluebubbles", "imessage", "sms", "text"],
bridge: "bluebubbles",
status: service.getIsRunning() ? "connected" : "not_connected",
},
sendHandler: async (
_runtime: IAgentRuntime,
target: ConnectorTargetInfo,
content: ConnectorContent,
) => {
const text = renderBlueBubblesInteractionText(
content,
resolveInteractionAppBaseUrl(runtime),
).trim();
if (!text) {
return;
}
const chatGuid = await resolveBlueBubblesSendTarget(runtime, target);
if (!chatGuid) {
throw new Error("BlueBubbles target is missing a chat GUID");
}
let selectedMessageGuid: string | undefined;
if (
typeof content.inReplyTo === "string" &&
content.inReplyTo.trim().length > 0
) {
const repliedToMemory = await runtime.getMemoryById(
content.inReplyTo as UUID,
);
const metadata = repliedToMemory?.metadata as
| Record<string, unknown>
| undefined;
const replyGuid = metadata?.bluebubblesMessageGuid;
if (typeof replyGuid === "string" && replyGuid.trim().length > 0) {
selectedMessageGuid = replyGuid.trim();
}
}
const result = await service.sendMessage(
chatGuid,
text,
selectedMessageGuid,
);
if (!target.roomId) {
return;
}
const memory = createMessageMemory({
id: createUniqueUuid(runtime, `bluebubbles:${result.guid}`) as UUID,
entityId: runtime.agentId,
roomId: target.roomId,
content: {
...content,
text,
source: "bluebubbles",
},
}) as Memory;
memory.createdAt = result.dateCreated;
memory.metadata = {
...(memory.metadata ?? {}),
type: MemoryType.MESSAGE,
accountId: service.accountId,
bluebubblesChatGuid: chatGuid,
bluebubblesMessageGuid: result.guid,
messageIdFull: result.guid,
};
return memory;
},
resolveTargets: async (query: string) => {
const candidates: MessageConnectorTarget[] = [];
for (const chat of await service.listChats()) {
const candidate = chatToTarget(chat, 0.78);
if (
matchesQuery(
query,
candidate.label,
chat.guid,
chat.chatIdentifier,
...chat.participants.map((participant) => participant.address),
)
) {
candidates.push(candidate);
}
}
const direct = directTarget(query, 0.72);
if (direct) candidates.push(direct);
return candidates;
},
listRecentTargets: async () =>
(await service.listChats()).map((chat) => chatToTarget(chat, 0.66)),
listRooms: async () =>
(await service.listChats()).map((chat) => chatToTarget(chat, 0.7)),
fetchMessages: async (context, params) => {
const limit = normalizeConnectorLimit(params?.limit);
const target = params?.target ?? context.target;
const resolvedTarget = target
? await resolveBlueBubblesSendTarget(context.runtime, target)
: null;
if (service.client && resolvedTarget) {
const chatGuid = await service.client.resolveTarget(resolvedTarget);
const roomId =
target?.roomId ??
(createUniqueUuid(
context.runtime,
`bluebubbles-room:${chatGuid}`,
) as UUID);
const messages = await service.client
.getMessages(chatGuid, limit)
.catch(() => []);
if (messages.length > 0) {
return messages
.map((message) =>
blueBubblesMessageToMemory({
runtime: context.runtime,
message,
chatGuid,
roomId,
source,
config: service.blueBubblesConfig,
accountId: service.accountId,
}),
)
.sort(
(left, right) =>
(right.createdAt ?? 0) - (left.createdAt ?? 0),
)
.slice(0, limit);
}
}
if (target?.roomId) {
return context.runtime.getMemories({
tableName: "messages",
roomId: target.roomId,
limit,
orderBy: "createdAt",
orderDirection: "desc",
});
}
const targets = (await service.listChats())
.slice(0, 10)
.map((chat) => chatToTarget(chat, 0.66));
const roomIds = Array.from(
new Set(
targets
.map((candidate) => candidate.target.roomId)
.filter((roomId): roomId is UUID => Boolean(roomId)),
),
);
const chunks = await Promise.all(
roomIds.map((roomId) =>
context.runtime.getMemories({
tableName: "messages",
roomId,
limit,
orderBy: "createdAt",
orderDirection: "desc",
}),
),
);
return chunks
.flat()
.sort(
(left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0),
)
.slice(0, limit);
},
searchMessages: async (context, params) => {
const limit = normalizeConnectorLimit(params?.limit);
const messages = await registration.fetchMessages?.(context, {
target: params?.target ?? context.target,
limit: Math.max(limit, 100),
});
return filterMemoriesByQuery(messages ?? [], params.query, limit);
},
reactHandler: async (handlerRuntime, params) => {
const mutationParams = params as ConnectorMutationParams;
const target = params.target ?? ({ source } as ConnectorTargetInfo);
const resolvedTarget = await resolveBlueBubblesSendTarget(
handlerRuntime,
target,
);
const chatGuid = mutationParams.chatGuid ?? resolvedTarget;
const messageGuid = String(
mutationParams.messageGuid ??
params.messageId ??
mutationParams.id ??
"",
).trim();
const reaction = String(
mutationParams.reaction ?? params.emoji ?? "",
).trim();
if (!chatGuid || !messageGuid || !reaction) {
throw new Error(
"BlueBubbles reactHandler requires chat, message guid, and reaction",
);
}
const result = await service.sendReaction(
chatGuid,
messageGuid,
reaction,
);
if (!result.success) {
throw new Error("BlueBubbles reaction failed");
}
},
editHandler: async (_handlerRuntime, params) => {
const mutationParams = params as ConnectorMutationParams;
if (!service.client) {
throw new Error("BlueBubbles client not initialized");
}
const messageGuid = String(
mutationParams.messageGuid ??
params.messageId ??
mutationParams.id ??
"",
).trim();
const text = String(
mutationParams.text ?? params.content?.text ?? "",
).trim();
if (!messageGuid || !text) {
throw new Error(
"BlueBubbles editHandler requires message guid and text",
);
}
await service.client.editMessage(messageGuid, text);
},
deleteHandler: async (_handlerRuntime, params) => {
const mutationParams = params as ConnectorMutationParams;
if (!service.client) {
throw new Error("BlueBubbles client not initialized");
}
const messageGuid = String(
mutationParams.messageGuid ??
params.messageId ??
mutationParams.id ??
"",
).trim();
if (!messageGuid) {
throw new Error("BlueBubbles deleteHandler requires message guid");
}
await service.client.unsendMessage(messageGuid);
},
getChatContext: async (
target: ConnectorTargetInfo,
context: MessageConnectorQueryContext,
): Promise<MessageConnectorChatContext | null> => {
const chatGuid = await resolveBlueBubblesSendTarget(
context.runtime,
target,
);
if (!chatGuid) return null;
const chatState = await service.getChatState(chatGuid);
if (!chatState) {
return {
target,
label: chatGuid,
summary: "BlueBubbles chat context unavailable for this target.",
metadata: { chatGuid },
};
}
return {
target,
label: chatState.displayName ?? chatState.chatIdentifier,
summary: chatState.isGroup
? "BlueBubbles iMessage group chat."
: "BlueBubbles iMessage direct chat.",
metadata: {
...chatState,
participants: chatState.participants.join(", "),
},
};
},
getUserContext: async (
entityId: string | UUID,
): Promise<MessageConnectorUserContext | null> => {
const handle = normalizeBlueBubblesTarget(String(entityId));
if (!handle) return null;
return {
entityId,
label: handle,
aliases: [handle],
handles: {
bluebubbles: handle,
...(handle.includes("@") ? { email: handle } : { phone: handle }),
},
metadata: { normalizedHandle: handle },
};
},
} as ExtendedMessageConnectorRegistration;
registerMessageConnectorIfAvailable(runtime, registration);
};
register("bluebubbles");
if (!hasRegisteredConnector(runtime, "imessage")) {
register("imessage");
}
}
static async stopRuntime(runtime: IAgentRuntime): Promise<void> {
const service = runtime.getService<BlueBubblesService>(
BLUEBUBBLES_SERVICE_NAME,
);
if (service) {
await service.stop();
}
}
async stop(): Promise<void> {
this.isRunning = false;
logger.info("BlueBubbles service stopped");
}
/**
* Gets the BlueBubbles client
*/
getClient(): BlueBubblesClient | null {
return this.client;
}
/**
* Gets the current configuration
*/
getConfig(): BlueBubblesConfig | null {
return this.blueBubblesConfig;
}
getAccountId(): string {
return this.accountId;
}
/**
* Checks if the service is running
*/
getIsRunning(): boolean {
return this.isRunning;
}
/**
* Gets the webhook path for receiving messages
*/
getWebhookPath(): string {
return this.webhookPath;
}
async listChats(limit = 100): Promise<BlueBubblesChat[]> {
if (this.client) {
try {
const chats = await this.client.listChats(limit);
for (const chat of chats) {
this.knownChats.set(chat.guid, chat);
}
} catch (error) {
logger.debug(
`Failed to list BlueBubbles chats: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return Array.from(this.knownChats.values());
}
/**
* Initializes known chats from the server
*/
private async initializeChats(): Promise<void> {
if (!this.client) return;
try {
const chats = await this.client.listChats(100);
for (const chat of chats) {
this.knownChats.set(chat.guid, chat);
}
logger.info(`Loaded ${chats.length} BlueBubbles chats`);
} catch (error) {
logger.error(
`Failed to load BlueBubbles chats: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Handles an incoming webhook payload
*/
async handleWebhook(payload: BlueBubblesWebhookPayload): Promise<void> {
if (!this.blueBubblesConfig || !this.client) {
logger.warn("Received webhook but BlueBubbles service is not configured");
return;
}
const event: BlueBubblesIncomingEvent = {
type: payload.type as BlueBubblesIncomingEvent["type"],
data: payload.data,
};
switch (event.type) {
case "new-message":
await this.handleIncomingMessage(event.data as BlueBubblesMessage);
break;
case "updated-message":
await this.handleMessageUpdate(event.data as BlueBubblesMessage);
break;
case "chat-updated":
await this.handleChatUpdate(event.data as BlueBubblesChat);
break;
case "typing-indicator":
case "read-receipt":
// These events can be logged but don't require action
logger.debug(
`BlueBubbles ${event.type}: ${JSON.stringify(event.data)}`,
);
break;
default:
logger.debug(`Unhandled BlueBubbles event: ${event.type}`);
}
}
/**
* Handles an incoming message
*/
private async handleIncomingMessage(
message: BlueBubblesMessage,
): Promise<void> {
// Skip outgoing messages
if (message.isFromMe) {
return;
}
// Skip system messages
if (message.isSystemMessage) {
return;
}
const config = this.blueBubblesConfig;
if (!config) {
return;
}
const chat = message.chats[0];
if (!chat) {
logger.warn(`Received message without chat info: ${message.guid}`);
return;
}
const isGroup = chat.participants.length > 1;
const senderHandle = message.handle?.address ?? "";
// Check access policies
if (isGroup) {
if (
!isHandleAllowed(
senderHandle,
config.groupAllowFrom ?? [],
config.groupPolicy ?? "allowlist",
)
) {
logger.debug(
`Ignoring message from ${senderHandle} - not in group allowlist`,
);
return;
}
} else {
if (
!isHandleAllowed(
senderHandle,
config.allowFrom ?? [],
config.dmPolicy ?? "pairing",
)
) {
logger.debug(
`Ignoring message from ${senderHandle} - not in DM allowlist`,
);
return;
}
}
// Mark as read if configured
if (config.sendReadReceipts && this.client) {
try {
await this.client.markChatRead(chat.guid);
} catch (error) {
logger.debug(`Failed to mark chat as read: ${error}`);
}
}
const entityId = await this.getOrCreateEntity(
senderHandle,
message.handle?.address,
);
const roomId = await this.getOrCreateRoom(chat);
const worldId = createUniqueUuid(this.runtime, "bluebubbles-world") as UUID;
const replyToGuid = message.threadOriginatorGuid?.trim() || "";
const replyToMessageId = replyToGuid
? (createUniqueUuid(this.runtime, `bluebubbles:${replyToGuid}`) as UUID)
: undefined;
const attachments = message.attachments.map((att) => ({
id: att.guid,
url: `${config.serverUrl}/api/v1/attachment/${encodeURIComponent(att.guid)}?password=${encodeURIComponent(config.password)}`,
title: att.transferName,
description: att.mimeType ?? undefined,
contentType: (att.mimeType ?? "application/octet-stream") as ContentType,
}));
await this.runtime.ensureConnection({
entityId,
roomId,
worldId,
worldName: "iMessage",
userId: senderHandle as UUID,
userName: senderHandle,
name: message.handle?.address ?? senderHandle,
source: "bluebubbles",
type: isGroup ? ChannelType.GROUP : ChannelType.DM,
channelId: chat.guid,
roomName: chat.displayName ?? chat.chatIdentifier,
metadata: {
bluebubblesChatGuid: chat.guid,
bluebubblesChatIdentifier: chat.chatIdentifier,
bluebubblesHandle: senderHandle,
},
});
const memory = createMessageMemory({
id: createUniqueUuid(this.runtime, `bluebubbles:${message.guid}`) as UUID,
agentId: this.runtime.agentId,
entityId,
roomId,
content: {
text: message.text ?? "",
source: "bluebubbles",
...(replyToMessageId ? { inReplyTo: replyToMessageId } : {}),
...(attachments.length > 0 ? { attachments } : {}),
},
}) as Memory;
memory.createdAt = message.dateCreated;
memory.metadata = {
...(memory.metadata ?? {}),
source: "bluebubbles",
provider: "bluebubbles",
// Top-level accountId per MessageMetadata contract. Inbound connector
// stamps this so outbound resolution can route replies back through
// the same connector account.
accountId: this.accountId,
timestamp: message.dateCreated,
entityName: message.handle?.address ?? senderHandle,
entityUserName: senderHandle,
fromId: senderHandle,
sourceId: entityId,
chatType: isGroup ? ChannelType.GROUP : ChannelType.DM,
messageIdFull: message.guid,
sender: {
id: senderHandle,
name: message.handle?.address ?? senderHandle,
username: senderHandle,
},
bluebubbles: {
id: senderHandle,
userId: senderHandle,
username: senderHandle,
userName: senderHandle,
name: message.handle?.address ?? senderHandle,
chatGuid: chat.guid,
chatIdentifier: chat.chatIdentifier,
messageGuid: message.guid,
},
bluebubblesChatGuid: chat.guid,
bluebubblesChatIdentifier: chat.chatIdentifier,
bluebubblesMessageGuid: message.guid,
bluebubblesThreadOriginatorGuid:
message.threadOriginatorGuid ?? undefined,
} as Memory["metadata"];
await this.runtime.createMemory(memory, "messages");
const room = await this.runtime.getRoom(roomId);
if (!room) {
logger.warn(
`BlueBubbles room ${roomId} not found after ensureConnection`,
);
return;
}
await this.processMessage(memory, room, chat.guid);
}
/**
* Handles a message update (edit, unsend, etc.)
*/
private async handleMessageUpdate(
message: BlueBubblesMessage,
): Promise<void> {
// Handle edited or unsent messages
if (message.dateEdited) {
logger.debug(`Message ${message.guid} was edited`);
}
}
/**
* Handles a chat update
*/
private async handleChatUpdate(chat: BlueBubblesChat): Promise<void> {
this.knownChats.set(chat.guid, chat);
logger.debug(
`Chat ${chat.guid} updated: ${chat.displayName ?? chat.chatIdentifier}`,
);
}
/**
* Gets or creates an entity for a BlueBubbles handle
*/
private async getOrCreateEntity(
handle: string,
displayName?: string,
): Promise<UUID> {
const normalized = normalizeHandle(handle);
const cached = this.entityCache.get(normalized);
if (cached) {
return cached;
}
const entityId = createUniqueUuid(
this.runtime,
`bluebubbles:${normalized}`,
) as UUID;
// Check if entity exists
const existing = await this.runtime.getEntityById(entityId);
if (!existing) {
const entity: Entity = {
id: entityId,
agentId: this.runtime.agentId,
names: displayName ? [displayName, normalized] : [normalized],
metadata: {
bluebubbles: {
handle: normalized,
displayName: displayName ?? normalized,
},
},
};
await this.runtime.createEntity(entity);
}
this.entityCache.set(normalized, entityId);
return entityId;
}
/**
* Gets or creates a room for a BlueBubbles chat
*/
private async getOrCreateRoom(chat: BlueBubblesChat): Promise<UUID> {
const cached = this.roomCache.get(chat.guid);
if (cached) {
return cached;
}
const roomId = createUniqueUuid(
this.runtime,
`bluebubbles:${chat.guid}`,
) as UUID;
this.roomCache.set(chat.guid, roomId);
return roomId;
}
/**
* Sends a message to a target
*/
async sendMessage(
target: string,
text: string,
replyToMessageGuid?: string,
): Promise<{ guid: string; dateCreated: number }> {
if (!this.client) {
throw new Error("BlueBubbles client not initialized");
}
const chatGuid = await this.client.resolveTarget(target);
const result = await this.client.sendMessage(chatGuid, text, {
...(replyToMessageGuid
? { selectedMessageGuid: replyToMessageGuid }
: {}),
});
return {
guid: result.guid,
dateCreated: result.dateCreated,
};
}
private async processMessage(
memory: Memory,
room: { id: UUID; channelId?: string | null },
chatGuid: string,
): Promise<void> {
const messageService = getMessageService(this.runtime);
if (!messageService) {
return;
}
const callback: HandlerCallback = async (
response: Content,
): Promise<Memory[]> => {
const responseText = renderBlueBubblesInteractionText(
response,
resolveInteractionAppBaseUrl(this.runtime),
).trim();
if (!responseText) {
return [];
}
let selectedMessageGuid: string | undefined;
if (
typeof memory.id === "string" &&
memory.metadata &&
typeof (memory.metadata as Record<string, unknown>)
.bluebubblesMessageGuid === "string"
) {
selectedMessageGuid = (memory.metadata as Record<string, unknown>)
.bluebubblesMessageGuid as string;
}
const sent = await this.sendMessage(
chatGuid,
responseText,
selectedMessageGuid,
);
const responseMemory = createMessageMemory({
id: createUniqueUuid(this.runtime, `bluebubbles:${sent.guid}`) as UUID,
agentId: this.runtime.agentId,
entityId: this.runtime.agentId,
roomId: room.id,
content: {
...response,
text: responseText,
source: "bluebubbles",
inReplyTo: memory.id,
},
}) as Memory;
responseMemory.createdAt = Date.now();
responseMemory.metadata = {
...(responseMemory.metadata ?? {}),
type: MemoryType.MESSAGE,
accountId: this.accountId,
bluebubblesChatGuid: chatGuid,
bluebubblesMessageGuid: sent.guid,
};
await this.runtime.createMemory(responseMemory, "messages");
return [responseMemory];
};
await messageService.handleMessage(this.runtime, memory, callback);
}
/**
* Gets the state for a chat
*/
async getChatState(chatGuid: string): Promise<BlueBubblesChatState | null> {
const chat = this.knownChats.get(chatGuid);
if (!chat && this.client) {
try {
const fetchedChat = await this.client.getChat(chatGuid);
this.knownChats.set(chatGuid, fetchedChat);
return this.chatToState(fetchedChat);
} catch {
return null;
}
}
if (!chat) {
return null;
}
return this.chatToState(chat);
}
private chatToState(chat: BlueBubblesChat): BlueBubblesChatState {
return {
chatGuid: chat.guid,
chatIdentifier: chat.chatIdentifier,
isGroup: chat.participants.length > 1,
participants: chat.participants.map((p) => p.address),
displayName: chat.displayName,
lastMessageAt: chat.lastMessage?.dateCreated ?? null,
hasUnread: chat.hasUnreadMessages,
};
}
/**
* Checks if the service is connected
*/
isConnected(): boolean {
return this.isRunning && this.client !== null;
}
/**
* Sends a reaction to a message
*/
async sendReaction(
chatGuid: string,
messageGuid: string,
reaction: string,
): Promise<{ success: boolean }> {
if (!this.client) {
throw new Error("BlueBubbles client not initialized");
}
try {
await this.client.reactToMessage(chatGuid, messageGuid, reaction);
return { success: true };
} catch (error) {
logger.error(
`Failed to send reaction: ${error instanceof Error ? error.message : String(error)}`,
);
return { success: false };
}
}
}