Compare commits

...

7 Commits

Author SHA1 Message Date
Arvin Xu 14e9287817 feat(bot): expose iMessage channel setup 2026-05-27 01:25:46 +08:00
Arvin Xu 92ca507021 ♻️ refactor(desktop): route iMessage through message API 2026-05-27 01:25:12 +08:00
Arvin Xu 3c6e0e1af8 feat(desktop): add local iMessage bridge 2026-05-27 01:22:35 +08:00
Arvin Xu 2c255a3cfa feat(device): add message API calls 2026-05-27 01:21:59 +08:00
Arvin Xu d5b4f6d30f ♻️ refactor(bot): derive gateway runtime user from provider 2026-05-27 01:15:34 +08:00
Arvin Xu ae89f96a8f 🐛 fix(bot): align iMessage search totals and attachment timeout 2026-05-26 14:42:06 +08:00
Arvin Xu 55a3974116 feat(bot): add hidden iMessage backend foundation 2026-05-26 10:55:55 +08:00
61 changed files with 4205 additions and 57 deletions
+1
View File
@@ -54,6 +54,7 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/tsconfig": "^2.0.0",
"@electron-toolkit/utils": "^4.0.0",
"@lobechat/chat-adapter-imessage": "workspace:*",
"@lobechat/desktop-bridge": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/electron-client-ipc": "workspace:*",
+1
View File
@@ -34,6 +34,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
gatewayDeviceName: '',
gatewayEnabled: true,
gatewayUrl: 'https://device-gateway.lobehub.com',
imessageBridgeConfigs: [],
locale: 'auto',
localFileWorkspaceRoots: [],
networkProxy: defaultProxySettings,
@@ -7,6 +7,7 @@ import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
@@ -54,6 +55,9 @@ interface PlatformTaskEntry {
topicId: string;
}
type ToolCallHandler = () => Promise<unknown>;
type ToolCallHandlerMap = Record<string, ToolCallHandler>;
/**
* GatewayConnectionCtr
*
@@ -86,6 +90,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(ShellCommandCtr);
}
private get imessageBridgeSrv() {
return this.app.getService(ImessageBridgeService);
}
private get heterogeneousAgentCtr() {
return this.app.getController(HeterogeneousAgentCtr);
}
@@ -104,6 +112,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up tool call handler
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
// Wire up message API handler
srv.setMessageApiHandler((platform, apiName, payload) =>
this.executeMessageApi(platform, apiName, payload),
);
// Wire up agent run handler
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
@@ -203,6 +216,37 @@ export default class GatewayConnectionCtr extends ControllerModule {
// ─── Tool Call Routing ───
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
const methodMap = {
...this.getLocalFileToolHandlers(args),
...this.getShellCommandToolHandlers(args),
...this.getPlatformAgentToolHandlers(args),
} satisfies ToolCallHandlerMap;
const handler = methodMap[apiName];
if (!handler) {
throw new Error(
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
);
}
return handler();
}
private async executeMessageApi(
platform: string,
apiName: string,
payload: Record<string, unknown>,
): Promise<unknown> {
if (platform === 'imessage') {
return this.imessageBridgeSrv.handleGatewayMessageApi(apiName, payload);
}
throw new Error(
`Message API "${platform}/${apiName}" is not available on this device. It may not be supported in the current desktop version.`,
);
}
private getLocalFileToolHandlers(args: any): ToolCallHandlerMap {
const editFile = () => this.localFileCtr.handleEditFile(args);
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
const listFiles = () => this.localFileCtr.listLocalFiles(args);
@@ -211,7 +255,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
const writeFile = () => this.localFileCtr.handleWriteFile(args);
const methodMap: Record<string, () => Promise<unknown>> = {
return {
editFile,
globFiles,
grepContent: () => this.localFileCtr.handleGrepContent(args),
@@ -221,10 +265,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
searchFiles,
writeFile,
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
// Legacy aliases — keep these so older Gateway versions sending the long
// names continue to route correctly. `renameLocalFile` is also kept even
// though the new surface drops rename (it's now handled by `moveFiles`).
@@ -236,7 +276,19 @@ export default class GatewayConnectionCtr extends ControllerModule {
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: searchFiles,
writeLocalFile: writeFile,
};
}
private getShellCommandToolHandlers(args: any): ToolCallHandlerMap {
return {
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
};
}
private getPlatformAgentToolHandlers(args: any): ToolCallHandlerMap {
return {
// Platform agent capability probing
checkPlatformCapability: () => this.checkPlatformCapability(args),
getAgentProfile: () => this.getAgentProfile(args),
@@ -245,15 +297,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
cancelHeteroTask: () => this.cancelHeteroTask(args),
runHeteroTask: () => this.runHeteroTask(args),
};
const handler = methodMap[apiName];
if (!handler) {
throw new Error(
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
);
}
return handler();
}
// ─── Platform Capability Probing ───
@@ -0,0 +1,68 @@
import type {
ImessageBridgeConfig,
ImessageBridgeSaveResult,
ImessageBridgeStatus,
} from '@lobechat/electron-client-ipc';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
const logger = createLogger('controllers:ImessageBridgeCtr');
export default class ImessageBridgeCtr extends ControllerModule {
static override readonly groupName = 'imessageBridge';
private get service() {
return this.app.getService(ImessageBridgeService);
}
private get remoteServerConfigCtr() {
return this.app.getController(RemoteServerConfigCtr);
}
afterAppReady() {
this.service.setRemoteServerProvider({
getAccessToken: () => this.remoteServerConfigCtr.getAccessToken(),
getServerUrl: async () => (await this.remoteServerConfigCtr.getRemoteServerUrl()) ?? null,
});
this.service.start().catch((error) => {
// The user can fix BlueBubbles or remote-server settings from the UI and start again.
logger.warn('Failed to auto-start iMessage bridge:', error);
});
}
@IpcMethod()
async getStatus(): Promise<ImessageBridgeStatus> {
return this.service.getStatus();
}
@IpcMethod()
async upsertConfig(config: ImessageBridgeConfig): Promise<ImessageBridgeSaveResult> {
const saved = await this.service.upsertConfig(config);
return { config: saved, success: true };
}
@IpcMethod()
async removeConfig(params: { applicationId: string }): Promise<{ success: boolean }> {
return this.service.removeConfig(params.applicationId);
}
@IpcMethod()
async start(): Promise<ImessageBridgeStatus> {
return this.service.start();
}
@IpcMethod()
async stop(): Promise<{ success: boolean }> {
return this.service.stop();
}
@IpcMethod()
async testConfig(config: ImessageBridgeConfig): Promise<{ success: boolean }> {
return this.service.testConfig(config);
}
}
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GatewayConnectionCtr from '../GatewayConnectionCtr';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
@@ -34,6 +35,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
});
sendToolCallResponse = vi.fn();
sendMessageApiResponse = vi.fn();
sendAgentRunAck = vi.fn();
constructor(options: any) {
@@ -67,6 +69,19 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
});
}
simulateMessageApiRequest(
platform: string,
apiName: string,
payload: Record<string, unknown>,
requestId = 'msg-req-1',
) {
this.emit('message_api_request', {
api: { apiName, payload, platform },
requestId,
type: 'message_api_request',
});
}
simulateAuthExpired() {
this.emit('auth_expired');
}
@@ -160,6 +175,10 @@ vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: MockGatewayClient,
}));
vi.mock('@/services/imessageBridgeSrv', () => ({
default: class ImessageBridgeService {},
}));
vi.mock('execa', () => ({
execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
}));
@@ -204,6 +223,10 @@ const mockHeterogeneousAgentCtr = {
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
} as unknown as HeterogeneousAgentCtr;
const mockImessageBridgeSrv = {
handleGatewayMessageApi: vi.fn().mockResolvedValue({ ok: true }),
} as unknown as ImessageBridgeService;
const mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
@@ -226,6 +249,7 @@ const mockApp = {
}),
getService: vi.fn((Cls) => {
if (Cls === GatewayConnectionService) return mockGatewayConnectionSrv;
if (Cls === ImessageBridgeService) return mockImessageBridgeSrv;
return null;
}),
storeManager: { get: mockStoreGet, set: mockStoreSet },
@@ -582,6 +606,66 @@ describe('GatewayConnectionCtr', () => {
});
});
describe('message API routing', () => {
async function connectAndOpen() {
ctr.afterAppReady();
await vi.advanceTimersByTimeAsync(0);
const client = MockGatewayClient.lastInstance!;
client.simulateConnected();
return client;
}
it('should route iMessage message API requests to the iMessage bridge service', async () => {
vi.mocked(mockImessageBridgeSrv.handleGatewayMessageApi).mockResolvedValueOnce({
guid: 'sent-1',
});
const client = await connectAndOpen();
client.simulateMessageApiRequest(
'imessage',
'sendText',
{
applicationId: 'home-mac-mini',
chatGuid: 'iMessage;-;chat-1',
message: 'hello',
},
'msg-req-42',
);
await vi.advanceTimersByTimeAsync(0);
expect(mockImessageBridgeSrv.handleGatewayMessageApi).toHaveBeenCalledWith('sendText', {
applicationId: 'home-mac-mini',
chatGuid: 'iMessage;-;chat-1',
message: 'hello',
});
expect(client.sendMessageApiResponse).toHaveBeenCalledWith({
requestId: 'msg-req-42',
result: {
content: JSON.stringify({ guid: 'sent-1' }),
success: true,
},
});
});
it('should send message_api_response with error for unsupported platforms', async () => {
const client = await connectAndOpen();
client.simulateMessageApiRequest('unsupported', 'sendText', {}, 'msg-req-err');
await vi.advanceTimersByTimeAsync(0);
const errorMsg =
'Message API "unsupported/sendText" is not available on this device. It may not be supported in the current desktop version.';
expect(client.sendMessageApiResponse).toHaveBeenCalledWith({
requestId: 'msg-req-err',
result: {
content: errorMsg,
error: errorMsg,
success: false,
},
});
});
});
// ─── Auth Expired ───
describe('auth_expired handling', () => {
@@ -7,6 +7,7 @@ import DevtoolsCtr from './DevtoolsCtr';
import GatewayConnectionCtr from './GatewayConnectionCtr';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import ImessageBridgeCtr from './ImessageBridgeCtr';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import McpInstallCtr from './McpInstallCtr';
@@ -33,6 +34,7 @@ export const controllerIpcConstructors = [
GatewayConnectionCtr,
GitCtr,
LocalFileCtr,
ImessageBridgeCtr,
McpCtr,
McpInstallCtr,
MenuController,
@@ -0,0 +1,210 @@
import { request } from 'node:http';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import ImessageBridgeService from '../imessageBridgeSrv';
const { MockBlueBubblesApiClient, getPortMock } = vi.hoisted(() => {
class _MockBlueBubblesApiClient {
static instances: _MockBlueBubblesApiClient[] = [];
getMessage = vi.fn().mockResolvedValue({
chats: [{ guid: 'iMessage;-;chat-1' }],
guid: 'msg-1',
text: 'hello',
});
listWebhooks = vi.fn().mockResolvedValue([]);
ping = vi.fn().mockResolvedValue(undefined);
registerWebhook = vi.fn().mockResolvedValue({ events: ['new-message'], id: 1 });
sendText = vi.fn().mockResolvedValue({ guid: 'sent-1', text: 'hello' });
constructor(public options: unknown) {
_MockBlueBubblesApiClient.instances.push(this);
}
}
return {
MockBlueBubblesApiClient: _MockBlueBubblesApiClient,
getPortMock: vi.fn().mockResolvedValue(43_210),
};
});
vi.mock('@lobechat/chat-adapter-imessage', () => ({
BlueBubblesApiClient: MockBlueBubblesApiClient,
}));
vi.mock('get-port-please', () => ({
getPort: getPortMock,
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
const config = {
applicationId: 'home-mac-mini',
blueBubblesPassword: 'local-password',
blueBubblesServerUrl: 'http://127.0.0.1:1234',
enabled: true,
webhookSecret: 'shared-secret',
};
function createService() {
const store = new Map<string, unknown>([['imessageBridgeConfigs', []]]);
const app = {
storeManager: {
get: vi.fn((key: string, fallback?: unknown) => store.get(key) ?? fallback),
set: vi.fn((key: string, value: unknown) => store.set(key, value)),
},
} as unknown as App;
const service = new ImessageBridgeService(app);
service.setRemoteServerProvider({
getAccessToken: vi.fn().mockResolvedValue('access-token'),
getServerUrl: vi.fn().mockResolvedValue('https://lobehub.example.com'),
});
return { app, service, store };
}
function postLocal(path: string, body: unknown): Promise<{ body: string; status: number }> {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const req = request(
{
headers: {
'Content-Length': Buffer.byteLength(payload),
'Content-Type': 'application/json',
},
hostname: '127.0.0.1',
method: 'POST',
path,
port: 43_210,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
res.on('end', () =>
resolve({
body: Buffer.concat(chunks).toString('utf8'),
status: res.statusCode ?? 0,
}),
);
},
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
describe('ImessageBridgeService', () => {
let fetchSpy: any;
beforeEach(() => {
vi.clearAllMocks();
MockBlueBubblesApiClient.instances = [];
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok', { status: 200 }));
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('stores local BlueBubbles credentials and registers a loopback webhook', async () => {
const { service, store } = createService();
const saved = await service.upsertConfig(config);
expect(saved).toMatchObject({
applicationId: 'home-mac-mini',
blueBubblesPasswordSet: true,
blueBubblesServerUrl: 'http://127.0.0.1:1234',
enabled: true,
});
expect(store.get('imessageBridgeConfigs')).toEqual([config]);
expect(MockBlueBubblesApiClient.instances.at(-1)?.registerWebhook).toHaveBeenCalledWith(
'http://127.0.0.1:43210/webhooks/bluebubbles/home-mac-mini?secret=shared-secret',
['new-message'],
);
await service.stop();
});
it('keeps the saved BlueBubbles password when updating bridge metadata', async () => {
const { service, store } = createService();
await service.upsertConfig(config);
await service.upsertConfig({
applicationId: 'home-mac-mini',
blueBubblesServerUrl: 'http://127.0.0.1:5678',
enabled: true,
webhookSecret: 'new-secret',
});
expect(store.get('imessageBridgeConfigs')).toEqual([
{
applicationId: 'home-mac-mini',
blueBubblesPassword: 'local-password',
blueBubblesServerUrl: 'http://127.0.0.1:5678',
enabled: true,
webhookSecret: 'new-secret',
},
]);
await service.stop();
});
it('executes outbound iMessage sends from device-gateway message API calls', async () => {
const { service } = createService();
await service.upsertConfig(config);
const result = await service.handleGatewayMessageApi('sendText', {
applicationId: 'home-mac-mini',
chatGuid: 'iMessage;-;chat-1',
message: 'hello',
});
expect(result).toEqual({ guid: 'sent-1', text: 'hello' });
expect(MockBlueBubblesApiClient.instances.at(-1)?.sendText).toHaveBeenCalledWith(
'iMessage;-;chat-1',
'hello',
undefined,
);
await service.stop();
});
it('receives BlueBubbles webhook locally and forwards the enriched event to LobeHub', async () => {
const { service } = createService();
await service.upsertConfig(config);
const response = await postLocal('/webhooks/bluebubbles/home-mac-mini?secret=shared-secret', {
data: { guid: 'msg-1' },
type: 'new-message',
});
expect(response.status).toBe(200);
expect(String(fetchSpy.mock.calls[0][0])).toBe(
'https://lobehub.example.com/api/agent/webhooks/imessage/home-mac-mini?secret=shared-secret',
);
expect(fetchSpy.mock.calls[0][1]).toMatchObject({
headers: {
'Authorization': 'Bearer access-token',
'Content-Type': 'application/json',
},
method: 'POST',
});
const forwarded = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
expect(forwarded.data.chats[0].guid).toBe('iMessage;-;chat-1');
await service.stop();
});
});
@@ -3,6 +3,7 @@ import os from 'node:os';
import type {
AgentRunRequestMessage,
MessageApiRequestMessage,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
@@ -22,6 +23,10 @@ interface ToolCallHandler {
(apiName: string, args: any): Promise<unknown>;
}
interface MessageApiHandler {
(platform: string, apiName: string, payload: Record<string, unknown>): Promise<unknown>;
}
interface AgentRunHandler {
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
}
@@ -41,6 +46,7 @@ export default class GatewayConnectionService extends ServiceModule {
private tokenProvider: (() => Promise<string | null>) | null = null;
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
private toolCallHandler: ToolCallHandler | null = null;
private messageApiHandler: MessageApiHandler | null = null;
private agentRunHandler: AgentRunHandler | null = null;
// ─── Configuration ───
@@ -66,6 +72,10 @@ export default class GatewayConnectionService extends ServiceModule {
this.toolCallHandler = handler;
}
setMessageApiHandler(handler: MessageApiHandler) {
this.messageApiHandler = handler;
}
setAgentRunHandler(handler: AgentRunHandler) {
this.agentRunHandler = handler;
}
@@ -185,6 +195,10 @@ export default class GatewayConnectionService extends ServiceModule {
this.handleToolCallRequest(request, client);
});
client.on('message_api_request', (request) => {
this.handleMessageApiRequest(request, client);
});
client.on('system_info_request', (request) => {
this.handleSystemInfoRequest(client, request);
});
@@ -319,6 +333,50 @@ export default class GatewayConnectionService extends ServiceModule {
}
};
// ─── Message API Routing ───
private handleMessageApiRequest = async (
request: MessageApiRequestMessage,
client: GatewayClient,
) => {
const { requestId, api } = request;
const { apiName, payload, platform } = api;
logger.info(
`Received message API request: platform=${platform}, apiName=${apiName}, requestId=${requestId}`,
);
try {
if (!this.messageApiHandler) {
throw new Error('No message API handler configured');
}
const result = await this.messageApiHandler(platform, apiName, payload);
client.sendMessageApiResponse({
requestId,
result: {
content: typeof result === 'string' ? result : JSON.stringify(result),
success: true,
},
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(
`Message API request failed: platform=${platform}, apiName=${apiName}, error=${errorMsg}`,
);
client.sendMessageApiResponse({
requestId,
result: {
content: errorMsg,
error: errorMsg,
success: false,
},
});
}
};
// ─── Power Save Blocker ───
/**
@@ -0,0 +1,404 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import {
BlueBubblesApiClient,
type BlueBubblesMessage,
type BlueBubblesOutboundAttachment,
type BlueBubblesSendOptions,
type BlueBubblesWebhookEvent,
} from '@lobechat/chat-adapter-imessage';
import type {
ImessageBridgeConfig,
ImessageBridgePublicConfig,
ImessageBridgeStatus,
} from '@lobechat/electron-client-ipc';
import { getPort } from 'get-port-please';
import { createLogger } from '@/utils/logger';
import { ServiceModule } from './index';
const logger = createLogger('services:ImessageBridgeSrv');
const STORE_KEY = 'imessageBridgeConfigs';
const LOCAL_HOST = '127.0.0.1';
const MAX_WEBHOOK_BYTES = 25 * 1024 * 1024;
interface RemoteServerProvider {
getAccessToken: () => Promise<string | null>;
getServerUrl: () => Promise<string | null>;
}
type StoredImessageBridgeConfig = ImessageBridgeConfig & { blueBubblesPassword: string };
interface ChatMessagesOptions {
after?: number | string;
before?: number | string;
limit?: number;
offset?: number;
sort?: 'ASC' | 'DESC';
withParts?: string[];
}
function toPublicConfig(config: StoredImessageBridgeConfig): ImessageBridgePublicConfig {
const { blueBubblesPassword, ...rest } = config;
return {
...rest,
blueBubblesPasswordSet: Boolean(blueBubblesPassword),
};
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`${field} is required`);
}
return value.trim();
}
export default class ImessageBridgeService extends ServiceModule {
private httpServer: Server | null = null;
private remoteServerProvider: RemoteServerProvider | null = null;
private serverPort = 0;
setRemoteServerProvider(provider: RemoteServerProvider) {
this.remoteServerProvider = provider;
}
getConfigs(): ImessageBridgePublicConfig[] {
return this.readConfigs().map(toPublicConfig);
}
getStatus(): ImessageBridgeStatus {
return {
configs: this.getConfigs(),
running: Boolean(this.httpServer),
serverUrl: this.httpServer ? this.getLocalServerUrl() : undefined,
};
}
async upsertConfig(config: ImessageBridgeConfig): Promise<ImessageBridgePublicConfig> {
const configs = this.readConfigs();
const index = configs.findIndex((item) => item.applicationId === config.applicationId?.trim());
const normalized = this.normalizeConfig(config, index >= 0 ? configs[index] : undefined);
if (index >= 0) {
configs[index] = normalized;
} else {
configs.push(normalized);
}
this.writeConfigs(configs);
if (normalized.enabled) {
await this.ensureServer();
await this.registerWebhook(normalized);
}
return toPublicConfig(normalized);
}
async removeConfig(applicationId: string): Promise<{ success: boolean }> {
const id = applicationId.trim();
this.writeConfigs(this.readConfigs().filter((config) => config.applicationId !== id));
if (this.readConfigs().every((config) => !config.enabled)) {
await this.stop();
}
return { success: true };
}
async start(): Promise<ImessageBridgeStatus> {
const enabled = this.readConfigs().filter((config) => config.enabled);
if (enabled.length === 0) return this.getStatus();
await this.ensureServer();
await Promise.all(enabled.map((config) => this.registerWebhook(config)));
return this.getStatus();
}
async stop(): Promise<{ success: boolean }> {
if (!this.httpServer) return { success: true };
await new Promise<void>((resolve, reject) => {
this.httpServer?.close((error) => {
if (error) reject(error);
else resolve();
});
});
this.httpServer = null;
this.serverPort = 0;
return { success: true };
}
async testConfig(config: ImessageBridgeConfig): Promise<{ success: boolean }> {
const existing = this.readConfigs().find(
(item) => item.applicationId === config.applicationId?.trim(),
);
await this.createApiClient(this.normalizeConfig(config, existing)).ping();
return { success: true };
}
async handleGatewayMessageApi(apiName: string, args: Record<string, unknown>): Promise<unknown> {
const applicationId = assertString(args.applicationId, 'applicationId');
const config = this.findConfig(applicationId);
const api = this.createApiClient(config);
switch (apiName) {
case 'ping': {
await api.ping();
return { ok: true };
}
case 'sendText': {
const chatGuid = assertString(args.chatGuid, 'chatGuid');
const message = assertString(args.message, 'message');
return api.sendText(chatGuid, message, args.options as BlueBubblesSendOptions | undefined);
}
case 'sendAttachment': {
const chatGuid = assertString(args.chatGuid, 'chatGuid');
return api.sendAttachment(
chatGuid,
args.attachment as BlueBubblesOutboundAttachment,
args.options as BlueBubblesSendOptions | undefined,
);
}
case 'startTyping': {
const chatGuid = assertString(args.chatGuid, 'chatGuid');
await api.startTyping(chatGuid);
return { ok: true };
}
case 'downloadAttachment': {
const guid = assertString(args.guid, 'guid');
const attachment = await api.downloadAttachment(guid);
return {
data: attachment.buffer.toString('base64'),
mimeType: attachment.mimeType,
};
}
case 'getChat': {
const guid = assertString(args.guid, 'guid');
return api.getChat(guid, args.withParts as string[] | undefined);
}
case 'getChatMessages': {
const chatGuid = assertString(args.chatGuid, 'chatGuid');
return api.getChatMessages(
chatGuid,
(args.options as ChatMessagesOptions | undefined) ?? {},
);
}
case 'queryMessages': {
return api.queryMessages((args.body as Record<string, unknown>) ?? {});
}
case 'queryChats': {
return api.queryChats((args.body as Record<string, unknown>) ?? {});
}
default: {
throw new Error(`Unsupported iMessage bridge action: ${apiName}`);
}
}
}
private readConfigs(): StoredImessageBridgeConfig[] {
return (this.app.storeManager.get(STORE_KEY, []) as StoredImessageBridgeConfig[]) ?? [];
}
private writeConfigs(configs: StoredImessageBridgeConfig[]) {
this.app.storeManager.set(STORE_KEY, configs);
}
private normalizeConfig(
config: ImessageBridgeConfig,
existing?: StoredImessageBridgeConfig,
): StoredImessageBridgeConfig {
const blueBubblesPassword =
config.blueBubblesPassword?.trim() || existing?.blueBubblesPassword?.trim();
if (!blueBubblesPassword) throw new Error('blueBubblesPassword is required');
return {
applicationId: assertString(config.applicationId, 'applicationId'),
blueBubblesPassword,
blueBubblesServerUrl: assertString(config.blueBubblesServerUrl, 'blueBubblesServerUrl'),
enabled: config.enabled,
webhookSecret: assertString(config.webhookSecret, 'webhookSecret'),
};
}
private findConfig(applicationId: string): StoredImessageBridgeConfig {
const config = this.readConfigs().find((item) => item.applicationId === applicationId);
if (!config) throw new Error(`iMessage bridge config not found: ${applicationId}`);
if (!config.enabled) throw new Error(`iMessage bridge config is disabled: ${applicationId}`);
return config;
}
private createApiClient(config: StoredImessageBridgeConfig): BlueBubblesApiClient {
return new BlueBubblesApiClient({
password: config.blueBubblesPassword,
serverUrl: config.blueBubblesServerUrl,
});
}
private async ensureServer(): Promise<void> {
if (this.httpServer) return;
this.serverPort = await getPort({
host: LOCAL_HOST,
port: 33_270,
ports: [33_271, 33_272, 33_273, 33_274, 33_275],
});
await new Promise<void>((resolve, reject) => {
const server = createServer(async (req, res) => {
try {
await this.handleHttpRequest(req, res);
} catch (error) {
logger.error('Unhandled iMessage bridge request error:', error);
writeText(res, 500, 'Internal Server Error');
}
});
server.listen(this.serverPort, LOCAL_HOST, () => {
this.httpServer = server;
logger.info(`iMessage local bridge started on ${this.getLocalServerUrl()}`);
resolve();
});
server.on('error', reject);
});
}
private async registerWebhook(config: StoredImessageBridgeConfig): Promise<void> {
const webhookUrl = this.getLocalWebhookUrl(config);
const api = this.createApiClient(config);
const existing = await api.listWebhooks(webhookUrl);
if (existing.some((webhook) => webhook.url === webhookUrl)) {
return;
}
await api.registerWebhook(webhookUrl, ['new-message']);
logger.info('Registered BlueBubbles local webhook for iMessage appId=%s', config.applicationId);
}
private getLocalServerUrl(): string {
return `http://${LOCAL_HOST}:${this.serverPort}`;
}
private getLocalWebhookUrl(config: ImessageBridgeConfig): string {
const url = new URL(
`/webhooks/bluebubbles/${encodeURIComponent(config.applicationId)}`,
this.getLocalServerUrl(),
);
url.searchParams.set('secret', config.webhookSecret);
return url.toString();
}
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method === 'OPTIONS') {
writeText(res, 204, '');
return;
}
if (req.method !== 'POST') {
writeText(res, 405, 'Method Not Allowed');
return;
}
const url = new URL(req.url ?? '/', this.getLocalServerUrl());
const match = url.pathname.match(/^\/webhooks\/bluebubbles\/([^/]+)$/);
if (!match) {
writeText(res, 404, 'Not Found');
return;
}
const applicationId = decodeURIComponent(match[1]);
const config = this.findConfig(applicationId);
if (url.searchParams.get('secret') !== config.webhookSecret) {
writeText(res, 401, 'Invalid secret');
return;
}
const event = (await readJson(req)) as BlueBubblesWebhookEvent;
const enriched = await this.enrichWebhookEvent(config, event);
await this.forwardWebhook(config, enriched);
writeJson(res, 200, { ok: true });
}
private async enrichWebhookEvent(
config: StoredImessageBridgeConfig,
event: BlueBubblesWebhookEvent,
): Promise<BlueBubblesWebhookEvent> {
const message = event.data;
if (event.type !== 'new-message' || !message?.guid) return event;
try {
const enriched = await this.createApiClient(config).getMessage(message.guid, [
'chats',
'attachments',
]);
return { ...event, data: { ...message, ...enriched } as BlueBubblesMessage };
} catch (error) {
logger.warn('Failed to enrich iMessage webhook message=%s: %O', message.guid, error);
return event;
}
}
private async forwardWebhook(
config: ImessageBridgeConfig,
event: BlueBubblesWebhookEvent,
): Promise<void> {
if (!this.remoteServerProvider) {
throw new Error('Remote server provider is not configured');
}
const [serverUrl, accessToken] = await Promise.all([
this.remoteServerProvider.getServerUrl(),
this.remoteServerProvider.getAccessToken(),
]);
if (!serverUrl) throw new Error('Remote server URL is not configured');
const target = new URL(
`/api/agent/webhooks/imessage/${encodeURIComponent(config.applicationId)}`,
serverUrl.endsWith('/') ? serverUrl : `${serverUrl}/`,
);
target.searchParams.set('secret', config.webhookSecret);
const response = await fetch(target, {
body: JSON.stringify(event),
headers: {
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch (error) {
logger.warn('Failed to read LobeHub webhook error response:', error);
}
throw new Error(detail || `LobeHub webhook failed with HTTP ${response.status}`);
}
}
}
async function readJson(req: IncomingMessage): Promise<unknown> {
let size = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_WEBHOOK_BYTES) throw new Error('Webhook payload is too large');
chunks.push(buffer);
}
const text = Buffer.concat(chunks).toString('utf8');
return text ? JSON.parse(text) : {};
}
function writeJson(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function writeText(res: ServerResponse, status: number, body: string) {
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(body);
}
+2
View File
@@ -1,5 +1,6 @@
import type {
DataSyncConfig,
ImessageBridgeConfig,
NetworkProxySettings,
UpdateChannel,
} from '@lobechat/electron-client-ipc';
@@ -18,6 +19,7 @@ export interface ElectronMainStore {
gatewayDeviceName: string;
gatewayEnabled: boolean;
gatewayUrl: string;
imessageBridgeConfigs: ImessageBridgeConfig[];
locale: string;
localFileWorkspaceRoots: string[];
networkProxy: NetworkProxySettings;
+103
View File
@@ -0,0 +1,103 @@
---
title: Connect LobeHub to iMessage
description: >-
Learn how to connect an iMessage account to your LobeHub agent through the local LobeHub Desktop BlueBubbles bridge.
tags:
- iMessage
- BlueBubbles
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to iMessage
LobeHub connects to iMessage through [BlueBubbles](https://bluebubbles.app/) running on a Mac signed into Messages. BlueBubbles only needs to talk to LobeHub Desktop on the same machine or local network. The Desktop app forwards inbound events to LobeHub and relays outbound replies back to the local BlueBubbles REST API, so you do not need to expose BlueBubbles to the public internet.
```text
iMessage user -> macOS Messages -> BlueBubbles -> LobeHub Desktop -> LobeHub
LobeHub agent -> Device Gateway -> LobeHub Desktop -> BlueBubbles -> iMessage user
```
## Prerequisites
- A Mac signed into the Apple ID you want the bot to use
- [BlueBubbles Server](https://bluebubbles.app/) installed on that Mac
- LobeHub Desktop signed in and connected to Device Gateway
- BlueBubbles API password
- A LobeHub deployment reachable from the Desktop app
> **Private API note:** BlueBubbles can send basic text and attachments through AppleScript. Advanced iMessage features such as typing indicators and message effects require the BlueBubbles Private API, which may require disabling SIP on the Mac. LobeHub only depends on basic text/attachment send and receive.
## Step 1: Set Up BlueBubbles
<Steps>
### Install BlueBubbles Server
Install BlueBubbles Server on the Mac that will host the iMessage account. Keep the Mac awake and connected to the network.
### Enable API access
In BlueBubbles Server, set a strong server password. LobeHub Desktop uses this password locally when calling BlueBubbles REST endpoints.
### Keep it local
Use a local address such as `http://127.0.0.1:1234` or a private LAN address. A public HTTPS BlueBubbles URL is no longer required.
</Steps>
## Step 2: Configure the Desktop Bridge
<Steps>
### Open LobeHub Desktop
Sign in to the same LobeHub account and make sure Device Gateway is connected.
### Add an iMessage bridge config
Use the same **Application ID** and **Webhook Secret** that you will save in the cloud channel settings. Enter the local BlueBubbles Server URL and password in Desktop only.
### Start the bridge
LobeHub Desktop starts a loopback HTTP listener and registers a BlueBubbles `new-message` webhook that points to `127.0.0.1`.
</Steps>
## Step 3: Configure iMessage in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, select **Channels**, and choose **iMessage**.
### Fill in the cloud credentials
1. **Application ID** — choose a stable identifier such as `home-mac-mini`.
2. **Desktop Device ID** — the LobeHub Desktop device that runs the BlueBubbles bridge.
3. **Webhook Secret** — the same random secret configured in the Desktop bridge.
### Save and connect
Click **Save**, then **Connect**. LobeHub verifies the Desktop bridge by asking that device to ping BlueBubbles locally.
</Steps>
## Step 4: Test the Bot
Send an iMessage to the Apple ID or phone number signed into the BlueBubbles Mac. LobeHub Desktop should receive the local `new-message` webhook, forward it to LobeHub, and the agent should reply in the same iMessage conversation.
## Feature Notes
- **Markdown** — iMessage receives plain text. LobeHub strips Markdown markup before sending final replies.
- **Message editing** — iMessage replies are sent as new messages. LobeHub skips progress-message edits.
- **Attachments** — inbound and outbound attachments are relayed through the Desktop bridge and BlueBubbles attachment APIs.
- **Typing indicators** — attempted when BlueBubbles Private API is available; otherwise they fail silently.
- **Group chats** — group iMessage chats are supported when BlueBubbles includes the `chatGuid` in message events. Use the `chatGuid` as the allowed-channel ID when scoping group access.
- **Loop prevention** — messages sent by the Mac account itself are ignored so the bot does not respond to its own replies.
## Troubleshooting
- **Connect fails with `DEVICE_OFFLINE`.** Make sure LobeHub Desktop is open, signed in, and connected to Device Gateway with the device ID saved in the channel.
- **Connect fails with BlueBubbles errors.** Recheck the local BlueBubbles URL and password in the Desktop bridge config.
- **Bot never responds.** Confirm the BlueBubbles webhook points to `127.0.0.1:<desktop-port>` and includes the same secret saved in LobeHub.
- **Webhook returns 401.** The webhook secret in Desktop and the cloud channel do not match.
- **Messages send but typing indicator fails.** The BlueBubbles Private API is probably disabled. This does not block normal bot replies.
- **Attachments fail.** Confirm the attachment has downloaded on the Mac and that BlueBubbles can serve it locally.
+103
View File
@@ -0,0 +1,103 @@
---
title: 将 LobeHub 连接到 iMessage
description: >-
了解如何通过 LobeHub Desktop 本地 BlueBubbles 桥接,将 iMessage 账号连接到 LobeHub 代理。
tags:
- iMessage
- BlueBubbles
- 消息渠道
- Bot 设置
- 集成
---
# 将 LobeHub 连接到 iMessage
LobeHub 通过运行在 Messages 登录 Mac 上的 [BlueBubbles](https://bluebubbles.app/) 接入 iMessage。BlueBubbles 只需要和同机或局域网内的 LobeHub Desktop 通信。Desktop 会把入站事件转发到 LobeHub,并把云端回复通过本地 BlueBubbles REST API 发回 iMessage,因此不再需要把 BlueBubbles 暴露到公网。
```text
iMessage 用户 -> macOS Messages -> BlueBubbles -> LobeHub Desktop -> LobeHub
LobeHub Agent -> Device Gateway -> LobeHub Desktop -> BlueBubbles -> iMessage 用户
```
## 前置条件
- 一台已登录目标 Apple ID 的 Mac
- 已在这台 Mac 上安装 [BlueBubbles Server](https://bluebubbles.app/)
- LobeHub Desktop 已登录并连接 Device Gateway
- BlueBubbles API 密码
- Desktop 可以访问的 LobeHub 部署
> **Private API 说明:** BlueBubbles 可以通过 AppleScript 发送基础文本和附件。输入状态、消息特效等高级能力依赖 BlueBubbles Private API,可能需要在 Mac 上关闭 SIP。LobeHub 的基础收发只依赖文本 / 附件能力。
## 步骤 1:设置 BlueBubbles
<Steps>
### 安装 BlueBubbles Server
在承载 iMessage 账号的 Mac 上安装 BlueBubbles Server,并确保这台 Mac 保持唤醒和联网。
### 开启 API 访问
在 BlueBubbles Server 中设置一个强密码。LobeHub Desktop 会在本机调用 BlueBubbles REST API 时使用这个密码。
### 保持本地访问
使用 `http://127.0.0.1:1234` 或局域网地址即可,不再需要公网 HTTPS BlueBubbles URL。
</Steps>
## 步骤 2:配置 Desktop 桥接
<Steps>
### 打开 LobeHub Desktop
登录同一个 LobeHub 账号,并确认 Device Gateway 已连接。
### 添加 iMessage bridge 配置
填入和云端渠道相同的 **Application ID** 与 **Webhook Secret**。BlueBubbles 本地 URL 和密码只保存在 Desktop 端。
### 启动桥接
LobeHub Desktop 会启动一个本地 loopback HTTP listener,并在 BlueBubbles 中注册指向 `127.0.0.1` 的 `new-message` webhook。
</Steps>
## 步骤 3:在 LobeHub 配置 iMessage
<Steps>
### 打开渠道设置
在 LobeHub 中进入代理设置,选择 **渠道**,然后点击 **iMessage**。
### 填写云端凭据
1. **Application ID** — 填一个稳定标识,例如 `home-mac-mini`。
2. **Desktop Device ID** — 运行 BlueBubbles 桥接的 LobeHub Desktop 设备。
3. **Webhook Secret** — 与 Desktop 桥接配置中相同的随机共享密钥。
### 保存并连接
点击 **保存**,然后点击 **连接**。LobeHub 会通过 Device Gateway 让对应 Desktop 在本地 ping BlueBubbles。
</Steps>
## 步骤 4:测试 Bot
向 BlueBubbles Mac 上登录的 Apple ID 或手机号发送一条 iMessage。LobeHub Desktop 应该会收到本地 `new-message` webhook,转发给 LobeHub,运行代理,并在同一个 iMessage 会话中回复。
## 功能说明
- **Markdown** — iMessage 接收纯文本。LobeHub 会在发送最终回复前移除 Markdown 标记。
- **消息编辑** — iMessage 回复会作为新消息发送,LobeHub 会跳过中间步骤的进度编辑。
- **附件** — 入站和出站附件通过 Desktop 桥接和 BlueBubbles 附件 API 中转。
- **输入状态** — 如果 BlueBubbles Private API 可用,会尝试发送 typing;不可用时会静默跳过,不影响正常回复。
- **群聊** — BlueBubbles 消息事件中包含 `chatGuid` 时可支持 iMessage 群聊。限制群组访问时,请把 `chatGuid` 填入允许频道。
- **防回环** — 由 Mac 账号自己发出的消息会被忽略,避免机器人回复自己的回复。
## 故障排查
- **连接时报 `DEVICE_OFFLINE`。** 确认 LobeHub Desktop 已打开、已登录,并且保存到渠道中的设备 ID 正在连接 Device Gateway。
- **连接时报 BlueBubbles 错误。** 检查 Desktop 桥接中的本地 BlueBubbles URL 和密码。
- **Bot 完全不回复。** 确认 BlueBubbles webhook 指向 `127.0.0.1:<desktop-port>`,并且 secret 与 LobeHub 保存的一致。
- **Webhook 返回 401。** Desktop 和云端渠道的 webhook secret 不一致。
- **消息能发出,但 typing 失败。** 通常是 BlueBubbles Private API 未开启;这不影响普通回复。
- **附件失败。** 确认附件已在 Mac 上下载完成,并且 BlueBubbles 可以在本地提供该附件。
+14 -9
View File
@@ -2,7 +2,7 @@
title: Channels Overview
description: >-
Connect your LobeHub agents to external messaging platforms like Discord,
Slack, Telegram, LINE, QQ, WeChat, Feishu, and Lark, allowing users to
Slack, Telegram, LINE, iMessage, QQ, WeChat, Feishu, and Lark, allowing users to
interact with AI assistants directly in their favorite chat apps.
tags:
- Channels
@@ -14,6 +14,7 @@ tags:
- LINE
- QQ
- WeChat
- iMessage
- Feishu
- Lark
---
@@ -34,6 +35,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
| [LINE](/docs/usage/channels/line) | Connect to LINE Messaging API for direct and group chats |
| [iMessage](/docs/usage/channels/imessage) | Connect to iMessage through the local LobeHub Desktop BlueBubbles bridge |
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
@@ -44,7 +46,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, LINE, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, LINE, iMessage, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
## Getting Started
@@ -55,6 +57,7 @@ Each channel integration works by linking a bot account on the target platform t
- [Slack](/docs/usage/channels/slack)
- [Telegram](/docs/usage/channels/telegram)
- [LINE](/docs/usage/channels/line)
- [iMessage](/docs/usage/channels/imessage)
- [QQ](/docs/usage/channels/qq)
- [WeChat (微信)](/docs/usage/channels/wechat)
- [Feishu (飞书)](/docs/usage/channels/feishu)
@@ -66,13 +69,13 @@ If you do not see **WeChat** in the channel list, check that your account has an
Text messages are supported across all platforms. Some features vary by platform:
| Feature | Discord | Slack | Telegram | LINE | QQ | WeChat | Feishu | Lark |
| ---------------------- | ------- | ----- | -------- | ------- | --- | ------ | ------- | ------- |
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Reactions | Yes | Yes | Yes | No | No | No | Partial | Partial |
| Image/file attachments | Yes | Yes | Yes | Inbound | Yes | No | Yes | Yes |
| Feature | Discord | Slack | Telegram | LINE | iMessage | QQ | WeChat | Feishu | Lark |
| ---------------------- | ------- | ----- | -------- | ------- | -------- | --- | ------ | ------- | ------- |
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Reactions | Yes | Yes | Yes | No | No | No | No | Partial | Partial |
| Image/file attachments | Yes | Yes | Yes | Inbound | Yes | Yes | No | Yes | Yes |
## Allowed Users (global)
@@ -123,6 +126,7 @@ Every supported platform defaults to **Open** for both policies so the bot stays
- **Discord** — Enable Developer Mode in user settings, right-click the user, and choose **Copy User ID**.
- **Slack** — Open the user's profile → click the `⋮` menu → **Copy member ID** (starts with `U`).
- **Telegram** — Ask the user to message [@userinfobot](https://t.me/userinfobot), or read `from.id` from the bot's incoming update.
- **iMessage** — Use the sender handle shown in BlueBubbles, usually an email address or E.164 phone number.
- **QQ** — Use the `tiny_id` from the OpenAPI event payload (the public-facing QQ number is not guaranteed to be the platform ID).
- **Feishu / Lark** — Use the `open_id` from the event payload, or the **User ID** displayed in the developer portal.
@@ -131,4 +135,5 @@ Every supported platform defaults to **Open** for both policies so the bot stays
- **Discord** — Enable Developer Mode, right-click the channel, and choose **Copy Channel ID**.
- **Slack** — Open the channel's About panel and copy the channel ID at the bottom (starts with `C`).
- **Telegram** — Forward a message from the group to [@userinfobot](https://t.me/userinfobot), or read `chat.id` from the bot's incoming update (group IDs are negative).
- **iMessage** — Use the BlueBubbles `chatGuid` shown in webhook payloads or recent message API responses.
- **Feishu / Lark** — Use the `chat_id` from the event payload.
+24 -19
View File
@@ -1,7 +1,7 @@
---
title: 渠道概览
description: >-
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、LINE、QQ、微信、飞书和
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、LINE、iMessage、QQ、微信、飞书和
Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
tags:
- 渠道
@@ -11,6 +11,7 @@ tags:
- Slack
- Telegram
- LINE
- iMessage
- QQ
- 微信
- 飞书
@@ -27,23 +28,24 @@ tags:
## 支持的平台
| 平台 | 描述 |
| ----------------------------------------- | -------------------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
| 平台 | 描述 |
| ----------------------------------------- | ------------------------------------------------ |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 |
| [iMessage](/docs/usage/channels/imessage) | 通过 LobeHub Desktop 本地 BlueBubbles 桥接连接到 iMessage |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
## 工作原理
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、LINE、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、LINE、iMessage、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
## 快速开始
@@ -54,6 +56,7 @@ tags:
- [Slack](/docs/usage/channels/slack)
- [Telegram](/docs/usage/channels/telegram)
- [LINE](/docs/usage/channels/line)
- [iMessage](/docs/usage/channels/imessage)
- [QQ](/docs/usage/channels/qq)
- [微信](/docs/usage/channels/wechat)
- [飞书](/docs/usage/channels/feishu)
@@ -65,13 +68,13 @@ tags:
所有平台均支持文本消息。某些功能因平台而异:
| 功能 | Discord | Slack | Telegram | LINE | QQ | 微信 | 飞书 | Lark |
| --------- | ------- | ----- | -------- | ---- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 仅入站 | 是 | 否 | 是 | 是 |
| 功能 | Discord | Slack | Telegram | LINE | iMessage | QQ | 微信 | 飞书 | Lark |
| --------- | ------- | ----- | -------- | ---- | -------- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 仅入站 | 是 | 是 | 否 | 是 | 是 |
## 允许的用户(全局)
@@ -122,6 +125,7 @@ tags:
- **Discord** — 在用户设置里开启开发者模式,右键用户头像选 **复制用户 ID**。
- **Slack** — 打开用户资料 → 点击 `⋮` 菜单 → **复制成员 ID**(以 `U` 开头)。
- **Telegram** — 让用户私信 [@userinfobot](https://t.me/userinfobot),或者从机器人收到的 update 里读 `from.id`。
- **iMessage** — 使用 BlueBubbles 中显示的发送者 handle,通常是邮箱或 E.164 手机号。
- **QQ** — 使用 OpenAPI 事件 payload 里的 `tiny_id`(用户对外可见的 QQ 号不一定就是平台 ID)。
- **飞书 / Lark** — 使用事件 payload 里的 `open_id`,或开发者后台显示的 **User ID**。
@@ -130,4 +134,5 @@ tags:
- **Discord** — 开启开发者模式,右键频道选 **复制频道 ID**。
- **Slack** — 打开频道详情面板,底部能看到频道 ID(以 `C` 开头)。
- **Telegram** — 把群里的一条消息转发给 [@userinfobot](https://t.me/userinfobot),或者从机器人收到的 update 里读 `chat.id`(群组是负数)。
- **iMessage** — 使用 BlueBubbles webhook payload 或近期消息 API 响应中的 `chatGuid`。
- **飞书 / Lark** — 使用事件 payload 里的 `chat_id`。
+30
View File
@@ -100,6 +100,35 @@
"channel.groupPolicyOpenHint": "Respond in any group, channel, or thread",
"channel.historyLimit": "History Message Limit",
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
"channel.imessage.applicationIdHint": "A stable identifier shared by the cloud channel and the Desktop bridge.",
"channel.imessage.applicationIdPlaceholder": "e.g. home-mac-mini",
"channel.imessage.blueBubblesPassword": "BlueBubbles Password",
"channel.imessage.blueBubblesPasswordHint": "Stored locally in LobeHub Desktop and used only to call the local BlueBubbles server.",
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
"channel.imessage.blueBubblesServerUrlHint": "The local BlueBubbles server URL reachable from this Desktop app.",
"channel.imessage.bridgeEnabled": "Enable Bridge",
"channel.imessage.bridgeEnabledHint": "When enabled, LobeHub Desktop receives local BlueBubbles webhooks and forwards them to LobeHub.",
"channel.imessage.bridgeMissingApplicationId": "Enter the Application ID first.",
"channel.imessage.bridgeMissingPassword": "Enter the BlueBubbles password first.",
"channel.imessage.bridgeMissingServerUrl": "Enter the BlueBubbles Server URL first.",
"channel.imessage.bridgeMissingWebhookSecret": "Enter the Webhook Secret first.",
"channel.imessage.bridgePasswordSavedPlaceholder": "Leave blank to keep the saved password",
"channel.imessage.bridgeRefresh": "Refresh",
"channel.imessage.bridgeRefreshFailed": "Failed to refresh iMessage Desktop bridge",
"channel.imessage.bridgeRunning": "Running",
"channel.imessage.bridgeSave": "Save Bridge",
"channel.imessage.bridgeSaveFailed": "Failed to save iMessage Desktop bridge",
"channel.imessage.bridgeSaved": "iMessage Desktop bridge saved",
"channel.imessage.bridgeStopped": "Stopped",
"channel.imessage.bridgeTest": "Test BlueBubbles",
"channel.imessage.bridgeTestFailed": "BlueBubbles test failed",
"channel.imessage.bridgeTestSuccess": "BlueBubbles connection passed",
"channel.imessage.description": "Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
"channel.imessage.desktopBridge": "Desktop Bridge",
"channel.imessage.desktopDeviceId": "Desktop Device ID",
"channel.imessage.desktopDeviceIdHint": "The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.",
"channel.imessage.webhookSecret": "Webhook Secret",
"channel.imessage.webhookSecretHint": "A shared secret used between LobeHub Desktop and the cloud webhook. Use the same value in the Desktop bridge config.",
"channel.importConfig": "Import Configuration",
"channel.importFailed": "Failed to import configuration",
"channel.importInvalidFormat": "Invalid configuration file format",
@@ -176,6 +205,7 @@
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
"channel.userIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.",
"channel.userIdHint.feishu": "Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.",
"channel.userIdHint.imessage": "Use your iMessage handle as seen in BlueBubbles, usually an email address or E.164 phone number.",
"channel.userIdHint.line": "Open the LINE Developers Console → your channel → Basic settings tab, and copy \"Your user ID\" (starts with U, 33 chars).",
"channel.userIdHint.qq": "Your QQ number, shown on your QQ profile page.",
"channel.userIdHint.slack": "Open your Slack profile → ⋮ More → Copy member ID (starts with U).",
+30
View File
@@ -100,6 +100,35 @@
"channel.groupPolicyOpenHint": "在所有群组、频道、子话题中响应",
"channel.historyLimit": "历史消息条数",
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
"channel.imessage.applicationIdHint": "云端渠道和桌面端桥接共同使用的稳定标识。",
"channel.imessage.applicationIdPlaceholder": "例如 home-mac-mini",
"channel.imessage.blueBubblesPassword": "BlueBubbles 密码",
"channel.imessage.blueBubblesPasswordHint": "仅保存在 LobeHub Desktop 本地,用于访问本机 BlueBubbles Server。",
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
"channel.imessage.blueBubblesServerUrlHint": "当前桌面端可以访问到的本机 BlueBubbles Server 地址。",
"channel.imessage.bridgeEnabled": "启用桥接",
"channel.imessage.bridgeEnabledHint": "启用后,LobeHub Desktop 会接收本机 BlueBubbles webhook 并转发给 LobeHub。",
"channel.imessage.bridgeMissingApplicationId": "请先填写 Application ID。",
"channel.imessage.bridgeMissingPassword": "请先填写 BlueBubbles 密码。",
"channel.imessage.bridgeMissingServerUrl": "请先填写 BlueBubbles Server URL。",
"channel.imessage.bridgeMissingWebhookSecret": "请先填写 Webhook Secret。",
"channel.imessage.bridgePasswordSavedPlaceholder": "留空则沿用已保存的密码",
"channel.imessage.bridgeRefresh": "刷新",
"channel.imessage.bridgeRefreshFailed": "刷新 iMessage Desktop 桥接失败",
"channel.imessage.bridgeRunning": "运行中",
"channel.imessage.bridgeSave": "保存桥接",
"channel.imessage.bridgeSaveFailed": "保存 iMessage Desktop 桥接失败",
"channel.imessage.bridgeSaved": "iMessage Desktop 桥接已保存",
"channel.imessage.bridgeStopped": "已停止",
"channel.imessage.bridgeTest": "测试 BlueBubbles",
"channel.imessage.bridgeTestFailed": "BlueBubbles 测试失败",
"channel.imessage.bridgeTestSuccess": "BlueBubbles 连接测试通过",
"channel.imessage.description": "通过 LobeHub Desktop 本地 BlueBubbles 桥接将助手连接到 iMessage。",
"channel.imessage.desktopBridge": "桌面端桥接",
"channel.imessage.desktopDeviceId": "桌面端设备 ID",
"channel.imessage.desktopDeviceIdHint": "运行本地 BlueBubbles 桥接的 LobeHub Desktop 设备,可在桌面端 Gateway 设置中找到。",
"channel.imessage.webhookSecret": "Webhook Secret",
"channel.imessage.webhookSecretHint": "LobeHub Desktop 与云端 webhook 之间使用的共享密钥,需要和桌面端桥接配置保持一致。",
"channel.importConfig": "导入平台配置",
"channel.importFailed": "配置导入失败",
"channel.importInvalidFormat": "配置文件格式无效",
@@ -176,6 +205,7 @@
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
"channel.userIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键你的头像 → 复制用户 ID。",
"channel.userIdHint.feishu": "在飞书 / Lark 开放平台打开你的应用 → 权限管理,查看你的 Open ID。",
"channel.userIdHint.imessage": "使用 BlueBubbles 中显示的 iMessage handle,通常是邮箱或 E.164 手机号。",
"channel.userIdHint.line": "打开 LINE Developers Console → 你的 channel → Basic settings 选项卡,复制 \"Your user ID\"(以 U 开头共 33 位)。",
"channel.userIdHint.qq": "你的 QQ 号,在 QQ 资料页可见。",
"channel.userIdHint.slack": "打开 Slack 个人资料 → ⋮ 更多 → 复制 Member ID(以 U 开头)。",
+1
View File
@@ -245,6 +245,7 @@
"@lobechat/business-model-bank": "workspace:*",
"@lobechat/business-model-runtime": "workspace:*",
"@lobechat/chat-adapter-feishu": "workspace:*",
"@lobechat/chat-adapter-imessage": "workspace:*",
"@lobechat/chat-adapter-line": "workspace:*",
"@lobechat/chat-adapter-qq": "workspace:*",
"@lobechat/chat-adapter-wechat": "workspace:*",
@@ -3,7 +3,7 @@ import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { MessageApiName, MessageToolIdentifier } from './types';
const platformEnum = ['discord', 'telegram', 'slack', 'feishu', 'lark', 'qq', 'wechat'];
const platformEnum = ['discord', 'telegram', 'slack', 'feishu', 'lark', 'qq', 'wechat', 'imessage'];
/**
* Shared schema fragment for the outbound `attachments` array on message-
@@ -17,7 +17,7 @@ const platformEnum = ['discord', 'telegram', 'slack', 'feishu', 'lark', 'qq', 'w
*
* Platform support varies — see each platform's `sendAttachments` helper
* for the actual delivery shape:
* - WeChat / Discord / Telegram / Slack / Feishu / Lark: full support
* - WeChat / iMessage / Discord / Telegram / Slack / Feishu / Lark: full support
* - LINE: image + HTTPS URL only; other types degrade to a text-link line
* - QQ: group / c2c only; guild / dms / data-only degrade to text-link
*/
@@ -8,6 +8,7 @@ export const systemPrompt = `You have access to a Message tool that provides uni
- **lark** — Lark (international Feishu) chats, groups, message replies, reactions
- **qq** — QQ groups, guild channels, direct messages
- **wechat** — WeChat (微信) iLink Bot conversations
- **imessage** — iMessage conversations through the LobeHub Desktop BlueBubbles bridge
</supported_platforms>
<bot_management>
@@ -7,6 +7,7 @@ export const MessageToolIdentifier = 'lobe-message';
export const MessagePlatform = {
discord: 'discord',
feishu: 'feishu',
imessage: 'imessage',
lark: 'lark',
qq: 'qq',
slack: 'slack',
@@ -0,0 +1,26 @@
{
"name": "@lobechat/chat-adapter-imessage",
"version": "0.1.0",
"description": "iMessage adapter for chat SDK via BlueBubbles",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chat": "^4.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createImessageAdapter, extractAttachmentMetadata, ImessageAdapter } from './adapter';
import { BlueBubblesApiClient } from './api';
import type { BlueBubblesMessage, BlueBubblesWebhookEvent } from './types';
const baseConfig = {
password: 'server-password',
serverUrl: 'https://bluebubbles.example.com',
webhookSecret: 'shared-secret',
};
function makeAdapter(overrides: Partial<typeof baseConfig> = {}) {
const adapter = createImessageAdapter({ ...baseConfig, ...overrides });
const processMessage = vi.fn(
async (_adapter: unknown, _threadId: string, factory: () => Promise<unknown> | unknown) =>
factory(),
);
const chat = {
getLogger: () => ({
child: () => ({}),
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
getUserName: () => 'imessage-bot',
processMessage,
} as any;
return { adapter, chat, processMessage };
}
function makeRequest(body: BlueBubblesWebhookEvent, secret = baseConfig.webhookSecret): Request {
return new Request(
`https://lobehub.example.com/api/agent/webhooks/imessage/mac?secret=${secret}`,
{
body: JSON.stringify(body),
method: 'POST',
},
);
}
function textMessage(overrides: Partial<BlueBubblesMessage> = {}): BlueBubblesMessage {
return {
chats: [{ guid: 'iMessage;-;chat-1', style: 45 }],
dateCreated: 1_700_000_000_000,
guid: 'msg-1',
handle: { address: '+15551234567' },
isFromMe: false,
text: 'hello',
...overrides,
};
}
describe('ImessageAdapter webhook handling', () => {
let fetchSpy: any;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.useRealTimers();
fetchSpy.mockRestore();
});
it('rejects POST with a missing or mismatched secret', async () => {
const { adapter, chat } = makeAdapter();
await adapter.initialize(chat);
const res = await adapter.handleWebhook(
makeRequest({ data: textMessage(), type: 'new-message' }, 'wrong'),
);
expect(res.status).toBe(401);
});
it('dispatches a BlueBubbles new-message webhook to the chat guid thread', async () => {
const { adapter, chat, processMessage } = makeAdapter();
await adapter.initialize(chat);
const res = await adapter.handleWebhook(
makeRequest({ data: textMessage(), type: 'new-message' }),
);
expect(res.status).toBe(200);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage.mock.calls[0][1]).toBe('imessage:iMessage;-;chat-1');
const factory = processMessage.mock.calls[0][2] as () => Promise<any>;
const message = await factory();
expect(message.text).toBe('hello');
expect(message.author.userId).toBe('+15551234567');
expect(message.metadata.dateSent.getTime()).toBe(1_700_000_000_000);
});
it('enriches webhook messages that do not carry chats', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: textMessage({ guid: 'msg-needs-enrichment', text: 'enriched' }),
}),
{ status: 200 },
),
);
const { adapter, chat, processMessage } = makeAdapter();
await adapter.initialize(chat);
const res = await adapter.handleWebhook(
makeRequest({ data: { guid: 'msg-needs-enrichment', isFromMe: false }, type: 'new-message' }),
);
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe(
'https://bluebubbles.example.com/api/v1/message/msg-needs-enrichment?password=server-password&with=chats%2Cattachments',
);
expect(processMessage.mock.calls[0][1]).toBe('imessage:iMessage;-;chat-1');
});
it('ignores messages sent by the hosted Mac account', async () => {
const { adapter, chat, processMessage } = makeAdapter();
await adapter.initialize(chat);
const res = await adapter.handleWebhook(
makeRequest({ data: textMessage({ isFromMe: true }), type: 'new-message' }),
);
expect(res.status).toBe(200);
expect(processMessage).not.toHaveBeenCalled();
});
});
describe('ImessageAdapter parsing and outbound', () => {
let fetchSpy: any;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ data: { guid: 'sent-1', text: 'hi back' } }), {
status: 200,
}),
);
});
afterEach(() => {
vi.useRealTimers();
fetchSpy.mockRestore();
});
it('extracts metadata-only attachments from BlueBubbles messages', () => {
const attachments = extractAttachmentMetadata(
textMessage({
attachments: [
{
guid: 'att-1',
mimeType: 'image/png',
totalBytes: 123,
transferName: 'photo.png',
},
],
}),
);
expect(attachments).toHaveLength(1);
expect(attachments[0].type).toBe('image');
expect(attachments[0].mimeType).toBe('image/png');
expect((attachments[0] as any).raw.guid).toBe('att-1');
});
it('postMessage sends text through BlueBubbles /message/text', async () => {
const adapter = new ImessageAdapter(baseConfig);
const result = await adapter.postMessage('imessage:iMessage;-;chat-1', 'hi back' as any);
expect(result.id).toBe('sent-1');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
'https://bluebubbles.example.com/api/v1/message/text?password=server-password',
);
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string);
expect(body.chatGuid).toBe('iMessage;-;chat-1');
expect(body.message).toBe('hi back');
expect(body.method).toBe('apple-script');
expect(body.tempGuid).toBeTruthy();
});
it('BlueBubblesApiClient pings the authenticated API endpoint', async () => {
const api = new BlueBubblesApiClient(baseConfig);
await api.ping();
expect(fetchSpy.mock.calls[0][0]).toBe(
'https://bluebubbles.example.com/api/v1/ping?password=server-password',
);
});
it('applies the request timeout when fetching outbound attachment URLs', async () => {
vi.useFakeTimers();
fetchSpy.mockImplementationOnce(
async (_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('Attachment fetch timed out', 'AbortError'));
});
}),
);
const api = new BlueBubblesApiClient({ ...baseConfig, requestTimeoutMs: 1000 });
const sendPromise = api.sendAttachment('iMessage;-;chat-1', {
fetchUrl: 'https://assets.example.com/photo.png',
mimeType: 'image/png',
name: 'photo.png',
});
const assertion = expect(sendPromise).rejects.toMatchObject({ name: 'AbortError' });
await vi.advanceTimersByTimeAsync(1000);
await assertion;
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe('https://assets.example.com/photo.png');
expect(fetchSpy.mock.calls[0][1]).toMatchObject({
method: 'GET',
signal: expect.any(AbortSignal),
});
});
});
@@ -0,0 +1,388 @@
import type {
Adapter,
AdapterPostableMessage,
Attachment,
Author,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import { BlueBubblesApiClient, resolveAttachmentName } from './api';
import { ImessageFormatConverter } from './format-converter';
import type {
BlueBubblesAttachment,
BlueBubblesChat,
BlueBubblesMessage,
BlueBubblesWebhookEvent,
ImessageAdapterConfig,
ImessageBridgeTransport,
ImessageThreadId,
} from './types';
const NEW_MESSAGE_EVENT = 'new-message';
function senderIdFromMessage(message: BlueBubblesMessage): string {
const handle = message.handle;
return (
handle?.address ||
handle?.uncanonicalizedId ||
String(message.handleId ?? message.otherHandle ?? 'unknown')
);
}
function extractText(message: BlueBubblesMessage): string {
const text = message.text?.trim();
if (text) return text;
const subject = message.subject?.trim();
if (subject) return subject;
const attachments = message.attachments ?? [];
if (attachments.length === 0) return '';
return attachments
.map((attachment) => {
const name = resolveAttachmentName(attachment);
return `[attachment: ${name}]`;
})
.join('\n');
}
function attachmentType(mimeType: string | undefined): 'audio' | 'file' | 'image' | 'video' {
if (mimeType?.startsWith('image/')) return 'image';
if (mimeType?.startsWith('video/')) return 'video';
if (mimeType?.startsWith('audio/')) return 'audio';
return 'file';
}
export function extractAttachmentMetadata(message: BlueBubblesMessage): Attachment[] {
return (message.attachments ?? []).map((attachment) => ({
mimeType: attachment.mimeType ?? 'application/octet-stream',
name: resolveAttachmentName(attachment),
raw: attachment,
size: attachment.totalBytes,
type: attachmentType(attachment.mimeType),
url: '',
})) as Attachment[];
}
function dateFromBlueBubbles(timestamp: number | null | undefined): Date {
if (!timestamp) return new Date();
return new Date(timestamp);
}
function isDirectChat(chat: BlueBubblesChat | undefined): boolean {
if (!chat) return false;
if (typeof chat.style === 'number') return chat.style !== 43;
if (Array.isArray(chat.participants)) return chat.participants.length <= 1;
return false;
}
export function encodeImessageThreadId(data: ImessageThreadId): string {
return `imessage:${data.chatGuid}`;
}
export function decodeImessageThreadId(threadId: string): ImessageThreadId {
if (threadId.startsWith('imessage:')) {
return { chatGuid: threadId.slice('imessage:'.length) };
}
return { chatGuid: threadId };
}
export class ImessageAdapter implements Adapter<ImessageThreadId, BlueBubblesMessage> {
readonly name = 'imessage';
private readonly api?: BlueBubblesApiClient;
private readonly botId: string;
private readonly formatConverter: ImessageFormatConverter;
private readonly knownDmThreads = new Map<string, boolean>();
private readonly transport?: ImessageBridgeTransport;
private readonly webhookSecret: string;
private _userName: string;
private chat!: ChatInstance;
private logger!: Logger;
constructor(config: ImessageAdapterConfig) {
if (!config.webhookSecret?.trim()) throw new Error('iMessage adapter requires webhookSecret');
if (config.transport) {
this.transport = config.transport;
} else {
if (!config.serverUrl?.trim()) throw new Error('iMessage adapter requires serverUrl');
if (!config.password?.trim()) throw new Error('iMessage adapter requires password');
this.api = new BlueBubblesApiClient({
password: config.password,
requestTimeoutMs: config.requestTimeoutMs,
serverUrl: config.serverUrl,
});
}
this.webhookSecret = config.webhookSecret;
this.botId = config.botUserId || 'imessage:self';
this._userName = config.userName || 'imessage-bot';
this.formatConverter = new ImessageFormatConverter();
}
get botUserId(): string {
return this.botId;
}
get userName(): string {
return this._userName;
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger(this.name);
this._userName = chat.getUserName();
this.logger.info(
this.transport
? 'Initialized iMessage adapter via Desktop BlueBubbles bridge'
: 'Initialized iMessage adapter via BlueBubbles',
);
}
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const url = new URL(request.url);
if (url.searchParams.get('secret') !== this.webhookSecret) {
this.logger.warn('Rejected iMessage webhook with invalid secret');
return new Response('Invalid secret', { status: 401 });
}
let event: BlueBubblesWebhookEvent;
try {
event = (await request.json()) as BlueBubblesWebhookEvent;
} catch {
return new Response('Invalid JSON', { status: 400 });
}
if (event.type !== NEW_MESSAGE_EVENT) {
return Response.json({ ok: true });
}
const message = await this.resolveWebhookMessage(event.data);
if (!message?.guid) {
this.logger.warn('Ignored iMessage webhook without message guid');
return Response.json({ ok: true });
}
if (message.isFromMe) {
return Response.json({ ok: true });
}
const chat = message.chats?.[0];
const chatGuid = chat?.guid;
if (!chatGuid) {
this.logger.warn('Ignored iMessage webhook without chat guid for message=%s', message.guid);
return Response.json({ ok: true });
}
const threadId = this.encodeThreadId({ chatGuid });
this.knownDmThreads.set(threadId, isDirectChat(chat));
const messageFactory = async () => this.parseInbound(message, threadId);
this.chat.processMessage(this, threadId, messageFactory, options);
return Response.json({ ok: true });
}
async postMessage(
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<BlueBubblesMessage>> {
const { chatGuid } = this.decodeThreadId(threadId);
const text = this.formatConverter.renderPostable(message);
const raw = this.transport?.sendText
? await this.transport.sendText(chatGuid, text)
: await this.getApi().sendText(chatGuid, text);
return {
id: raw.guid || raw.tempGuid || `local_${Date.now()}`,
raw,
threadId,
};
}
async editMessage(
threadId: string,
_messageId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<BlueBubblesMessage>> {
return this.postMessage(threadId, message);
}
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
this.logger.warn('Message deletion not supported for iMessage via BlueBubbles');
}
async fetchMessages(
threadId: string,
options?: FetchOptions,
): Promise<FetchResult<BlueBubblesMessage>> {
const { chatGuid } = this.decodeThreadId(threadId);
const result = this.transport?.getChatMessages
? await this.transport.getChatMessages(chatGuid, {
limit: options?.limit,
sort: 'DESC',
withParts: ['attachments'],
})
: await this.getApi().getChatMessages(chatGuid, {
limit: options?.limit,
sort: 'DESC',
withParts: ['attachments'],
});
return {
messages: result.data.map((raw) => this.parseInbound(raw, threadId)).reverse(),
nextCursor: undefined,
};
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { chatGuid } = this.decodeThreadId(threadId);
try {
const chat = this.transport?.getChat
? await this.transport.getChat(chatGuid, ['participants'])
: await this.getApi().getChat(chatGuid, ['participants']);
const isDM = isDirectChat(chat);
this.knownDmThreads.set(threadId, isDM);
return {
channelId: threadId,
channelName: chat.displayName || chat.chatIdentifier,
id: threadId,
isDM,
metadata: chat as unknown as Record<string, unknown>,
};
} catch (error) {
this.logger.warn('fetchThread failed for %s: %s', threadId, error);
return {
channelId: threadId,
id: threadId,
isDM: this.knownDmThreads.get(threadId) ?? false,
metadata: { chatGuid },
};
}
}
async addReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {}
async startTyping(threadId: string): Promise<void> {
const { chatGuid } = this.decodeThreadId(threadId);
try {
if (this.transport?.startTyping) {
await this.transport.startTyping(chatGuid);
} else {
await this.getApi().startTyping(chatGuid);
}
} catch (error) {
this.logger.warn('startTyping failed for %s: %s', threadId, error);
}
}
parseMessage(raw: BlueBubblesMessage, threadId?: string): Message<BlueBubblesMessage> {
return this.parseInbound(
raw,
threadId ?? this.encodeThreadId({ chatGuid: raw.chats?.[0]?.guid ?? this.botId }),
);
}
encodeThreadId(data: ImessageThreadId): string {
return encodeImessageThreadId(data);
}
decodeThreadId(threadId: string): ImessageThreadId {
return decodeImessageThreadId(threadId);
}
channelIdFromThreadId(threadId: string): string {
return threadId;
}
isDM(threadId: string): boolean {
return this.knownDmThreads.get(threadId) ?? false;
}
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
private async resolveWebhookMessage(
message: BlueBubblesMessage | undefined,
): Promise<BlueBubblesMessage | undefined> {
if (!message?.guid) return message;
if (message.chats?.[0]?.guid) return message;
if (!this.api) {
this.logger.warn(
'iMessage bridge webhook message=%s did not include chat data; configure Desktop bridge enrichment',
message.guid,
);
return message;
}
try {
return await this.api.getMessage(message.guid, ['chats', 'attachments']);
} catch (error) {
this.logger.warn('Failed to enrich iMessage webhook message=%s: %s', message.guid, error);
return message;
}
}
private getApi(): BlueBubblesApiClient {
if (!this.api) throw new Error('BlueBubbles API is not available in Desktop bridge mode');
return this.api;
}
private parseInbound(message: BlueBubblesMessage, threadId: string): Message<BlueBubblesMessage> {
const text = extractText(message);
const formatted = parseMarkdown(text);
const userId = message.isFromMe ? this.botId : senderIdFromMessage(message);
const author: Author = {
fullName: userId,
isBot: Boolean(message.isFromMe),
isMe: Boolean(message.isFromMe),
userId,
userName: userId,
};
return new Message({
attachments: extractAttachmentMetadata(message),
author,
formatted,
id: message.guid,
metadata: {
dateSent: dateFromBlueBubbles(message.dateCreated),
edited: false,
},
raw: message,
text,
threadId,
});
}
}
export function createImessageAdapter(config: ImessageAdapterConfig): ImessageAdapter {
return new ImessageAdapter(config);
}
export function resolveAttachmentGuid(raw: BlueBubblesAttachment | undefined): string | undefined {
return raw?.guid;
}
+325
View File
@@ -0,0 +1,325 @@
import { randomUUID } from 'node:crypto';
import type {
BlueBubblesApiConfig,
BlueBubblesAttachment,
BlueBubblesChat,
BlueBubblesDownloadedAttachment,
BlueBubblesMessage,
BlueBubblesOutboundAttachment,
BlueBubblesQueryResult,
BlueBubblesResponse,
BlueBubblesSendOptions,
BlueBubblesWebhook,
} from './types';
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
interface RequestOptions {
body?: FormData | Record<string, unknown>;
method?: 'DELETE' | 'GET' | 'POST';
query?: Record<string, boolean | number | string | undefined>;
signal?: AbortSignal;
}
export class BlueBubblesApiClient {
readonly password: string;
readonly requestTimeoutMs: number;
readonly serverUrl: string;
constructor(options: BlueBubblesApiConfig) {
if (!options.serverUrl?.trim()) throw new Error('BlueBubbles serverUrl is required');
if (!options.password?.trim()) throw new Error('BlueBubbles password is required');
this.serverUrl = stripTrailingSlashes(options.serverUrl.trim());
this.password = options.password;
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
async ping(): Promise<void> {
await this.requestData<Record<string, unknown>>('ping');
}
async getMessage(
guid: string,
withParts: string[] = ['chats', 'attachments'],
): Promise<BlueBubblesMessage> {
return this.requestData<BlueBubblesMessage>(`message/${encodeURIComponent(guid)}`, {
query: { with: withParts.join(',') },
});
}
async getChat(guid: string, withParts: string[] = ['participants']): Promise<BlueBubblesChat> {
return this.requestData<BlueBubblesChat>(`chat/${encodeURIComponent(guid)}`, {
query: { with: withParts.join(',') },
});
}
async getChatMessages(
chatGuid: string,
options: {
after?: number | string;
before?: number | string;
limit?: number;
offset?: number;
sort?: 'ASC' | 'DESC';
withParts?: string[];
} = {},
): Promise<BlueBubblesQueryResult<BlueBubblesMessage>> {
const response = await this.request<BlueBubblesMessage[]>(
`chat/${encodeURIComponent(chatGuid)}/message`,
{
query: {
after: options.after,
before: options.before,
limit: options.limit,
offset: options.offset,
sort: options.sort,
with: (options.withParts ?? ['attachments']).join(','),
},
},
);
return { data: response.data ?? [], metadata: response.metadata };
}
async queryMessages(
body: Record<string, unknown>,
): Promise<BlueBubblesQueryResult<BlueBubblesMessage>> {
const response = await this.request<BlueBubblesMessage[]>('message/query', {
body,
method: 'POST',
});
return { data: response.data ?? [], metadata: response.metadata };
}
async queryChats(
body: Record<string, unknown>,
): Promise<BlueBubblesQueryResult<BlueBubblesChat>> {
const response = await this.request<BlueBubblesChat[]>('chat/query', {
body,
method: 'POST',
});
return { data: response.data ?? [], metadata: response.metadata };
}
async registerWebhook(
url: string,
events: string[] = ['new-message'],
): Promise<BlueBubblesWebhook> {
return this.requestData<BlueBubblesWebhook>('webhook', {
body: { events, url },
method: 'POST',
});
}
async listWebhooks(url?: string): Promise<BlueBubblesWebhook[]> {
const response = await this.request<BlueBubblesWebhook[]>('webhook', {
query: { url },
});
return response.data ?? [];
}
async sendText(
chatGuid: string,
message: string,
options: BlueBubblesSendOptions = {},
): Promise<BlueBubblesMessage> {
return this.requestData<BlueBubblesMessage>('message/text', {
body: {
chatGuid,
message,
method: options.method ?? 'apple-script',
tempGuid: options.tempGuid ?? randomUUID(),
},
method: 'POST',
});
}
async sendAttachment(
chatGuid: string,
attachment: BlueBubblesOutboundAttachment,
options: BlueBubblesSendOptions = {},
): Promise<BlueBubblesMessage> {
const { buffer, mimeType } = await resolveAttachmentBytes(attachment, this.requestTimeoutMs);
const name = attachment.name || inferFileName(mimeType || attachment.mimeType);
const form = new FormData();
const attachmentBytes = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
form.set('chatGuid', chatGuid);
form.set('tempGuid', options.tempGuid ?? randomUUID());
form.set('method', options.method ?? 'apple-script');
form.set('name', name);
form.set(
'attachment',
new Blob([attachmentBytes], { type: mimeType ?? attachment.mimeType }),
name,
);
return this.requestData<BlueBubblesMessage>('message/attachment', {
body: form,
method: 'POST',
});
}
async startTyping(chatGuid: string): Promise<void> {
await this.requestData<Record<string, unknown>>(`chat/${encodeURIComponent(chatGuid)}/typing`, {
method: 'POST',
});
}
async stopTyping(chatGuid: string): Promise<void> {
await this.requestData<Record<string, unknown>>(`chat/${encodeURIComponent(chatGuid)}/typing`, {
method: 'DELETE',
});
}
async downloadAttachment(guid: string): Promise<BlueBubblesDownloadedAttachment> {
const url = this.buildUrl(`attachment/${encodeURIComponent(guid)}/download`, {
original: true,
});
const response = await fetchWithTimeout(url, { method: 'GET' }, this.requestTimeoutMs);
if (!response.ok) {
const detail = await safeReadError(response);
throw new Error(detail || `downloadAttachment ${guid} failed with HTTP ${response.status}`);
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
mimeType: response.headers.get('content-type') ?? undefined,
};
}
private async requestData<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.request<T>(path, options);
return (response.data ?? ({} as T)) as T;
}
private async request<T>(
path: string,
{ method = 'GET', body, query, signal }: RequestOptions = {},
): Promise<BlueBubblesResponse<T>> {
const url = this.buildUrl(path, query);
const init: RequestInit = { method, signal };
if (body instanceof FormData) {
init.body = body;
} else if (body) {
init.body = JSON.stringify(body);
init.headers = { 'Content-Type': 'application/json' };
}
const response = await fetchWithTimeout(url, init, this.requestTimeoutMs);
return parseResponse<T>(response, path);
}
private buildUrl(
path: string,
query?: Record<string, boolean | number | string | undefined>,
): string {
const url = new URL(path.replace(/^\/+/, ''), `${this.serverUrl}/api/v1/`);
url.searchParams.set('password', this.password);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined) continue;
url.searchParams.set(key, String(value));
}
return url.toString();
}
}
async function parseResponse<T>(
response: Response,
label: string,
): Promise<BlueBubblesResponse<T>> {
const text = await response.text();
const payload = parseJson<BlueBubblesResponse<T>>(text);
if (!response.ok) {
const detail = readBlueBubblesError(payload) ?? text;
throw new Error(detail || `${label} failed with HTTP ${response.status}`);
}
return payload ?? {};
}
async function safeReadError(response: Response): Promise<string | undefined> {
const text = await response.text();
const payload = parseJson<BlueBubblesResponse>(text);
return readBlueBubblesError(payload) ?? (text || undefined);
}
function readBlueBubblesError(payload: BlueBubblesResponse | undefined): string | undefined {
if (!payload) return undefined;
const data = payload.data as { error?: unknown; message?: unknown } | undefined;
if (typeof data?.error === 'string') return data.error;
if (typeof data?.message === 'string') return data.message;
return payload.message;
}
function parseJson<T>(text: string): T | undefined {
if (!text) return undefined;
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), timeoutMs);
const signal = init.signal ? AbortSignal.any([init.signal, abort.signal]) : abort.signal;
try {
return await fetch(url, { ...init, signal });
} finally {
clearTimeout(timer);
}
}
function stripTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url[end - 1] === '/') end--;
return url.slice(0, end);
}
async function resolveAttachmentBytes(
attachment: BlueBubblesOutboundAttachment,
requestTimeoutMs: number,
): Promise<{ buffer: Buffer; mimeType?: string }> {
if (attachment.data) {
return { buffer: Buffer.from(attachment.data, 'base64'), mimeType: attachment.mimeType };
}
if (!attachment.fetchUrl) {
throw new Error('BlueBubbles attachment requires either data or fetchUrl');
}
const response = await fetchWithTimeout(attachment.fetchUrl, { method: 'GET' }, requestTimeoutMs);
if (!response.ok) {
throw new Error(`Failed to fetch attachment ${attachment.fetchUrl}: HTTP ${response.status}`);
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
mimeType: response.headers.get('content-type') ?? attachment.mimeType,
};
}
function inferFileName(mimeType: string | undefined): string {
if (!mimeType) return 'attachment.bin';
const [topLevel, subtype] = mimeType.split('/');
if (!subtype) return 'attachment.bin';
if (topLevel === 'image') return `image.${subtype}`;
if (topLevel === 'video') return `video.${subtype}`;
if (topLevel === 'audio') return `audio.${subtype}`;
return `attachment.${subtype}`;
}
export function resolveAttachmentName(attachment: BlueBubblesAttachment): string {
return attachment.transferName || attachment.filename || `${attachment.guid}.bin`;
}
@@ -0,0 +1,17 @@
import type { Root } from 'chat';
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
/**
* iMessage ultimately receives plain text through BlueBubbles. Keeping the
* markdown markers here preserves Chat SDK compatibility; the LobeHub platform
* client strips markdown before final bot replies are sent.
*/
export class ImessageFormatConverter extends BaseFormatConverter {
fromAst(ast: Root): string {
return stringifyMarkdown(ast);
}
toAst(text: string): Root {
return parseMarkdown(text.trim());
}
}
@@ -0,0 +1,27 @@
export {
createImessageAdapter,
decodeImessageThreadId,
encodeImessageThreadId,
extractAttachmentMetadata,
ImessageAdapter,
resolveAttachmentGuid,
} from './adapter';
export { BlueBubblesApiClient, resolveAttachmentName } from './api';
export { ImessageFormatConverter } from './format-converter';
export type {
BlueBubblesApiConfig,
BlueBubblesAttachment,
BlueBubblesChat,
BlueBubblesDownloadedAttachment,
BlueBubblesHandle,
BlueBubblesMessage,
BlueBubblesOutboundAttachment,
BlueBubblesQueryResult,
BlueBubblesResponse,
BlueBubblesSendOptions,
BlueBubblesWebhook,
BlueBubblesWebhookEvent,
ImessageAdapterConfig,
ImessageBridgeTransport,
ImessageThreadId,
} from './types';
+137
View File
@@ -0,0 +1,137 @@
export interface BlueBubblesApiConfig {
/**
* BlueBubbles API password. The server accepts it as the `password` query
* parameter for REST calls.
*/
password: string;
requestTimeoutMs?: number;
/**
* Public base URL of the BlueBubbles server, e.g. `https://mac.example.com`.
*/
serverUrl: string;
}
export interface ImessageBridgeTransport {
getChat?: (guid: string, withParts?: string[]) => Promise<BlueBubblesChat>;
getChatMessages?: (
chatGuid: string,
options?: {
after?: number | string;
before?: number | string;
limit?: number;
offset?: number;
sort?: 'ASC' | 'DESC';
withParts?: string[];
},
) => Promise<BlueBubblesQueryResult<BlueBubblesMessage>>;
sendText?: (
chatGuid: string,
message: string,
options?: BlueBubblesSendOptions,
) => Promise<BlueBubblesMessage>;
startTyping?: (chatGuid: string) => Promise<void>;
}
export interface ImessageAdapterConfig {
botUserId?: string;
password?: string;
requestTimeoutMs?: number;
serverUrl?: string;
transport?: ImessageBridgeTransport;
userName?: string;
/**
* Shared secret appended to the LobeHub webhook URL. BlueBubbles webhooks are
* not signed, so the route-level secret is the lightweight authenticity gate.
*/
webhookSecret: string;
}
export interface BlueBubblesResponse<T = unknown> {
data?: T;
message?: string;
metadata?: {
count?: number;
limit?: number;
offset?: number;
total?: number;
[key: string]: unknown;
};
status?: number;
}
export interface BlueBubblesHandle {
address?: string;
country?: string;
guid?: string;
service?: string;
uncanonicalizedId?: string;
}
export interface BlueBubblesChat {
chatIdentifier?: string;
displayName?: string;
guid: string;
lastMessage?: BlueBubblesMessage;
participants?: BlueBubblesHandle[];
serviceName?: string;
style?: number;
}
export interface BlueBubblesAttachment {
filename?: string;
guid: string;
mimeType?: string;
totalBytes?: number;
transferName?: string;
}
export interface BlueBubblesMessage {
attachments?: BlueBubblesAttachment[];
chats?: BlueBubblesChat[];
dateCreated?: number | null;
guid: string;
handle?: BlueBubblesHandle | null;
handleId?: number | string | null;
isFromMe?: boolean;
otherHandle?: number | string | null;
subject?: string | null;
tempGuid?: string;
text?: string | null;
}
export interface BlueBubblesWebhookEvent {
data?: BlueBubblesMessage;
type: string;
}
export interface BlueBubblesWebhook {
events: string[];
id: number;
url: string;
}
export interface BlueBubblesQueryResult<T> {
data: T[];
metadata?: BlueBubblesResponse<T[]>['metadata'];
}
export interface BlueBubblesSendOptions {
method?: 'apple-script' | 'private-api';
tempGuid?: string;
}
export interface BlueBubblesOutboundAttachment {
data?: string;
fetchUrl?: string;
mimeType?: string;
name?: string;
}
export interface BlueBubblesDownloadedAttachment {
buffer: Buffer;
mimeType?: string;
}
export interface ImessageThreadId {
chatGuid: string;
}
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
all: false,
},
environment: 'node',
},
});
@@ -206,6 +206,24 @@ describe('GatewayClient', () => {
expect(toolCallCb).toHaveBeenCalledWith(msg);
});
it('should handle message_api_request', () => {
const messageApiCb = vi.fn();
client.on('message_api_request', messageApiCb);
const msg = {
api: {
apiName: 'sendText',
payload: { chatGuid: 'chat-1', message: 'hello' },
platform: 'imessage',
},
requestId: 'req-message-1',
type: 'message_api_request',
};
handler(JSON.stringify(msg));
expect(messageApiCb).toHaveBeenCalledWith(msg);
});
it('should handle system_info_request', () => {
const sysInfoCb = vi.fn();
client.on('system_info_request', sysInfoCb);
@@ -268,6 +286,27 @@ describe('GatewayClient', () => {
});
});
describe('sendMessageApiResponse', () => {
it('should send message API response message', async () => {
client.connect();
await vi.advanceTimersByTimeAsync(1);
const ws = (client as any).ws;
client.sendMessageApiResponse({
requestId: 'req-message-1',
result: { content: '{"ok":true}', success: true },
});
expect(ws.send).toHaveBeenCalledWith(
JSON.stringify({
requestId: 'req-message-1',
result: { content: '{"ok":true}', success: true },
type: 'message_api_response',
}),
);
});
});
describe('sendSystemInfoResponse', () => {
it('should send system info response message', async () => {
client.connect();
@@ -10,6 +10,8 @@ import type {
ClientMessage,
ConnectionStatus,
GatewayClientEvents,
MessageApiRequestMessage,
MessageApiResponseMessage,
ServerMessage,
SystemInfoRequestMessage,
SystemInfoResponseMessage,
@@ -146,6 +148,13 @@ export class GatewayClient extends EventEmitter {
});
}
sendMessageApiResponse(response: Omit<MessageApiResponseMessage, 'type'>): void {
this.sendMessage({
...response,
type: 'message_api_response',
});
}
sendSystemInfoResponse(response: Omit<SystemInfoResponseMessage, 'type'>): void {
this.sendMessage({
...response,
@@ -256,6 +265,11 @@ export class GatewayClient extends EventEmitter {
break;
}
case 'message_api_request': {
this.emit('message_api_request', message as MessageApiRequestMessage);
break;
}
case 'system_info_request': {
this.emit('system_info_request', message as SystemInfoRequestMessage);
break;
@@ -236,6 +236,83 @@ describe('GatewayHttpClient', () => {
});
});
describe('executeMessageApi', () => {
it('should return message API result on success', async () => {
mockFetch({
json: vi.fn().mockResolvedValue({ content: '{"guid":"sent-1"}', success: true }),
ok: true,
});
const result = await client.executeMessageApi(
{ userId: 'user-1' },
{ apiName: 'sendText', payload: { chatGuid: 'chat-1' }, platform: 'imessage' },
);
expect(result).toEqual({ content: '{"guid":"sent-1"}', error: undefined, success: true });
expect(fetch).toHaveBeenCalledWith(
'https://gateway.test.com/api/device/message-api',
expect.objectContaining({
body: JSON.stringify({
api: { apiName: 'sendText', payload: { chatGuid: 'chat-1' }, platform: 'imessage' },
userId: 'user-1',
}),
}),
);
});
it('should handle non-string content', async () => {
mockFetch({
json: vi.fn().mockResolvedValue({ content: { ok: true }, success: true }),
ok: true,
});
const result = await client.executeMessageApi(
{ userId: 'user-1' },
{ apiName: 'ping', payload: {}, platform: 'imessage' },
);
expect(result.content).toBe(JSON.stringify({ ok: true }));
});
it('should handle non-ok response', async () => {
mockFetch({
ok: false,
status: 503,
text: vi.fn().mockResolvedValue('Desktop offline'),
});
const result = await client.executeMessageApi(
{ userId: 'user-1' },
{ apiName: 'ping', payload: {}, platform: 'imessage' },
);
expect(result).toEqual({
content: 'Device message API call failed (HTTP 503)',
error: 'Desktop offline',
success: false,
});
});
it('should pass optional deviceId and timeout', async () => {
mockFetch({
json: vi.fn().mockResolvedValue({ content: 'ok', success: true }),
ok: true,
});
await client.executeMessageApi(
{ deviceId: 'device-1', timeout: 5000, userId: 'user-1' },
{ apiName: 'ping', payload: {}, platform: 'imessage' },
);
expect(fetch).toHaveBeenCalledWith(
'https://gateway.test.com/api/device/message-api',
expect.objectContaining({
body: expect.stringContaining('"deviceId":"device-1"'),
}),
);
});
});
describe('getDeviceSystemInfo', () => {
it('should return system info on success', async () => {
const systemInfo = {
@@ -11,6 +11,12 @@ export interface DeviceToolCallResult {
success: boolean;
}
export interface DeviceMessageApiResult {
content: string;
error?: string;
success: boolean;
}
export interface GatewayHttpClientOptions {
gatewayUrl: string;
serviceToken: string;
@@ -73,6 +79,35 @@ export class GatewayHttpClient {
};
}
async executeMessageApi(
params: { deviceId?: string; timeout?: number; userId: string },
api: { apiName: string; payload: Record<string, unknown>; platform: string },
): Promise<DeviceMessageApiResult> {
const res = await this.post('/api/device/message-api', {
api,
deviceId: params.deviceId,
timeout: params.timeout,
userId: params.userId,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return {
content: `Device message API call failed (HTTP ${res.status})`,
error: text || `HTTP ${res.status}`,
success: false,
};
}
const data = await res.json();
return {
content:
typeof data.content === 'string' ? data.content : JSON.stringify(data.content ?? data),
error: data.error,
success: data.success ?? true,
};
}
async dispatchAgentRun(params: {
agentType: string;
cwd?: string;
+6 -1
View File
@@ -1,5 +1,10 @@
export type { GatewayClientLogger, GatewayClientOptions } from './client';
export { GatewayClient } from './client';
export type { DeviceStatusResult, DeviceToolCallResult, GatewayHttpClientOptions } from './http';
export type {
DeviceMessageApiResult,
DeviceStatusResult,
DeviceToolCallResult,
GatewayHttpClientOptions,
} from './http';
export { GatewayHttpClient } from './http';
export * from './types';
@@ -44,6 +44,16 @@ export interface ToolCallResponseMessage {
type: 'tool_call_response';
}
export interface MessageApiResponseMessage {
requestId: string;
result: {
content: string;
error?: string;
success: boolean;
};
type: 'message_api_response';
}
// Server → Client
export interface HeartbeatAckMessage {
type: 'heartbeat_ack';
@@ -72,6 +82,16 @@ export interface ToolCallRequestMessage {
type: 'tool_call_request';
}
export interface MessageApiRequestMessage {
api: {
apiName: string;
payload: Record<string, unknown>;
platform: string;
};
requestId: string;
type: 'message_api_request';
}
// Server → Client
export interface SystemInfoRequestMessage {
requestId: string;
@@ -112,6 +132,7 @@ export type ClientMessage =
| AgentRunAckMessage
| AuthMessage
| HeartbeatMessage
| MessageApiResponseMessage
| SystemInfoResponseMessage
| ToolCallResponseMessage;
export type ServerMessage =
@@ -120,6 +141,7 @@ export type ServerMessage =
| AuthFailedMessage
| AuthSuccessMessage
| HeartbeatAckMessage
| MessageApiRequestMessage
| SystemInfoRequestMessage
| ToolCallRequestMessage;
@@ -140,6 +162,7 @@ export interface GatewayClientEvents {
disconnected: () => void;
error: (error: Error) => void;
heartbeat_ack: () => void;
message_api_request: (request: MessageApiRequestMessage) => void;
reconnecting: (delay: number) => void;
status_changed: (status: ConnectionStatus) => void;
system_info_request: (request: SystemInfoRequestMessage) => void;
@@ -0,0 +1,25 @@
export interface ImessageBridgeConfig {
applicationId: string;
blueBubblesPassword?: string;
blueBubblesServerUrl: string;
enabled: boolean;
webhookSecret: string;
}
export interface ImessageBridgePublicConfig extends Omit<
ImessageBridgeConfig,
'blueBubblesPassword'
> {
blueBubblesPasswordSet: boolean;
}
export interface ImessageBridgeStatus {
configs: ImessageBridgePublicConfig[];
running: boolean;
serverUrl?: string;
}
export interface ImessageBridgeSaveResult {
config: ImessageBridgePublicConfig;
success: boolean;
}
@@ -1,6 +1,7 @@
export * from './dataSync';
export * from './git';
export * from './heterogeneousAgent';
export * from './imessageBridge';
export * from './localSystem';
export * from './mcpInstall';
export * from './notification';
+38
View File
@@ -57,6 +57,42 @@ export default {
'channel.feishu.webhookMigrationTitle': 'Consider migrating to WebSocket mode',
'channel.feishu.webhookMigrationDesc':
'WebSocket mode provides real-time event delivery without needing a public callback URL. To migrate, switch the Connection Mode to WebSocket in Advanced Settings. No additional configuration is needed on the Feishu/Lark Open Platform.',
'channel.imessage.description':
'Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.',
'channel.imessage.applicationIdHint':
'A stable identifier shared by the cloud channel and the Desktop bridge.',
'channel.imessage.applicationIdPlaceholder': 'e.g. home-mac-mini',
'channel.imessage.blueBubblesPassword': 'BlueBubbles Password',
'channel.imessage.blueBubblesPasswordHint':
'Stored locally in LobeHub Desktop and used only to call the local BlueBubbles server.',
'channel.imessage.blueBubblesServerUrl': 'BlueBubbles Server URL',
'channel.imessage.blueBubblesServerUrlHint':
'The local BlueBubbles server URL reachable from this Desktop app.',
'channel.imessage.bridgeEnabled': 'Enable Bridge',
'channel.imessage.bridgeEnabledHint':
'When enabled, LobeHub Desktop receives local BlueBubbles webhooks and forwards them to LobeHub.',
'channel.imessage.bridgeMissingApplicationId': 'Enter the Application ID first.',
'channel.imessage.bridgeMissingPassword': 'Enter the BlueBubbles password first.',
'channel.imessage.bridgeMissingServerUrl': 'Enter the BlueBubbles Server URL first.',
'channel.imessage.bridgeMissingWebhookSecret': 'Enter the Webhook Secret first.',
'channel.imessage.bridgePasswordSavedPlaceholder': 'Leave blank to keep the saved password',
'channel.imessage.bridgeRefresh': 'Refresh',
'channel.imessage.bridgeRefreshFailed': 'Failed to refresh iMessage Desktop bridge',
'channel.imessage.bridgeRunning': 'Running',
'channel.imessage.bridgeSave': 'Save Bridge',
'channel.imessage.bridgeSaveFailed': 'Failed to save iMessage Desktop bridge',
'channel.imessage.bridgeSaved': 'iMessage Desktop bridge saved',
'channel.imessage.bridgeStopped': 'Stopped',
'channel.imessage.bridgeTest': 'Test BlueBubbles',
'channel.imessage.bridgeTestFailed': 'BlueBubbles test failed',
'channel.imessage.bridgeTestSuccess': 'BlueBubbles connection passed',
'channel.imessage.desktopDeviceId': 'Desktop Device ID',
'channel.imessage.desktopDeviceIdHint':
'The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.',
'channel.imessage.desktopBridge': 'Desktop Bridge',
'channel.imessage.webhookSecret': 'Webhook Secret',
'channel.imessage.webhookSecretHint':
'A shared secret used between LobeHub Desktop and the cloud webhook. Use the same value in the Desktop bridge config.',
'channel.lark.description': 'Connect this assistant to Lark for private and group chats.',
'channel.line.description':
'Connect this assistant to LINE Messaging API for direct and group chats.',
@@ -234,6 +270,8 @@ export default {
'Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.',
'channel.userIdHint.feishu':
'Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.',
'channel.userIdHint.imessage':
'Use your iMessage handle as seen in BlueBubbles, usually an email address or E.164 phone number.',
'channel.userIdHint.line':
'Open the LINE Developers Console → your channel → Basic settings tab, and copy "Your user ID" (starts with U, 33 chars).',
'channel.userIdHint.qq': 'Your QQ number, shown on your QQ profile page.',
@@ -117,9 +117,12 @@ const Footer = memo<FooterProps>(
hasUserIdField &&
!(typeof effectiveUserId === 'string' && effectiveUserId.trim());
const webhookUrl = applicationId
? `${origin}/api/agent/webhooks/${platformId}/${applicationId}`
: `${origin}/api/agent/webhooks/${platformId}`;
const webhookUrl = useMemo(() => {
const path = applicationId
? `/api/agent/webhooks/${platformId}/${applicationId}`
: `/api/agent/webhooks/${platformId}`;
return `${origin}${path}`;
}, [applicationId, origin, platformId]);
return (
<div className={styles.bottom}>
@@ -0,0 +1,235 @@
'use client';
import { isDesktop } from '@lobechat/const';
import type { ImessageBridgePublicConfig } from '@lobechat/electron-client-ipc';
import { Flexbox, FormGroup, FormItem, Tag, Text } from '@lobehub/ui';
import { App, Button, Form as AntdForm, Switch } from 'antd';
import { RefreshCw, Save, TestTube2 } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import { gatewayConnectionService } from '@/services/electron/gatewayConnection';
import { imessageBridgeService } from '@/services/electron/imessageBridge';
interface BridgeFormState {
blueBubblesPassword: string;
blueBubblesServerUrl: string;
enabled: boolean;
}
const DEFAULT_BRIDGE_FORM: BridgeFormState = {
blueBubblesPassword: '',
blueBubblesServerUrl: '',
enabled: true,
};
const getErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const CredentialExtras = memo(() => {
const { t: _t } = useTranslation('agent');
const t = _t as (key: string) => string;
const { message } = App.useApp();
const form = AntdForm.useFormInstance();
const applicationId = AntdForm.useWatch('applicationId', form) as string | undefined;
const webhookSecret = AntdForm.useWatch(['credentials', 'webhookSecret'], form) as
| string
| undefined;
const [bridgeForm, setBridgeForm] = useState<BridgeFormState>(DEFAULT_BRIDGE_FORM);
const [loading, setLoading] = useState(false);
const [passwordSet, setPasswordSet] = useState(false);
const [running, setRunning] = useState(false);
const [saving, setSaving] = useState(false);
const [serverUrl, setServerUrl] = useState<string>();
const [testing, setTesting] = useState(false);
const fillDesktopDeviceId = useCallback(async () => {
const deviceInfo = await gatewayConnectionService.getDeviceInfo();
form.setFieldValue(['credentials', 'desktopDeviceId'], deviceInfo.deviceId);
void form.validateFields([['credentials', 'desktopDeviceId']]).catch(() => undefined);
}, [form]);
const refreshStatus = useCallback(async () => {
if (!isDesktop) return;
setLoading(true);
try {
await fillDesktopDeviceId();
const status = await imessageBridgeService.getStatus();
const savedConfig = status.configs.find(
(config: ImessageBridgePublicConfig) => config.applicationId === applicationId?.trim(),
);
setBridgeForm(
savedConfig
? {
blueBubblesPassword: '',
blueBubblesServerUrl: savedConfig.blueBubblesServerUrl,
enabled: savedConfig.enabled,
}
: DEFAULT_BRIDGE_FORM,
);
setPasswordSet(Boolean(savedConfig?.blueBubblesPasswordSet));
setRunning(status.running);
setServerUrl(status.serverUrl);
} catch (error) {
message.error(`${t('channel.imessage.bridgeRefreshFailed')}: ${getErrorMessage(error)}`);
} finally {
setLoading(false);
}
}, [applicationId, fillDesktopDeviceId, message, t]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
if (!isDesktop) return null;
const getBridgeConfig = () => {
const appId = applicationId?.trim();
const secret = webhookSecret?.trim();
const blueBubblesServerUrl = bridgeForm.blueBubblesServerUrl.trim();
const blueBubblesPassword = bridgeForm.blueBubblesPassword.trim();
if (!appId) {
message.warning(t('channel.imessage.bridgeMissingApplicationId'));
return;
}
if (!secret) {
message.warning(t('channel.imessage.bridgeMissingWebhookSecret'));
return;
}
if (!blueBubblesServerUrl) {
message.warning(t('channel.imessage.bridgeMissingServerUrl'));
return;
}
if (!blueBubblesPassword && !passwordSet) {
message.warning(t('channel.imessage.bridgeMissingPassword'));
return;
}
return {
applicationId: appId,
blueBubblesPassword: blueBubblesPassword || undefined,
blueBubblesServerUrl,
enabled: bridgeForm.enabled,
webhookSecret: secret,
};
};
const handleSave = async () => {
const config = getBridgeConfig();
if (!config) return;
setSaving(true);
try {
await fillDesktopDeviceId();
await imessageBridgeService.upsertConfig(config);
message.success(t('channel.imessage.bridgeSaved'));
await refreshStatus();
} catch (error) {
message.error(`${t('channel.imessage.bridgeSaveFailed')}: ${getErrorMessage(error)}`);
} finally {
setSaving(false);
}
};
const handleTest = async () => {
const config = getBridgeConfig();
if (!config) return;
setTesting(true);
try {
await imessageBridgeService.testConfig(config);
message.success(t('channel.imessage.bridgeTestSuccess'));
} catch (error) {
message.error(`${t('channel.imessage.bridgeTestFailed')}: ${getErrorMessage(error)}`);
} finally {
setTesting(false);
}
};
return (
<FormGroup
style={{ marginBlockStart: 16 }}
title={t('channel.imessage.desktopBridge')}
variant="borderless"
extra={
<Button
icon={<RefreshCw size={14} />}
loading={loading}
size="small"
type="text"
onClick={refreshStatus}
>
{t('channel.imessage.bridgeRefresh')}
</Button>
}
>
<FormItem
desc={t('channel.imessage.blueBubblesServerUrlHint')}
label={t('channel.imessage.blueBubblesServerUrl')}
minWidth={'max(50%, 400px)'}
variant="borderless"
>
<FormInput
placeholder="http://127.0.0.1:1234"
value={bridgeForm.blueBubblesServerUrl}
onChange={(value) =>
setBridgeForm((previous) => ({ ...previous, blueBubblesServerUrl: value }))
}
/>
</FormItem>
<FormItem
divider
desc={t('channel.imessage.blueBubblesPasswordHint')}
label={t('channel.imessage.blueBubblesPassword')}
minWidth={'max(50%, 400px)'}
variant="borderless"
>
<FormPassword
autoComplete="new-password"
placeholder={passwordSet ? t('channel.imessage.bridgePasswordSavedPlaceholder') : ''}
value={bridgeForm.blueBubblesPassword}
onChange={(value) =>
setBridgeForm((previous) => ({ ...previous, blueBubblesPassword: value }))
}
/>
</FormItem>
<FormItem
divider
desc={t('channel.imessage.bridgeEnabledHint')}
label={t('channel.imessage.bridgeEnabled')}
minWidth={'max(50%, 400px)'}
variant="borderless"
>
<Switch
checked={bridgeForm.enabled}
onChange={(enabled) => setBridgeForm((previous) => ({ ...previous, enabled }))}
/>
</FormItem>
<Flexbox horizontal align="center" gap={8} style={{ marginBlockStart: 8 }}>
<Tag color={running ? 'green' : 'default'}>
{running ? t('channel.imessage.bridgeRunning') : t('channel.imessage.bridgeStopped')}
</Tag>
{serverUrl && (
<Text fontSize={12} type="secondary">
{serverUrl}
</Text>
)}
</Flexbox>
<Flexbox horizontal gap={8} style={{ marginBlockStart: 12 }}>
<Button icon={<TestTube2 size={14} />} loading={testing} onClick={handleTest}>
{t('channel.imessage.bridgeTest')}
</Button>
<Button icon={<Save size={14} />} loading={saving} type="primary" onClick={handleSave}>
{t('channel.imessage.bridgeSave')}
</Button>
</Flexbox>
</FormGroup>
);
});
export default CredentialExtras;
@@ -1,5 +1,6 @@
import type { ComponentType } from 'react';
import ImessageCredentialExtras from './imessage/CredentialExtras';
import LineCredentialExtras from './line/CredentialExtras';
import type { PlatformCredentialBodyProps } from './types';
import WechatCredentialBody from './wechat/CredentialBody';
@@ -19,5 +20,6 @@ export const platformCredentialBodyMap: Record<
* without replacing it wholesale.
*/
export const platformCredentialExtrasMap: Record<string, ComponentType> = {
imessage: ImessageCredentialExtras,
line: LineCredentialExtras,
};
@@ -227,6 +227,7 @@ export class BotCallbackService {
const client = entry.clientFactory.createClient(config, {
redisClient: getAgentRuntimeRedisClient() as any,
userId: row.userId ?? userId,
});
const messenger = client.getMessenger(platformThreadId);
@@ -322,6 +322,7 @@ export class BotMessageRouter {
const runtimeContext: BotPlatformRuntimeContext = {
appUrl: appEnv.APP_URL,
redisClient: getAgentRuntimeRedisClient() as any,
userId,
};
const client = entry.clientFactory.createClient(providerConfig, runtimeContext);
@@ -26,6 +26,7 @@ const USER_ID_TOOLTIP_BY_PLATFORM: Record<string, string> = {
// Feishu and Lark share `sharedSchema`, which always passes 'feishu' — the
// tooltip copy mentions both products so it reads naturally for either.
feishu: 'channel.userIdHint.feishu',
imessage: 'channel.userIdHint.imessage',
line: 'channel.userIdHint.line',
qq: 'channel.userIdHint.qq',
slack: 'channel.userIdHint.slack',
@@ -86,6 +87,7 @@ export type BotReplyLocale = Locales;
const PLATFORM_REPLY_LOCALES: Record<string, BotReplyLocale> = {
discord: 'en-US',
feishu: 'zh-CN',
imessage: 'en-US',
lark: 'en-US',
qq: 'zh-CN',
slack: 'en-US',
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ImessageClientFactory } from './client';
const mockExecuteMessageApi = vi.hoisted(() => vi.fn());
vi.mock('@/server/services/toolExecution/deviceProxy', () => ({
deviceProxy: {
executeMessageApi: mockExecuteMessageApi,
},
}));
vi.mock('@/server/services/gateway/runtimeStatus', () => ({
BOT_RUNTIME_STATUSES: {
connected: 'connected',
disconnected: 'disconnected',
failed: 'failed',
starting: 'starting',
},
getRuntimeStatusErrorMessage: (e: unknown) => (e instanceof Error ? e.message : 'unknown'),
updateBotRuntimeStatus: vi.fn().mockResolvedValue(undefined),
}));
const APPLICATION_ID = 'home-mac-mini';
const credentials = {
desktopDeviceId: 'desktop-device-1',
webhookSecret: 'shared-secret',
};
const createClient = (settings: Record<string, unknown> = {}) =>
new ImessageClientFactory().createClient(
{
applicationId: APPLICATION_ID,
credentials,
platform: 'imessage',
settings,
},
{ appUrl: 'https://lobehub.example.com', userId: 'user-1' },
);
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
mockExecuteMessageApi.mockReset();
});
describe('ImessageWebhookClient', () => {
it('extractChatId strips the iMessage thread prefix without changing the chat guid', () => {
const client = createClient();
expect(client.extractChatId('imessage:iMessage;-;abc:def')).toBe('iMessage;-;abc:def');
});
it('createAdapter wires the Desktop bridge transport into the SDK adapter', () => {
const client = createClient({ userId: 'operator@example.com' });
const adapter = client.createAdapter();
expect(adapter.imessage).toBeDefined();
expect((adapter.imessage as any).botUserId).toBe('operator@example.com');
});
it('messenger.createMessage sends text through the Desktop bridge', async () => {
mockExecuteMessageApi.mockResolvedValueOnce({
content: JSON.stringify({ guid: 'sent-1', text: 'hello' }),
success: true,
});
const client = createClient();
const messenger = client.getMessenger('imessage:iMessage;-;chat-1');
await messenger.createMessage('hello');
expect(mockExecuteMessageApi).toHaveBeenCalledWith(
{ deviceId: 'desktop-device-1', userId: 'user-1' },
{
apiName: 'sendText',
payload: {
applicationId: APPLICATION_ID,
chatGuid: 'iMessage;-;chat-1',
message: 'hello',
options: {},
},
platform: 'imessage',
},
60_000,
);
});
it('extractFiles downloads BlueBubbles attachments through the Desktop bridge', async () => {
mockExecuteMessageApi.mockResolvedValueOnce({
content: JSON.stringify({
data: Buffer.from('image-bytes').toString('base64'),
mimeType: 'image/png',
}),
success: true,
});
const client = createClient();
const sources = await (client as any).extractFiles({
attachments: [
{
mimeType: 'image/png',
name: 'photo.png',
raw: {
guid: 'att-1',
mimeType: 'image/png',
transferName: 'photo.png',
},
type: 'image',
url: '',
},
],
id: 'merged',
});
expect(sources).toHaveLength(1);
expect(sources[0].name).toBe('photo.png');
expect(sources[0].mimeType).toBe('image/png');
expect(sources[0].buffer.toString()).toBe('image-bytes');
expect(mockExecuteMessageApi).toHaveBeenCalledWith(
{ deviceId: 'desktop-device-1', userId: 'user-1' },
{
apiName: 'downloadAttachment',
payload: {
applicationId: APPLICATION_ID,
guid: 'att-1',
},
platform: 'imessage',
},
60_000,
);
});
it('formatMarkdown strips Markdown and formatReply appends usage only when enabled', () => {
const off = createClient();
const on = createClient({ showUsageStats: true });
expect(off.formatMarkdown!('**hi**')).toBe('hi');
expect(off.formatReply!('body', { totalCost: 0.01, totalTokens: 42 })).toBe('body');
expect(
on.formatReply!('body', { elapsedMs: 1234, totalCost: 0.01, totalTokens: 42 }).startsWith(
'body\n\n',
),
).toBe(true);
});
it('start verifies the Desktop bridge can reach BlueBubbles', async () => {
mockExecuteMessageApi.mockResolvedValueOnce({
content: JSON.stringify({ ok: true }),
success: true,
});
const client = createClient();
await client.start();
expect(mockExecuteMessageApi).toHaveBeenCalledWith(
{ deviceId: 'desktop-device-1', userId: 'user-1' },
{
apiName: 'ping',
payload: { applicationId: APPLICATION_ID },
platform: 'imessage',
},
60_000,
);
});
});
describe('ImessageClientFactory.validateCredentials', () => {
it('reports missing fields without hitting the Desktop bridge', async () => {
const factory = new ImessageClientFactory();
const result = await factory.validateCredentials({});
expect(result.valid).toBe(false);
const fields = (result.errors ?? []).map((e) => e.field).sort();
expect(fields).toEqual(['applicationId', 'desktopDeviceId', 'webhookSecret']);
expect(mockExecuteMessageApi).not.toHaveBeenCalled();
});
it('returns valid=true when required Desktop bridge fields are present', async () => {
const factory = new ImessageClientFactory();
const result = await factory.validateCredentials(credentials, undefined, APPLICATION_ID);
expect(result.valid).toBe(true);
expect(mockExecuteMessageApi).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,255 @@
import {
type BlueBubblesAttachment,
type BlueBubblesOutboundAttachment,
createImessageAdapter,
resolveAttachmentGuid,
resolveAttachmentName,
} from '@lobechat/chat-adapter-imessage';
import type { Message } from 'chat';
import debug from 'debug';
import type { AttachmentSource } from '@/server/services/aiAgent/ingestAttachment';
import {
BOT_RUNTIME_STATUSES,
getRuntimeStatusErrorMessage,
updateBotRuntimeStatus,
} from '@/server/services/gateway/runtimeStatus';
import { stripMarkdown } from '../stripMarkdown';
import {
type BotMessageAttachment,
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
messengerContentText,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
import { ImessageDesktopBridgeApi } from './desktopBridge';
const log = debug('bot-platform:imessage:bot');
interface ImessageCredentials {
desktopDeviceId: string;
webhookSecret: string;
}
function resolveCredentials(credentials: Record<string, string>): ImessageCredentials {
const desktopDeviceId = credentials.desktopDeviceId?.trim();
const webhookSecret = credentials.webhookSecret?.trim();
if (!desktopDeviceId) throw new Error('Desktop Device ID is required');
if (!webhookSecret) throw new Error('Webhook Secret is required');
return { desktopDeviceId, webhookSecret };
}
function decodeThread(platformThreadId: string): string {
return platformThreadId.startsWith('imessage:')
? platformThreadId.slice('imessage:'.length)
: platformThreadId;
}
function toBlueBubblesAttachment(attachment: BotMessageAttachment): BlueBubblesOutboundAttachment {
return {
data: attachment.data,
fetchUrl: attachment.fetchUrl,
mimeType: attachment.mimeType,
name: attachment.name,
};
}
class ImessageWebhookClient implements PlatformClient {
readonly id = 'imessage';
readonly applicationId: string;
private bridge: ImessageDesktopBridgeApi;
private config: BotProviderConfig;
private credentials: ImessageCredentials;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.applicationId = config.applicationId;
this.credentials = resolveCredentials(config.credentials);
if (!context.userId?.trim()) {
throw new Error('User ID is required for iMessage Desktop bridge');
}
this.bridge = new ImessageDesktopBridgeApi({
applicationId: this.applicationId,
deviceId: this.credentials.desktopDeviceId,
userId: context.userId,
});
}
async start(): Promise<void> {
log(
'Starting iMessage Desktop bridge appId=%s deviceId=%s',
this.applicationId,
this.credentials.desktopDeviceId,
);
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.starting,
});
try {
await this.bridge.ping();
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.connected,
});
log('iMessage Desktop bridge appId=%s ready', this.applicationId);
} catch (error) {
await updateBotRuntimeStatus({
applicationId: this.applicationId,
errorMessage: getRuntimeStatusErrorMessage(error),
platform: this.id,
status: BOT_RUNTIME_STATUSES.failed,
});
throw error;
}
}
async stop(): Promise<void> {
log('Stopping iMessage appId=%s', this.applicationId);
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.disconnected,
});
}
createAdapter(): Record<string, any> {
return {
imessage: createImessageAdapter({
botUserId: this.config.settings?.userId as string | undefined,
transport: {
getChat: this.bridge.getChat,
getChatMessages: this.bridge.getChatMessages,
sendText: this.bridge.sendText,
startTyping: this.bridge.startTyping,
},
webhookSecret: this.credentials.webhookSecret,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const chatGuid = decodeThread(platformThreadId);
return {
createMessage: async (content) => {
const text = messengerContentText(content);
const attachments = typeof content === 'string' ? undefined : content.attachments;
if (text.trim()) {
await this.bridge.sendText(chatGuid, text);
}
for (const attachment of attachments ?? []) {
await this.bridge.sendAttachment(chatGuid, toBlueBubblesAttachment(attachment));
}
},
editMessage: async (_messageId, content) => {
await this.getMessenger(platformThreadId).createMessage(content);
},
removeReaction: () => Promise.resolve(),
triggerTyping: async () => {
try {
await this.bridge.startTyping(chatGuid);
} catch (error) {
log('triggerTyping failed: %O', error);
}
},
};
}
async extractFiles(message: Message): Promise<AttachmentSource[] | undefined> {
const attachments = ((message as any).attachments ?? []) as Array<{
raw?: BlueBubblesAttachment;
}>;
const candidates = attachments
.map((attachment) => ({
guid: resolveAttachmentGuid(attachment.raw),
raw: attachment.raw,
}))
.filter((entry): entry is { guid: string; raw: BlueBubblesAttachment } =>
Boolean(entry.guid && entry.raw),
);
if (candidates.length === 0) return undefined;
const results = await Promise.all(
candidates.map(async ({ guid, raw }): Promise<AttachmentSource | undefined> => {
try {
const downloaded = await this.bridge.downloadAttachment(guid);
return {
buffer: downloaded.buffer,
mimeType: downloaded.mimeType ?? raw.mimeType ?? 'application/octet-stream',
name: resolveAttachmentName(raw),
size: downloaded.buffer.length,
};
} catch (error) {
log('extractFiles: downloadAttachment failed for guid=%s: %O', guid, error);
return undefined;
}
}),
);
const sources = results.filter((source): source is AttachmentSource => Boolean(source));
return sources.length > 0 ? sources : undefined;
}
extractChatId(platformThreadId: string): string {
return decodeThread(platformThreadId);
}
formatMarkdown(markdown: string): string {
return stripMarkdown(markdown);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class ImessageClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new ImessageWebhookClient(config, context);
}
async validateCredentials(
credentials: Record<string, string>,
_settings?: Record<string, unknown>,
applicationId?: string,
): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!credentials.desktopDeviceId?.trim()) {
errors.push({ field: 'desktopDeviceId', message: 'Desktop Device ID is required' });
}
if (!credentials.webhookSecret?.trim()) {
errors.push({ field: 'webhookSecret', message: 'Webhook Secret is required' });
}
if (!applicationId?.trim()) {
errors.push({ field: 'applicationId', message: 'Application ID is required' });
}
if (errors.length > 0) return { errors, valid: false };
return { valid: true };
}
}
export const imessageTestInternals = {
decodeThread,
};
@@ -0,0 +1,19 @@
import type { PlatformDefinition } from '../types';
import { ImessageClientFactory } from './client';
import { schema } from './schema';
export const imessage: PlatformDefinition = {
id: 'imessage',
name: 'iMessage',
connectionMode: 'webhook',
description: 'Connect iMessage through the local LobeHub Desktop BlueBubbles bridge.',
documentation: {
portalUrl: 'https://bluebubbles.app/',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/imessage',
},
schema,
showWebhookUrl: false,
supportsMarkdown: false,
supportsMessageEdit: false,
clientFactory: new ImessageClientFactory(),
};
@@ -0,0 +1,118 @@
import type {
BlueBubblesChat,
BlueBubblesDownloadedAttachment,
BlueBubblesMessage,
BlueBubblesOutboundAttachment,
BlueBubblesQueryResult,
BlueBubblesSendOptions,
} from '@lobechat/chat-adapter-imessage';
import { deviceProxy } from '@/server/services/toolExecution/deviceProxy';
const IMESSAGE_MESSAGE_API_TIMEOUT_MS = 60_000;
interface ImessageDesktopBridgeOptions {
applicationId: string;
deviceId: string;
userId: string;
}
interface DownloadAttachmentResult {
data: string;
mimeType?: string;
}
export class ImessageDesktopBridgeApi {
private readonly applicationId: string;
private readonly deviceId: string;
private readonly userId: string;
constructor(options: ImessageDesktopBridgeOptions) {
this.applicationId = options.applicationId;
this.deviceId = options.deviceId;
this.userId = options.userId;
}
ping = async (): Promise<void> => {
await this.call<Record<string, unknown>>('ping', {});
};
getChat = async (
guid: string,
withParts: string[] = ['participants'],
): Promise<BlueBubblesChat> => this.call<BlueBubblesChat>('getChat', { guid, withParts });
getChatMessages = async (
chatGuid: string,
options: {
after?: number | string;
before?: number | string;
limit?: number;
offset?: number;
sort?: 'ASC' | 'DESC';
withParts?: string[];
} = {},
): Promise<BlueBubblesQueryResult<BlueBubblesMessage>> =>
this.call<BlueBubblesQueryResult<BlueBubblesMessage>>('getChatMessages', {
chatGuid,
options,
});
queryMessages = async (
body: Record<string, unknown>,
): Promise<BlueBubblesQueryResult<BlueBubblesMessage>> =>
this.call<BlueBubblesQueryResult<BlueBubblesMessage>>('queryMessages', { body });
queryChats = async (
body: Record<string, unknown>,
): Promise<BlueBubblesQueryResult<BlueBubblesChat>> =>
this.call<BlueBubblesQueryResult<BlueBubblesChat>>('queryChats', { body });
sendText = async (
chatGuid: string,
message: string,
options: BlueBubblesSendOptions = {},
): Promise<BlueBubblesMessage> =>
this.call<BlueBubblesMessage>('sendText', { chatGuid, message, options });
sendAttachment = async (
chatGuid: string,
attachment: BlueBubblesOutboundAttachment,
options: BlueBubblesSendOptions = {},
): Promise<BlueBubblesMessage> =>
this.call<BlueBubblesMessage>('sendAttachment', { attachment, chatGuid, options });
startTyping = async (chatGuid: string): Promise<void> => {
await this.call<Record<string, unknown>>('startTyping', { chatGuid });
};
downloadAttachment = async (guid: string): Promise<BlueBubblesDownloadedAttachment> => {
const result = await this.call<DownloadAttachmentResult>('downloadAttachment', { guid });
return {
buffer: Buffer.from(result.data, 'base64'),
mimeType: result.mimeType,
};
};
private async call<T>(apiName: string, payload: Record<string, unknown>): Promise<T> {
const result = await deviceProxy.executeMessageApi(
{ deviceId: this.deviceId, userId: this.userId },
{
apiName,
payload: {
applicationId: this.applicationId,
...payload,
},
platform: 'imessage',
},
IMESSAGE_MESSAGE_API_TIMEOUT_MS,
);
if (!result.success) {
throw new Error(result.error || result.content || 'iMessage Desktop bridge call failed');
}
if (!result.content) return {} as T;
return JSON.parse(result.content) as T;
}
}
@@ -0,0 +1,90 @@
# iMessage via BlueBubbles Desktop Bridge Bot Integration Notes
Authoritative references:
- BlueBubbles REST API and webhooks: <https://docs.bluebubbles.app/server/developer-guides/rest-api-and-webhooks>
- BlueBubbles Server source: <https://github.com/BlueBubblesApp/bluebubbles-server>
## Architecture
LobeHub does not speak to Apple's iMessage service directly. Operators host
BlueBubbles Server on a Mac signed into Messages. The LobeHub Desktop app runs
a loopback webhook bridge on that Mac and keeps the BlueBubbles REST URL and
password local.
```text
iMessage -> macOS Messages -> BlueBubbles -> 127.0.0.1 LobeHub Desktop bridge
Desktop bridge -> /api/agent/webhooks/imessage/:applicationId -> LobeHub bot router
LobeHub bot reply -> Device Gateway tool call -> Desktop bridge -> BlueBubbles REST API
```
## Credentials
Cloud bot provider:
| Field | Source | Notes |
| ----------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `applicationId` | Operator-chosen LobeHub identifier | Shared by the cloud provider and Desktop bridge. |
| `desktopDeviceId` | LobeHub Desktop Gateway settings | Identifies the Desktop device that can reach BlueBubbles locally. |
| `webhookSecret` | Operator-generated | Desktop forwards BlueBubbles events to LobeHub with `?secret=<value>` because BlueBubbles does not sign. |
Desktop-only bridge config:
| Field | Source | Notes |
| ---------------------- | --------------------------- | -------------------------------------------------------------- |
| `applicationId` | Same as cloud provider | Selects which cloud bot receives forwarded events. |
| `blueBubblesServerUrl` | Local BlueBubbles base URL | Usually `http://127.0.0.1:<port>`. No public URL is required. |
| `blueBubblesPassword` | BlueBubbles Server password | Stored only in Desktop local settings. |
| `webhookSecret` | Same as cloud provider | Used by the Desktop loopback URL and the cloud forwarding URL. |
## Webhook lifecycle
1. Desktop starts a loopback HTTP server bound to `127.0.0.1`.
2. Desktop registers a BlueBubbles `new-message` webhook pointing to
`http://127.0.0.1:<port>/webhooks/bluebubbles/:applicationId?secret=...`.
3. BlueBubbles posts `{ "type": "new-message", "data": <message> }` locally.
4. Desktop enriches the payload with
`GET /api/v1/message/:guid?with=chats,attachments` when possible.
5. Desktop forwards the event to
`/api/agent/webhooks/imessage/:applicationId?secret=...`.
6. The Chat SDK adapter ignores `isFromMe` messages to avoid loops and
dispatches inbound messages as `imessage:<chatGuid>`.
## Outbound lifecycle
The server never calls BlueBubbles directly. It uses the existing Device Gateway
tool-call channel to ask the configured Desktop device to execute iMessage
bridge actions:
- `imessage.ping`
- `imessage.sendText`
- `imessage.sendAttachment`
- `imessage.startTyping`
- `imessage.downloadAttachment`
- `imessage.getChat`
- `imessage.getChatMessages`
- `imessage.queryMessages`
- `imessage.queryChats`
## Capabilities
- Text reply: Desktop calls `POST /api/v1/message/text`
- Attachment reply: Desktop calls `POST /api/v1/message/attachment`
- Attachment download: Desktop calls `GET /api/v1/attachment/:guid/download`
- Read recent messages: Desktop calls `GET /api/v1/chat/:guid/message`
- Search messages: Desktop calls `POST /api/v1/message/query`
- Channel metadata: Desktop calls `GET /api/v1/chat/:guid`
Typing indicators call `POST /api/v1/chat/:guid/typing`, which requires
BlueBubbles Private API. Failures are logged and ignored.
## Limitations
- LobeHub Desktop must stay online for inbound forwarding and outbound replies.
- iMessage has no general bot mention primitive. Group wake behavior relies on
watch keywords and group policy, not native `@bot` mentions.
- Message editing, deleting, reactions, pins, polls, and threads are not
exposed as LobeHub bot capabilities for iMessage.
- BlueBubbles advanced send features may require the Private API / SIP changes
on the Mac. LobeHub's basic text and attachment path uses AppleScript by
default.
@@ -0,0 +1,83 @@
import { DEFAULT_BOT_DEBOUNCE_MS, MAX_BOT_DEBOUNCE_MS } from '@lobechat/const';
import { displayToolCallsField, makeUserIdField, watchKeywordsField } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'desktopDeviceId',
description: 'channel.imessage.desktopDeviceIdHint',
label: 'channel.imessage.desktopDeviceId',
placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
required: true,
type: 'string',
},
{
key: 'webhookSecret',
description: 'channel.imessage.webhookSecretHint',
label: 'channel.imessage.webhookSecret',
required: true,
type: 'password',
},
],
type: 'object',
},
{
key: 'applicationId',
description: 'channel.imessage.applicationIdHint',
label: 'channel.applicationId',
placeholder: 'channel.imessage.applicationIdPlaceholder',
required: true,
type: 'string',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
makeUserIdField('imessage'),
{
key: 'charLimit',
default: 5000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 10_000,
minimum: 100,
type: 'number',
},
{
key: 'concurrency',
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumDescriptions: ['channel.concurrencyQueueHint', 'channel.concurrencyDebounceHint'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
},
{
key: 'debounceMs',
default: DEFAULT_BOT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_BOT_DEBOUNCE_MS,
minimum: 100,
type: 'number',
visibleWhen: { field: 'concurrency', value: 'debounce' },
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
displayToolCallsField,
watchKeywordsField,
],
type: 'object',
},
];
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImessageMessageService } from './service';
const makeApi = () => ({
getChatMessages: vi.fn().mockResolvedValue({
data: [
{
attachments: [{ guid: 'att-1', mimeType: 'image/png', transferName: 'photo.png' }],
dateCreated: 1_700_000_000_000,
guid: 'msg-1',
handle: { address: '+15551234567' },
text: 'hello',
},
],
}),
queryMessages: vi.fn().mockResolvedValue({
data: [
{
dateCreated: 1_700_000_000_000,
guid: 'msg-2',
handle: { address: '+15551234567' },
text: 'search hit',
},
{
dateCreated: 1_700_000_001_000,
guid: 'msg-3',
handle: { address: '+15557654321' },
text: 'other hit',
},
],
metadata: { total: 2 },
}),
sendAttachment: vi.fn().mockResolvedValue({ guid: 'att-sent' }),
sendText: vi.fn().mockResolvedValue({ guid: 'text-sent' }),
});
describe('ImessageMessageService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('sends text and attachments to the BlueBubbles chat guid', async () => {
const api = makeApi();
const service = new ImessageMessageService(api as any);
const result = await service.sendMessage({
attachments: [
{
data: Buffer.from('image-bytes').toString('base64'),
mimeType: 'image/png',
name: 'photo.png',
type: 'image',
},
],
channelId: 'iMessage;-;chat-1',
content: 'hello',
platform: 'imessage',
});
expect(api.sendText).toHaveBeenCalledWith('iMessage;-;chat-1', 'hello');
expect(api.sendAttachment).toHaveBeenCalledWith('iMessage;-;chat-1', {
data: Buffer.from('image-bytes').toString('base64'),
fetchUrl: undefined,
mimeType: 'image/png',
name: 'photo.png',
});
expect(result).toEqual({
channelId: 'iMessage;-;chat-1',
messageId: 'att-sent',
platform: 'imessage',
});
});
it('reads recent messages and maps BlueBubbles attachments', async () => {
const api = makeApi();
const service = new ImessageMessageService(api as any);
const result = await service.readMessages({
channelId: 'iMessage;-;chat-1',
limit: 10,
platform: 'imessage',
});
expect(api.getChatMessages).toHaveBeenCalledWith('iMessage;-;chat-1', {
after: undefined,
before: undefined,
limit: 10,
sort: 'DESC',
withParts: ['attachments'],
});
expect(result.messages?.[0]).toMatchObject({
attachments: [{ name: 'photo.png', url: 'bluebubbles:attachment:att-1' }],
author: { id: '+15551234567', name: '+15551234567' },
content: 'hello',
id: 'msg-1',
});
});
it('searches messages and applies optional author filtering', async () => {
const api = makeApi();
const service = new ImessageMessageService(api as any);
const result = await service.searchMessages({
authorId: '+15551234567',
channelId: 'iMessage;-;chat-1',
limit: 5,
platform: 'imessage',
query: 'hit',
});
expect(api.queryMessages).toHaveBeenCalledWith({
chatGuid: 'iMessage;-;chat-1',
limit: 5,
sort: 'DESC',
where: [
{
args: { query: '%hit%' },
statement: 'message.text LIKE :query COLLATE NOCASE',
},
],
with: ['attachments'],
});
expect(result.messages).toHaveLength(1);
expect(result.messages?.[0].id).toBe('msg-2');
expect(result.totalFound).toBe(1);
});
it('keeps the query metadata total when no author filter is applied', async () => {
const api = makeApi();
const service = new ImessageMessageService(api as any);
const result = await service.searchMessages({
channelId: 'iMessage;-;chat-1',
limit: 5,
platform: 'imessage',
query: 'hit',
});
expect(result.messages).toHaveLength(2);
expect(result.totalFound).toBe(2);
});
});
@@ -0,0 +1,255 @@
import type {
CreatePollParams,
CreatePollState,
CreateThreadParams,
CreateThreadState,
DeleteMessageParams,
DeleteMessageState,
EditMessageParams,
EditMessageState,
GetChannelInfoParams,
GetChannelInfoState,
GetMemberInfoParams,
GetMemberInfoState,
GetReactionsParams,
GetReactionsState,
ListChannelsParams,
ListChannelsState,
ListPinsParams,
ListPinsState,
ListThreadsParams,
ListThreadsState,
MessageItem,
PinMessageParams,
PinMessageState,
ReactToMessageParams,
ReactToMessageState,
ReadMessagesParams,
ReadMessagesState,
ReplyToThreadParams,
ReplyToThreadState,
SearchMessagesParams,
SearchMessagesState,
SendMessageParams,
SendMessageState,
UnpinMessageParams,
UnpinMessageState,
} from '@lobechat/builtin-tool-message/executionRuntime';
import type {
BlueBubblesChat,
BlueBubblesMessage,
BlueBubblesOutboundAttachment,
BlueBubblesQueryResult,
} from '@lobechat/chat-adapter-imessage';
import { resolveAttachmentName } from '@lobechat/chat-adapter-imessage';
import type { MessageRuntimeService } from '@/server/services/toolExecution/serverRuntimes/message/adapters/types';
import { PlatformUnsupportedError } from '@/server/services/toolExecution/serverRuntimes/message/PlatformUnsupportedError';
function authorFromMessage(message: BlueBubblesMessage): MessageItem['author'] {
if (message.isFromMe) return { id: 'me', name: 'me' };
const handle = message.handle;
const id =
handle?.address ||
handle?.uncanonicalizedId ||
String(message.handleId ?? message.otherHandle ?? 'unknown');
return { id, name: id };
}
function messageToItem(message: BlueBubblesMessage): MessageItem {
return {
attachments: (message.attachments ?? []).map((attachment) => ({
name: resolveAttachmentName(attachment),
url: `bluebubbles:attachment:${attachment.guid}`,
})),
author: authorFromMessage(message),
content: message.text ?? message.subject ?? '',
id: message.guid,
timestamp: new Date(message.dateCreated ?? Date.now()).toISOString(),
};
}
function chatName(chat: BlueBubblesChat): string {
return chat.displayName || chat.chatIdentifier || chat.guid;
}
interface ImessageMessageApi {
getChat: (guid: string, withParts?: string[]) => Promise<BlueBubblesChat>;
getChatMessages: (
chatGuid: string,
options?: {
after?: number | string;
before?: number | string;
limit?: number;
offset?: number;
sort?: 'ASC' | 'DESC';
withParts?: string[];
},
) => Promise<BlueBubblesQueryResult<BlueBubblesMessage>>;
queryChats: (body: Record<string, unknown>) => Promise<BlueBubblesQueryResult<BlueBubblesChat>>;
queryMessages: (
body: Record<string, unknown>,
) => Promise<BlueBubblesQueryResult<BlueBubblesMessage>>;
sendAttachment: (
chatGuid: string,
attachment: BlueBubblesOutboundAttachment,
) => Promise<BlueBubblesMessage>;
sendText: (chatGuid: string, message: string) => Promise<BlueBubblesMessage>;
}
/**
* iMessage message-tool adapter backed by BlueBubbles.
*
* The `channelId` accepted by Message tools is the BlueBubbles `chatGuid`
* (also exposed as `imessage:<chatGuid>` in inbound bot thread IDs).
*/
export class ImessageMessageService implements MessageRuntimeService {
constructor(private api: ImessageMessageApi) {}
sendMessage = async (params: SendMessageParams): Promise<SendMessageState> => {
let lastMessage: BlueBubblesMessage | undefined;
if (params.content?.trim()) {
lastMessage = await this.api.sendText(params.channelId, params.content);
}
for (const attachment of params.attachments ?? []) {
lastMessage = await this.api.sendAttachment(params.channelId, {
data: attachment.data,
fetchUrl: attachment.fetchUrl,
mimeType: attachment.mimeType,
name: attachment.name,
});
}
return {
channelId: params.channelId,
messageId: lastMessage?.guid ?? lastMessage?.tempGuid,
platform: 'imessage',
};
};
readMessages = async (params: ReadMessagesParams): Promise<ReadMessagesState> => {
const result = await this.api.getChatMessages(params.channelId, {
after: params.after,
before: params.before,
limit: params.limit ?? 25,
sort: 'DESC',
withParts: ['attachments'],
});
return {
channelId: params.channelId,
messages: result.data.map(messageToItem).reverse(),
platform: 'imessage',
totalFetched: result.data.length,
};
};
editMessage = async (_params: EditMessageParams): Promise<EditMessageState> => {
throw new PlatformUnsupportedError('iMessage', 'editMessage');
};
deleteMessage = async (_params: DeleteMessageParams): Promise<DeleteMessageState> => {
throw new PlatformUnsupportedError('iMessage', 'deleteMessage');
};
searchMessages = async (params: SearchMessagesParams): Promise<SearchMessagesState> => {
const result = await this.api.queryMessages({
chatGuid: params.channelId,
limit: params.limit ?? 25,
sort: 'DESC',
where: [
{
args: { query: `%${params.query}%` },
statement: 'message.text LIKE :query COLLATE NOCASE',
},
],
with: ['attachments'],
});
const authorId = params.authorId?.trim();
const filtered = authorId
? result.data.filter((message) => authorFromMessage(message).id === authorId)
: result.data;
return {
messages: filtered.map(messageToItem),
query: params.query,
totalFound: authorId ? filtered.length : (result.metadata?.total ?? filtered.length),
};
};
reactToMessage = async (_params: ReactToMessageParams): Promise<ReactToMessageState> => {
throw new PlatformUnsupportedError('iMessage', 'reactToMessage');
};
getReactions = async (_params: GetReactionsParams): Promise<GetReactionsState> => {
throw new PlatformUnsupportedError('iMessage', 'getReactions');
};
pinMessage = async (_params: PinMessageParams): Promise<PinMessageState> => {
throw new PlatformUnsupportedError('iMessage', 'pinMessage');
};
unpinMessage = async (_params: UnpinMessageParams): Promise<UnpinMessageState> => {
throw new PlatformUnsupportedError('iMessage', 'unpinMessage');
};
listPins = async (_params: ListPinsParams): Promise<ListPinsState> => {
throw new PlatformUnsupportedError('iMessage', 'listPins');
};
getChannelInfo = async (params: GetChannelInfoParams): Promise<GetChannelInfoState> => {
const chat = await this.api.getChat(params.channelId, ['participants']);
return {
id: chat.guid,
memberCount: chat.participants?.length,
name: chatName(chat),
type: chat.style === 43 ? 'group' : 'direct',
};
};
listChannels = async (_params: ListChannelsParams): Promise<ListChannelsState> => {
const result = await this.api.queryChats({
limit: 100,
sort: 'lastmessage',
with: ['lastmessage'],
});
return {
channels: result.data.map((chat) => ({
id: chat.guid,
name: chatName(chat),
type: chat.style === 43 ? 'group' : 'direct',
})),
};
};
getMemberInfo = async (_params: GetMemberInfoParams): Promise<GetMemberInfoState> => {
throw new PlatformUnsupportedError('iMessage', 'getMemberInfo');
};
createThread = async (_params: CreateThreadParams): Promise<CreateThreadState> => {
throw new PlatformUnsupportedError('iMessage', 'createThread');
};
listThreads = async (_params: ListThreadsParams): Promise<ListThreadsState> => {
throw new PlatformUnsupportedError('iMessage', 'listThreads');
};
replyToThread = async (params: ReplyToThreadParams): Promise<ReplyToThreadState> => {
const result = await this.sendMessage({
attachments: params.attachments,
channelId: params.threadId,
content: params.content,
platform: 'imessage',
});
return { messageId: result.messageId, threadId: params.threadId };
};
createPoll = async (_params: CreatePollParams): Promise<CreatePollState> => {
throw new PlatformUnsupportedError('iMessage', 'createPoll');
};
}
@@ -3,6 +3,7 @@
import { discord } from './discord/definition';
import { feishu } from './feishu/definitions/feishu';
import { lark } from './feishu/definitions/lark';
import { imessage } from './imessage/definition';
import { line } from './line/definition';
import { qq } from './qq/definition';
import { PlatformRegistry } from './registry';
@@ -82,6 +83,7 @@ export {
export { discord } from './discord/definition';
export { feishu } from './feishu/definitions/feishu';
export { lark } from './feishu/definitions/lark';
export { imessage } from './imessage/definition';
export { line } from './line/definition';
export { qq } from './qq/definition';
export { slack } from './slack/definition';
@@ -94,6 +96,7 @@ platformRegistry.register(discord);
platformRegistry.register(telegram);
platformRegistry.register(slack);
platformRegistry.register(feishu);
platformRegistry.register(imessage);
platformRegistry.register(lark);
platformRegistry.register(qq);
platformRegistry.register(wechat);
@@ -355,6 +355,7 @@ export interface BotPlatformRuntimeContext {
appUrl?: string;
redisClient?: BotPlatformRedisClient;
registerByToken?: (token: string) => void;
userId?: string;
}
// --------------- Validation ---------------
@@ -227,6 +227,29 @@ describe('GatewayManager', () => {
expect(mockBot.start).toHaveBeenCalled();
});
it('should scope lookup by the requested user but build runtime context from the provider row', async () => {
const mockBot = createMockBot();
const factory = vi.fn().mockReturnValue(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok123' },
settings: {},
userId: 'provider-user',
});
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startClient('slack', 'app-123', 'requested-user');
expect(vi.mocked(AgentBotProviderModel)).toHaveBeenCalledWith(
mockDb,
'requested-user',
mockGateKeeper,
);
expect(factory.mock.calls[0][1]).toMatchObject({ userId: 'provider-user' });
expect(mockBot.start).toHaveBeenCalled();
});
it('should stop existing bot before starting a new one for the same key', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
@@ -1,6 +1,7 @@
import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import type { DecryptedBotProvider } from '@/database/models/agentBotProvider';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
@@ -186,14 +187,7 @@ export class GatewayManager {
// Factory
// ------------------------------------------------------------------
private createClient(
platform: string,
provider: {
applicationId: string;
credentials: Record<string, string>;
settings?: Record<string, unknown> | null;
},
): PlatformClient | null {
private createClient(platform: string, provider: DecryptedBotProvider): PlatformClient | null {
const def = this.definitionByPlatform.get(platform);
if (!def) {
log('No definition registered for platform: %s', platform);
@@ -205,6 +199,7 @@ export class GatewayManager {
const context: BotPlatformRuntimeContext = {
appUrl: process.env.APP_URL,
redisClient: getAgentRuntimeRedisClient() as any,
userId: provider.userId,
};
return def.clientFactory.createClient(config, context);
@@ -9,6 +9,7 @@ const mockEnv = vi.hoisted(() => ({
}));
const mockClient = vi.hoisted(() => ({
executeMessageApi: vi.fn(),
executeToolCall: vi.fn(),
getDeviceSystemInfo: vi.fn(),
queryDeviceList: vi.fn(),
@@ -256,6 +257,67 @@ describe('DeviceProxy', () => {
});
});
describe('executeMessageApi', () => {
const params = { deviceId: 'dev-1', userId: 'user-1' };
const api = { apiName: 'sendText', payload: { chatGuid: 'chat-1' }, platform: 'imessage' };
it('should return error when not configured', async () => {
const proxy = new DeviceProxy();
const result = await proxy.executeMessageApi(params, api);
expect(result).toEqual({
content: 'Device Gateway is not configured',
error: 'GATEWAY_NOT_CONFIGURED',
success: false,
});
});
it('should execute message API with default timeout', async () => {
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
const expected = { content: '{"ok":true}', success: true };
mockClient.executeMessageApi.mockResolvedValue(expected);
const proxy = new DeviceProxy();
const result = await proxy.executeMessageApi(params, api);
expect(result).toEqual(expected);
expect(mockClient.executeMessageApi).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
api,
);
});
it('should use custom timeout', async () => {
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
mockClient.executeMessageApi.mockResolvedValue({ content: 'ok', success: true });
const proxy = new DeviceProxy();
await proxy.executeMessageApi(params, api, 60_000);
expect(mockClient.executeMessageApi).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 60_000, userId: 'user-1' },
api,
);
});
it('should return error result on exception', async () => {
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
mockClient.executeMessageApi.mockRejectedValue(new Error('connection refused'));
const proxy = new DeviceProxy();
const result = await proxy.executeMessageApi(params, api);
expect(result).toEqual({
content: 'Device message API error: connection refused',
error: 'connection refused',
success: false,
});
});
});
describe('getClient (lazy initialization)', () => {
it('should return null when URL is missing', async () => {
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
@@ -1,7 +1,9 @@
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
import {
type DeviceMessageApiResult,
type DeviceStatusResult,
type DeviceSystemInfo,
type DeviceToolCallResult,
GatewayHttpClient,
} from '@lobechat/device-gateway-client';
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
@@ -94,7 +96,7 @@ export class DeviceProxy {
params: { deviceId: string; userId: string },
toolCall: { apiName: string; arguments: string; identifier: string },
timeout = 30_000,
): Promise<{ content: string; error?: string; success: boolean }> {
): Promise<DeviceToolCallResult> {
const client = this.getClient();
if (!client) {
return {
@@ -124,6 +126,40 @@ export class DeviceProxy {
}
}
async executeMessageApi(
params: { deviceId: string; userId: string },
api: { apiName: string; payload: Record<string, unknown>; platform: string },
timeout = 30_000,
): Promise<DeviceMessageApiResult> {
const client = this.getClient();
if (!client) {
return {
content: 'Device Gateway is not configured',
error: 'GATEWAY_NOT_CONFIGURED',
success: false,
};
}
log(
'executeMessageApi: userId=%s, deviceId=%s, api=%s/%s',
params.userId,
params.deviceId,
api.platform,
api.apiName,
);
try {
return await client.executeMessageApi(
{ deviceId: params.deviceId, timeout, userId: params.userId },
api,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log('executeMessageApi: error — %s', message);
return { content: `Device message API error: ${message}`, error: message, success: false };
}
}
private getClient(): GatewayHttpClient | null {
const url = gatewayEnv.DEVICE_GATEWAY_URL;
const token = gatewayEnv.DEVICE_GATEWAY_SERVICE_TOKEN;
@@ -27,6 +27,8 @@ import { platformRegistry } from '@/server/services/bot/platforms';
import { DiscordApi } from '@/server/services/bot/platforms/discord/api';
import { DiscordMessageService } from '@/server/services/bot/platforms/discord/service';
import { FeishuMessageService } from '@/server/services/bot/platforms/feishu/service';
import { ImessageDesktopBridgeApi } from '@/server/services/bot/platforms/imessage/desktopBridge';
import { ImessageMessageService } from '@/server/services/bot/platforms/imessage/service';
import { QQMessageService } from '@/server/services/bot/platforms/qq/service';
import { SlackApi } from '@/server/services/bot/platforms/slack/api';
import { SlackMessageService } from '@/server/services/bot/platforms/slack/service';
@@ -82,6 +84,16 @@ export const messageRuntime: ServerRuntimeRegistration = {
'feishu',
);
},
imessage: async () => {
const { applicationId, credentials } = await resolveCredentials(providerModel, 'imessage');
return new ImessageMessageService(
new ImessageDesktopBridgeApi({
applicationId,
deviceId: credentials.desktopDeviceId,
userId: context.userId!,
}),
);
},
lark: async () => {
const { applicationId, credentials } = await resolveCredentials(providerModel, 'lark');
return new FeishuMessageService(
+31
View File
@@ -0,0 +1,31 @@
import type { ImessageBridgeConfig } from '@lobechat/electron-client-ipc';
import { ensureElectronIpc } from '@/utils/electron/ipc';
class ImessageBridgeService {
getStatus = async () => {
return ensureElectronIpc().imessageBridge.getStatus();
};
removeConfig = async (applicationId: string) => {
return ensureElectronIpc().imessageBridge.removeConfig({ applicationId });
};
start = async () => {
return ensureElectronIpc().imessageBridge.start();
};
stop = async () => {
return ensureElectronIpc().imessageBridge.stop();
};
testConfig = async (config: ImessageBridgeConfig) => {
return ensureElectronIpc().imessageBridge.testConfig(config);
};
upsertConfig = async (config: ImessageBridgeConfig) => {
return ensureElectronIpc().imessageBridge.upsertConfig(config);
};
}
export const imessageBridgeService = new ImessageBridgeService();