diff --git a/README.md b/README.md index bb1a8d1..85d4040 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard - **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing -- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Microsoft Teams, and Google Chat with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Microsoft Teams, Google Chat, and iMessage (BlueBubbles) with unified adapter interface - **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor - **Model Switching**: Switch between cloud/local models on demand - **Session Persistence**: SQLite-backed conversation history @@ -172,6 +172,18 @@ google_chat: # Google Chat messaging endpoint should point to: # POST https:///google-chat/events +# Optional: iMessage via BlueBubbles +bluebubbles: + endpoint: "http://localhost:1234" + api_key: "${BLUEBUBBLES_API_KEY}" + webhook_token: "${BLUEBUBBLES_WEBHOOK_TOKEN}" + allowed_chat_guids: [] + require_mention: true + mention_name: "flynn" + +# BlueBubbles webhook endpoint should point to: +# POST https:///bluebubbles/events + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index b413c98..629d0ee 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -36,6 +36,15 @@ telegram: # allowed_space_names: [] # Empty = allow all spaces # require_mention: true +# Optional: iMessage via BlueBubbles +# bluebubbles: +# endpoint: http://localhost:1234 +# api_key: ${BLUEBUBBLES_API_KEY} +# webhook_token: ${BLUEBUBBLES_WEBHOOK_TOKEN} +# allowed_chat_guids: [] # Empty = allow all chats +# require_mention: true +# mention_name: flynn + server: # Tailscale Serve config (optional). Enable `serve: true` to expose the # gateway to your tailnet via `tailscale serve`. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 18d438d..003c079 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -151,6 +151,7 @@ Exceptions (handled by their own trust/auth model and therefore bypass gateway t - `POST /gmail/push` (Google Pub/Sub push) - `POST /teams/events` (Microsoft Bot Framework activity callback) - `POST /google-chat/events` (Google Chat event callback, optional webhook token check) +- `POST /bluebubbles/events` (BlueBubbles iMessage webhook callback, optional webhook token check) ## Message Format diff --git a/src/channels/bluebubbles/adapter.test.ts b/src/channels/bluebubbles/adapter.test.ts new file mode 100644 index 0000000..bddd755 --- /dev/null +++ b/src/channels/bluebubbles/adapter.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { BlueBubblesAdapter } from './adapter.js'; +import type { InboundMessage } from '../types.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function makeRes() { + const state = { + status: 0, + body: '', + }; + return { + state, + res: { + writeHead: (status: number) => { + state.status = status; + }, + end: (chunk?: string) => { + state.body = chunk ?? ''; + }, + }, + }; +} + +function makeReq(body: unknown, headers?: Record) { + const chunks = [Buffer.from(JSON.stringify(body), 'utf8')]; + return { + headers: headers ?? {}, + on(event: string, handler: (...args: unknown[]) => void) { + if (event === 'data') { + for (const chunk of chunks) { + handler(chunk); + } + } + if (event === 'end') { + handler(); + } + return this; + }, + off() { + return this; + }, + destroy() { + return this; + }, + }; +} + +describe('BlueBubblesAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + it('has name bluebubbles and starts disconnected', () => { + const adapter = new BlueBubblesAdapter({ endpoint: 'http://localhost:1234', apiKey: 'key' }); + expect(adapter.name).toBe('bluebubbles'); + expect(adapter.status).toBe('disconnected'); + }); + + it('send posts text to BlueBubbles API', async () => { + const adapter = new BlueBubblesAdapter({ endpoint: 'http://localhost:1234', apiKey: 'key' }); + await adapter.connect(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => '', + } as Response); + + await adapter.send('chat123', { text: 'hello imessage' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe('http://localhost:1234/api/v1/message/text'); + const init = mockFetch.mock.calls[0]?.[1] as RequestInit; + expect((init.headers as Record).Authorization).toBe('Bearer key'); + }); + + it('handleEvent forwards inbound message and sets replyPeerId', async () => { + const adapter = new BlueBubblesAdapter({ endpoint: 'http://localhost:1234', apiKey: 'key', requireMention: false }); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleEvent({ + message: { + guid: 'm1', + text: 'hello', + chatGuid: 'chat123', + sender: { displayName: 'Alice' }, + }, + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0]?.[0] as InboundMessage; + expect(msg.channel).toBe('bluebubbles'); + expect(msg.senderId).toBe('chat123'); + expect((msg.metadata as Record).replyPeerId).toBe('chat123'); + }); + + it('handleRequest enforces webhook token when configured', async () => { + const adapter = new BlueBubblesAdapter({ + endpoint: 'http://localhost:1234', + apiKey: 'key', + webhookToken: 'secret-token', + requireMention: false, + }); + const handler = vi.fn(); + adapter.onMessage(handler); + + const { res, state } = makeRes(); + const req = makeReq({ + message: { text: 'hello', chatGuid: 'chat123' }, + }); + await adapter.handleRequest(req as never, res as never); + + expect(state.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/bluebubbles/adapter.ts b/src/channels/bluebubbles/adapter.ts new file mode 100644 index 0000000..b96fd9c --- /dev/null +++ b/src/channels/bluebubbles/adapter.ts @@ -0,0 +1,196 @@ +import type { IncomingMessage, ServerResponse } from 'http'; + +import type { + InboundMessage, + OutboundMessage, + ChannelAdapter, + ChannelStatus, +} from '../types.js'; +import { shouldIgnoreForMissingMention, splitMessage } from '../utils.js'; +import { readRequestBody } from '../../utils/httpBody.js'; + +export interface BlueBubblesAdapterConfig { + endpoint: string; + apiKey: string; + webhookToken?: string; + allowedChatGuids?: string[]; + requireMention?: boolean; + mentionName?: string; +} + +interface BlueBubblesWebhookEvent { + token?: string; + message?: { + guid?: string; + text?: string; + chatGuid?: string; + isFromMe?: boolean; + sender?: { displayName?: string }; + }; + data?: { + guid?: string; + text?: string; + chatGuid?: string; + isFromMe?: boolean; + sender?: { displayName?: string }; + }; + event?: string; +} + +const MAX_MESSAGE_LENGTH = 3500; + +export class BlueBubblesAdapter implements ChannelAdapter { + readonly name = 'bluebubbles'; + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + + constructor(private readonly config: BlueBubblesAdapterConfig) {} + + get status(): ChannelStatus { + return this._status; + } + + onMessage(handler: (msg: InboundMessage) => void): void { + this.messageHandler = handler; + } + + async connect(): Promise { + this._status = 'connected'; + } + + async disconnect(): Promise { + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + if (this._status !== 'connected') { + throw new Error('BlueBubbles adapter not connected'); + } + + const text = message.text.trim(); + if (!text) { + return; + } + + const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text]; + for (const chunk of chunks) { + await this.sendText(peerId, chunk); + } + } + + async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + let body = ''; + try { + body = await readRequestBody(req, { maxBytes: 1_048_576 }); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request body' })); + return; + } + + let payload: BlueBubblesWebhookEvent; + try { + payload = JSON.parse(body) as BlueBubblesWebhookEvent; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + if (this.config.webhookToken) { + const headerToken = req.headers['x-bluebubbles-token']; + const token = typeof headerToken === 'string' ? headerToken : payload.token; + if (token !== this.config.webhookToken) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid webhook token' })); + return; + } + } + + await this.handleEvent(payload); + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ accepted: true })); + } + + async handleEvent(payload: BlueBubblesWebhookEvent): Promise { + if (!this.messageHandler) { + return; + } + const message = payload.message ?? payload.data; + if (!message) { + return; + } + if (message.isFromMe) { + return; + } + + const chatGuid = message.chatGuid?.trim(); + if (!chatGuid) { + return; + } + if (this.config.allowedChatGuids && this.config.allowedChatGuids.length > 0) { + if (!this.config.allowedChatGuids.includes(chatGuid)) { + return; + } + } + + const text = (message.text ?? '').trim(); + if (!text) { + return; + } + + const mentionName = this.config.mentionName ?? 'flynn'; + const mentionsBot = new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i').test(text); + const isGroup = chatGuid.startsWith('chat'); + if (shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot: !isGroup || mentionsBot, + })) { + return; + } + + const cleaned = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim(); + if (!cleaned) { + return; + } + + this.messageHandler({ + id: message.guid ?? `bluebubbles-${Date.now()}`, + channel: 'bluebubbles', + senderId: chatGuid, + senderName: message.sender?.displayName, + text: cleaned, + timestamp: Date.now(), + metadata: { + chatGuid, + event: payload.event, + replyPeerId: chatGuid, + }, + }); + } + + private async sendText(chatGuid: string, text: string): Promise { + const endpoint = this.config.endpoint.replace(/\/+$/, ''); + const response = await fetch(`${endpoint}/api/v1/message/text`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify({ + chatGuid, + message: text, + }), + }); + + if (!response.ok) { + const responseBody = await response.text().catch(() => ''); + throw new Error(`BlueBubbles send failed (${response.status}): ${responseBody}`); + } + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/channels/bluebubbles/index.ts b/src/channels/bluebubbles/index.ts new file mode 100644 index 0000000..7fa4906 --- /dev/null +++ b/src/channels/bluebubbles/index.ts @@ -0,0 +1 @@ +export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './adapter.js'; diff --git a/src/channels/index.ts b/src/channels/index.ts index 10c6a40..0c1305b 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -19,4 +19,5 @@ export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js'; export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js'; export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js'; export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js'; +export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './bluebubbles/index.js'; export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 7e30dc8..2ef5c56 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -399,6 +399,31 @@ describe('configSchema — google_chat', () => { }); }); +describe('configSchema — bluebubbles', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts bluebubbles config and defaults optional fields', () => { + const result = configSchema.parse({ + ...minimalConfig, + bluebubbles: { + endpoint: 'http://localhost:1234', + api_key: 'bb-key', + }, + }); + + expect(result.bluebubbles).toBeDefined(); + if (!result.bluebubbles) { + throw new Error('Expected bluebubbles config'); + } + expect(result.bluebubbles.allowed_chat_guids).toEqual([]); + expect(result.bluebubbles.require_mention).toBe(true); + expect(result.bluebubbles.mention_name).toBe('flynn'); + }); +}); + describe('configSchema — whatsapp', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 66a9338..b5cb7e1 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -426,6 +426,15 @@ const googleChatSchema = z.object({ require_mention: z.boolean().default(true), }).optional(); +const bluebubblesSchema = z.object({ + endpoint: z.string().url('BlueBubbles endpoint must be a valid URL'), + api_key: z.string().min(1, 'BlueBubbles api_key is required'), + webhook_token: z.string().optional(), + allowed_chat_guids: z.array(z.string()).default([]), + require_mention: z.boolean().default(true), + mention_name: z.string().default('flynn'), +}).optional(); + const browserSchema = z.object({ enabled: z.boolean().default(false), executable_path: z.string().optional(), @@ -595,6 +604,7 @@ export const configSchema = z.object({ signal: signalSchema, teams: teamsSchema, google_chat: googleChatSchema, + bluebubbles: bluebubblesSchema, server: serverSchema.default({}), models: modelsSchema, backends: backendsSchema.default({}), @@ -642,6 +652,7 @@ export type MatrixConfig = z.infer; export type SignalConfig = z.infer; export type TeamsConfig = z.infer; export type GoogleChatConfig = z.infer; +export type BlueBubblesConfig = z.infer; export type RetryPolicyConfig = z.infer; export type ContextLevel = z.infer; export type PromptConfig = z.infer; diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 3f9be78..aa3e114 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -1,6 +1,6 @@ import type { Config } from '../config/index.js'; import type { HookEngine } from '../hooks/index.js'; -import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, TeamsAdapter, GoogleChatAdapter, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -126,6 +126,20 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { gateway.setGoogleChatHandler(googleChatAdapter); } + // Register BlueBubbles adapter (if configured) + if (config.bluebubbles) { + const blueBubblesAdapter = new BlueBubblesAdapter({ + endpoint: config.bluebubbles.endpoint, + apiKey: config.bluebubbles.api_key, + webhookToken: config.bluebubbles.webhook_token, + allowedChatGuids: config.bluebubbles.allowed_chat_guids.length > 0 ? config.bluebubbles.allowed_chat_guids : undefined, + requireMention: config.bluebubbles.require_mention, + mentionName: config.bluebubbles.mention_name, + }); + channelRegistry.register(blueBubblesAdapter); + gateway.setBlueBubblesHandler(blueBubblesAdapter); + } + // Register WebChat adapter (wraps the gateway) const webChatAdapter = new WebChatAdapter({ gateway }); channelRegistry.register(webChatAdapter); diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index 2a17c78..934399b 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -33,6 +33,7 @@ function makeBaseConfig(): Config { signal: undefined, teams: undefined, google_chat: undefined, + bluebubbles: undefined, } as unknown as Config; } @@ -49,6 +50,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'signal', status: 'not_configured' }), expect.objectContaining({ name: 'teams', status: 'not_configured' }), expect.objectContaining({ name: 'google_chat', status: 'not_configured' }), + expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }), expect.objectContaining({ name: 'cron', status: 'not_configured' }), expect.objectContaining({ name: 'mcp', status: 'not_configured' }), expect.objectContaining({ name: 'web_search', status: 'configured' }), diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index 2ee25c0..fb234d0 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -56,6 +56,7 @@ export function discoverServices( { key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' }, { key: 'teams', name: 'teams', description: 'Microsoft Teams bot' }, { key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' }, + { key: 'bluebubbles', name: 'bluebubbles', description: 'iMessage via BlueBubbles' }, ]; for (const { key, name, description } of channelConfigs) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 4d220f7..fafafb5 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -45,6 +45,7 @@ import type { ChannelRegistry } from '../channels/index.js'; import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js'; import type { TeamsAdapter } from '../channels/teams/adapter.js'; import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js'; +import type { BlueBubblesAdapter } from '../channels/bluebubbles/adapter.js'; export interface GatewayServerConfig { port: number; @@ -99,6 +100,8 @@ export interface GatewayServerConfig { teamsHandler?: Pick; /** Optional Google Chat adapter for inbound Chat event webhooks. */ googleChatHandler?: Pick; + /** Optional BlueBubbles adapter for inbound iMessage event webhooks. */ + blueBubblesHandler?: Pick; } export class GatewayServer { @@ -494,6 +497,12 @@ export class GatewayServer { return; } + // BlueBubbles events route — bypass gateway auth (BlueBubbles webhook posts directly) + if (this.config.blueBubblesHandler && req.method === 'POST' && req.url?.startsWith('/bluebubbles/events')) { + await this.config.blueBubblesHandler.handleRequest(req, res); + return; + } + // Apply auth to HTTP requests when configured const authConfig = this.config.auth ?? {}; if (this.config.authHttp !== false && authConfig.token) { @@ -591,6 +600,11 @@ export class GatewayServer { this.config.googleChatHandler = handler; } + /** Set the BlueBubbles handler for inbound webhook HTTP routes (late binding). */ + setBlueBubblesHandler(handler: Pick): void { + this.config.blueBubblesHandler = handler; + } + private async startDiscovery(host: string, port: number): Promise { const discovery = this.config.discovery; if (!discovery?.enabled) {