feat(channels): add bluebubbles imessage adapter
This commit is contained in:
@@ -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 { 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';
|
||||
|
||||
Reference in New Issue
Block a user