feat(channels): add bluebubbles imessage adapter
This commit is contained in:
@@ -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-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-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
|
- **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
|
- **Model Switching**: Switch between cloud/local models on demand
|
||||||
- **Session Persistence**: SQLite-backed conversation history
|
- **Session Persistence**: SQLite-backed conversation history
|
||||||
@@ -172,6 +172,18 @@ google_chat:
|
|||||||
# Google Chat messaging endpoint should point to:
|
# Google Chat messaging endpoint should point to:
|
||||||
# POST https://<your-flynn-host>/google-chat/events
|
# POST https://<your-flynn-host>/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://<your-flynn-host>/bluebubbles/events
|
||||||
|
|
||||||
models:
|
models:
|
||||||
default:
|
default:
|
||||||
provider: anthropic
|
provider: anthropic
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ telegram:
|
|||||||
# allowed_space_names: [] # Empty = allow all spaces
|
# allowed_space_names: [] # Empty = allow all spaces
|
||||||
# require_mention: true
|
# 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:
|
server:
|
||||||
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
||||||
# gateway to your tailnet via `tailscale serve`.
|
# gateway to your tailnet via `tailscale serve`.
|
||||||
|
|||||||
@@ -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 /gmail/push` (Google Pub/Sub push)
|
||||||
- `POST /teams/events` (Microsoft Bot Framework activity callback)
|
- `POST /teams/events` (Microsoft Bot Framework activity callback)
|
||||||
- `POST /google-chat/events` (Google Chat event callback, optional webhook token check)
|
- `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
|
## Message Format
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string>) {
|
||||||
|
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<string, string>).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<string, unknown>).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
this._status = 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
this._status = 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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, '\\$&');
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './adapter.js';
|
||||||
@@ -19,4 +19,5 @@ export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js';
|
|||||||
export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js';
|
export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js';
|
||||||
export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js';
|
export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js';
|
||||||
export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/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';
|
export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js';
|
||||||
|
|||||||
@@ -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', () => {
|
describe('configSchema — whatsapp', () => {
|
||||||
const minimalConfig = {
|
const minimalConfig = {
|
||||||
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
||||||
|
|||||||
@@ -426,6 +426,15 @@ const googleChatSchema = z.object({
|
|||||||
require_mention: z.boolean().default(true),
|
require_mention: z.boolean().default(true),
|
||||||
}).optional();
|
}).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({
|
const browserSchema = z.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
executable_path: z.string().optional(),
|
executable_path: z.string().optional(),
|
||||||
@@ -595,6 +604,7 @@ export const configSchema = z.object({
|
|||||||
signal: signalSchema,
|
signal: signalSchema,
|
||||||
teams: teamsSchema,
|
teams: teamsSchema,
|
||||||
google_chat: googleChatSchema,
|
google_chat: googleChatSchema,
|
||||||
|
bluebubbles: bluebubblesSchema,
|
||||||
server: serverSchema.default({}),
|
server: serverSchema.default({}),
|
||||||
models: modelsSchema,
|
models: modelsSchema,
|
||||||
backends: backendsSchema.default({}),
|
backends: backendsSchema.default({}),
|
||||||
@@ -642,6 +652,7 @@ export type MatrixConfig = z.infer<typeof matrixSchema>;
|
|||||||
export type SignalConfig = z.infer<typeof signalSchema>;
|
export type SignalConfig = z.infer<typeof signalSchema>;
|
||||||
export type TeamsConfig = z.infer<typeof teamsSchema>;
|
export type TeamsConfig = z.infer<typeof teamsSchema>;
|
||||||
export type GoogleChatConfig = z.infer<typeof googleChatSchema>;
|
export type GoogleChatConfig = z.infer<typeof googleChatSchema>;
|
||||||
|
export type BlueBubblesConfig = z.infer<typeof bluebubblesSchema>;
|
||||||
export type RetryPolicyConfig = z.infer<typeof retrySchema>;
|
export type RetryPolicyConfig = z.infer<typeof retrySchema>;
|
||||||
export type ContextLevel = z.infer<typeof contextLevelSchema>;
|
export type ContextLevel = z.infer<typeof contextLevelSchema>;
|
||||||
export type PromptConfig = z.infer<typeof promptSchema>;
|
export type PromptConfig = z.infer<typeof promptSchema>;
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type { HookEngine } from '../hooks/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 { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
|
||||||
import type { GatewayServer } from '../gateway/index.js';
|
import type { GatewayServer } from '../gateway/index.js';
|
||||||
|
|
||||||
@@ -126,6 +126,20 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
|
|||||||
gateway.setGoogleChatHandler(googleChatAdapter);
|
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)
|
// Register WebChat adapter (wraps the gateway)
|
||||||
const webChatAdapter = new WebChatAdapter({ gateway });
|
const webChatAdapter = new WebChatAdapter({ gateway });
|
||||||
channelRegistry.register(webChatAdapter);
|
channelRegistry.register(webChatAdapter);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function makeBaseConfig(): Config {
|
|||||||
signal: undefined,
|
signal: undefined,
|
||||||
teams: undefined,
|
teams: undefined,
|
||||||
google_chat: undefined,
|
google_chat: undefined,
|
||||||
|
bluebubbles: undefined,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ describe('discoverServices', () => {
|
|||||||
expect.objectContaining({ name: 'signal', status: 'not_configured' }),
|
expect.objectContaining({ name: 'signal', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'teams', status: 'not_configured' }),
|
expect.objectContaining({ name: 'teams', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'google_chat', 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: 'cron', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function discoverServices(
|
|||||||
{ key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' },
|
{ key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' },
|
||||||
{ key: 'teams', name: 'teams', description: 'Microsoft Teams bot' },
|
{ key: 'teams', name: 'teams', description: 'Microsoft Teams bot' },
|
||||||
{ key: 'google_chat', name: 'google_chat', description: 'Google Chat 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) {
|
for (const { key, name, description } of channelConfigs) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import type { ChannelRegistry } from '../channels/index.js';
|
|||||||
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
|
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
|
||||||
import type { TeamsAdapter } from '../channels/teams/adapter.js';
|
import type { TeamsAdapter } from '../channels/teams/adapter.js';
|
||||||
import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js';
|
import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js';
|
||||||
|
import type { BlueBubblesAdapter } from '../channels/bluebubbles/adapter.js';
|
||||||
|
|
||||||
export interface GatewayServerConfig {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -99,6 +100,8 @@ export interface GatewayServerConfig {
|
|||||||
teamsHandler?: Pick<TeamsAdapter, 'handleRequest'>;
|
teamsHandler?: Pick<TeamsAdapter, 'handleRequest'>;
|
||||||
/** Optional Google Chat adapter for inbound Chat event webhooks. */
|
/** Optional Google Chat adapter for inbound Chat event webhooks. */
|
||||||
googleChatHandler?: Pick<GoogleChatAdapter, 'handleRequest'>;
|
googleChatHandler?: Pick<GoogleChatAdapter, 'handleRequest'>;
|
||||||
|
/** Optional BlueBubbles adapter for inbound iMessage event webhooks. */
|
||||||
|
blueBubblesHandler?: Pick<BlueBubblesAdapter, 'handleRequest'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
export class GatewayServer {
|
||||||
@@ -494,6 +497,12 @@ export class GatewayServer {
|
|||||||
return;
|
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
|
// Apply auth to HTTP requests when configured
|
||||||
const authConfig = this.config.auth ?? {};
|
const authConfig = this.config.auth ?? {};
|
||||||
if (this.config.authHttp !== false && authConfig.token) {
|
if (this.config.authHttp !== false && authConfig.token) {
|
||||||
@@ -591,6 +600,11 @@ export class GatewayServer {
|
|||||||
this.config.googleChatHandler = handler;
|
this.config.googleChatHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set the BlueBubbles handler for inbound webhook HTTP routes (late binding). */
|
||||||
|
setBlueBubblesHandler(handler: Pick<BlueBubblesAdapter, 'handleRequest'>): void {
|
||||||
|
this.config.blueBubblesHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
private async startDiscovery(host: string, port: number): Promise<void> {
|
private async startDiscovery(host: string, port: number): Promise<void> {
|
||||||
const discovery = this.config.discovery;
|
const discovery = this.config.discovery;
|
||||||
if (!discovery?.enabled) {
|
if (!discovery?.enabled) {
|
||||||
|
|||||||
Reference in New Issue
Block a user