feat(channels): add signal-cli channel adapter
This commit is contained in:
@@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces.
|
||||
|
||||
- **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard
|
||||
- **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing
|
||||
- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp with unified adapter interface
|
||||
- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, and Signal with unified adapter interface
|
||||
- **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor
|
||||
- **Model Switching**: Switch between cloud/local models on demand
|
||||
- **Session Persistence**: SQLite-backed conversation history
|
||||
@@ -139,6 +139,17 @@ matrix:
|
||||
allowed_room_ids: ["!room1:example.org"]
|
||||
require_mention: true
|
||||
|
||||
# Optional: Signal (signal-cli)
|
||||
signal:
|
||||
account: "+15551234567"
|
||||
signal_cli_path: "signal-cli"
|
||||
allowed_numbers: ["+15550001111"]
|
||||
allowed_group_ids: []
|
||||
require_mention: true
|
||||
mention_name: "flynn"
|
||||
poll_interval_ms: 5000
|
||||
send_timeout_ms: 15000
|
||||
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
|
||||
@@ -9,6 +9,17 @@ telegram:
|
||||
bot_token: ${FLYNN_TELEGRAM_TOKEN}
|
||||
allowed_chat_ids: [] # Add your Telegram chat ID
|
||||
|
||||
# Optional: Signal via signal-cli
|
||||
# signal:
|
||||
# account: "+15551234567"
|
||||
# signal_cli_path: signal-cli
|
||||
# allowed_numbers: [] # Empty = allow all DMs
|
||||
# allowed_group_ids: [] # Empty = no groups
|
||||
# require_mention: true
|
||||
# mention_name: flynn
|
||||
# poll_interval_ms: 5000
|
||||
# send_timeout_ms: 15000
|
||||
|
||||
server:
|
||||
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
||||
# gateway to your tailnet via `tailscale serve`.
|
||||
|
||||
@@ -16,4 +16,5 @@ export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js';
|
||||
export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js';
|
||||
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js';
|
||||
export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js';
|
||||
export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js';
|
||||
export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js';
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { execFile } from 'child_process';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
import { SignalAdapter } from './adapter.js';
|
||||
import type { InboundMessage } from '../types.js';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecFile = vi.mocked(execFile);
|
||||
type ExecFileCallback = NonNullable<Parameters<typeof execFile>[3]>;
|
||||
|
||||
function mockChildProcess(): ChildProcess {
|
||||
return {} as ChildProcess;
|
||||
}
|
||||
|
||||
function mockExecFileOnce(impl: (callback: ExecFileCallback) => void): void {
|
||||
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
impl(callback as ExecFileCallback);
|
||||
}
|
||||
return mockChildProcess();
|
||||
});
|
||||
}
|
||||
|
||||
describe('SignalAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('has name signal and starts disconnected', () => {
|
||||
const adapter = new SignalAdapter({ account: '+15551234567' });
|
||||
expect(adapter.name).toBe('signal');
|
||||
expect(adapter.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('connect checks signal-cli availability', async () => {
|
||||
const adapter = new SignalAdapter({
|
||||
account: '+15551234567',
|
||||
pollIntervalMs: 60000,
|
||||
});
|
||||
mockExecFileOnce((callback) => callback(null, 'signal-cli 0.13.2', ''));
|
||||
mockExecFileOnce((callback) => callback(null, '', ''));
|
||||
|
||||
await adapter.connect();
|
||||
await adapter.disconnect();
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalled();
|
||||
const versionCall = mockExecFile.mock.calls[0];
|
||||
expect(versionCall[0]).toBe('signal-cli');
|
||||
expect(versionCall[1]).toEqual(['--version']);
|
||||
});
|
||||
|
||||
it('send uses -g for group peers', async () => {
|
||||
const adapter = new SignalAdapter({ account: '+15551234567' });
|
||||
mockExecFileOnce((callback) => callback(null, 'signal-cli 0.13.2', ''));
|
||||
mockExecFileOnce((callback) => callback(null, '', ''));
|
||||
mockExecFileOnce((callback) => callback(null, '', ''));
|
||||
|
||||
await adapter.connect();
|
||||
await adapter.send('group:abcd1234', { text: 'Hello group' });
|
||||
await adapter.disconnect();
|
||||
|
||||
const sendCall = mockExecFile.mock.calls.find((call) => Array.isArray(call[1]) && call[1].includes('send'));
|
||||
expect(sendCall).toBeDefined();
|
||||
expect(sendCall?.[1]).toEqual(['-u', '+15551234567', 'send', '-m', 'Hello group', '-g', 'abcd1234']);
|
||||
});
|
||||
|
||||
it('parses DM receive payload and forwards inbound message', async () => {
|
||||
const adapter = new SignalAdapter({
|
||||
account: '+15551234567',
|
||||
allowedNumbers: ['+15550001111'],
|
||||
});
|
||||
const messages: InboundMessage[] = [];
|
||||
adapter.onMessage((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
const run = adapter as unknown as {
|
||||
processReceiveOutput: (output: string) => Promise<void>;
|
||||
};
|
||||
await run.processReceiveOutput(
|
||||
JSON.stringify({
|
||||
envelope: {
|
||||
source: '+15550001111',
|
||||
sourceName: 'Alice',
|
||||
timestamp: 1700000000000,
|
||||
dataMessage: { message: 'hello from signal' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].channel).toBe('signal');
|
||||
expect(messages[0].senderId).toBe('+15550001111');
|
||||
expect(messages[0].text).toBe('hello from signal');
|
||||
});
|
||||
|
||||
it('requires mention in groups and strips leading mention token', async () => {
|
||||
const adapter = new SignalAdapter({
|
||||
account: '+15551234567',
|
||||
allowedGroupIds: ['grp1'],
|
||||
mentionName: 'flynn',
|
||||
requireMention: true,
|
||||
});
|
||||
const messages: InboundMessage[] = [];
|
||||
adapter.onMessage((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
const run = adapter as unknown as {
|
||||
processReceiveOutput: (output: string) => Promise<void>;
|
||||
};
|
||||
|
||||
await run.processReceiveOutput(
|
||||
JSON.stringify({
|
||||
envelope: {
|
||||
source: '+15550001111',
|
||||
timestamp: 1700000000000,
|
||||
dataMessage: {
|
||||
message: '@flynn check status',
|
||||
groupInfo: { groupId: 'grp1' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].senderId).toBe('group:grp1');
|
||||
expect(messages[0].text).toBe('check status');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
import type {
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.js';
|
||||
import {
|
||||
allowTrustedOrPairedSender,
|
||||
buildResetInboundMessage,
|
||||
isAllowedByAllowlist,
|
||||
normalizeResetCommandText,
|
||||
shouldIgnoreForMissingMention,
|
||||
splitMessage,
|
||||
} from '../utils.js';
|
||||
import type { PairingManager } from '../pairing.js';
|
||||
|
||||
export interface SignalAdapterConfig {
|
||||
/** Primary Signal account identifier used by signal-cli (-u). */
|
||||
account: string;
|
||||
/** Path to signal-cli binary. */
|
||||
signalCliPath?: string;
|
||||
/** Allowed direct-message sender numbers. Empty/undefined = allow all DMs. */
|
||||
allowedNumbers?: string[];
|
||||
/** Allowed group IDs. Empty/undefined = no groups allowed. */
|
||||
allowedGroupIds?: string[];
|
||||
/** Require mention in group chats (default: true). */
|
||||
requireMention?: boolean;
|
||||
/** Mention token used for group mention detection (default: flynn). */
|
||||
mentionName?: string;
|
||||
/** Poll interval for receive loop (default: 5000ms). */
|
||||
pollIntervalMs?: number;
|
||||
/** Timeout for send/receive CLI calls (default: 15000ms). */
|
||||
sendTimeoutMs?: number;
|
||||
/** Optional pairing manager for DM pairing codes. */
|
||||
pairingManager?: PairingManager;
|
||||
}
|
||||
|
||||
interface SignalEnvelope {
|
||||
envelope?: {
|
||||
source?: string;
|
||||
sourceName?: string;
|
||||
timestamp?: number;
|
||||
dataMessage?: {
|
||||
message?: string;
|
||||
body?: string;
|
||||
groupInfo?: { groupId?: string };
|
||||
groupId?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 3500;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
|
||||
export class SignalAdapter implements ChannelAdapter {
|
||||
readonly name = 'signal';
|
||||
|
||||
private _status: ChannelStatus = 'disconnected';
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
private readonly config: SignalAdapterConfig;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
private polling = false;
|
||||
|
||||
get status(): ChannelStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
constructor(config: SignalAdapterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
onMessage(handler: (msg: InboundMessage) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this._status = 'connecting';
|
||||
try {
|
||||
await this.execSignal(['--version']);
|
||||
this._status = 'connected';
|
||||
const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.pollTimer = setInterval(() => {
|
||||
void this.pollOnce();
|
||||
}, interval);
|
||||
void this.pollOnce();
|
||||
console.log(`Signal adapter connected (${this.config.account})`);
|
||||
} catch (error) {
|
||||
this._status = 'error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this._status = 'disconnected';
|
||||
}
|
||||
|
||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||
if (this._status !== 'connected') {
|
||||
throw new Error('Signal adapter not connected');
|
||||
}
|
||||
|
||||
const text = message.text.trim();
|
||||
if (text.length > 0) {
|
||||
const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text];
|
||||
for (const chunk of chunks) {
|
||||
await this.sendText(peerId, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
for (const a of message.attachments) {
|
||||
if (a.url) {
|
||||
const line = a.filename ? `${a.filename}: ${a.url}` : a.url;
|
||||
await this.sendText(peerId, line);
|
||||
} else if (a.data) {
|
||||
// Keep adapter minimal and robust: no temp-file attachment upload in this pass.
|
||||
console.warn(`Signal: skipping attachment data (${a.mimeType}) — upload not implemented`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendText(peerId: string, text: string): Promise<void> {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const args = ['-u', this.config.account, 'send', '-m', text];
|
||||
const groupId = this.extractGroupId(peerId);
|
||||
if (groupId) {
|
||||
args.push('-g', groupId);
|
||||
} else {
|
||||
args.push(peerId);
|
||||
}
|
||||
await this.execSignal(args);
|
||||
}
|
||||
|
||||
private async pollOnce(): Promise<void> {
|
||||
if (this.polling || !this.messageHandler || this._status !== 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.polling = true;
|
||||
try {
|
||||
const output = await this.execSignal([
|
||||
'-u',
|
||||
this.config.account,
|
||||
'-o',
|
||||
'json',
|
||||
'receive',
|
||||
'--timeout',
|
||||
'1',
|
||||
]);
|
||||
await this.processReceiveOutput(output);
|
||||
} catch (error) {
|
||||
if (this._status === 'connected') {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Signal receive failed: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processReceiveOutput(output: string): Promise<void> {
|
||||
if (!this.messageHandler) {
|
||||
return;
|
||||
}
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloads: unknown[] = [];
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
payloads.push(...parsed);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to line-based parsing.
|
||||
}
|
||||
}
|
||||
|
||||
if (payloads.length === 0) {
|
||||
for (const line of trimmed.split('\n')) {
|
||||
const text = line.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
payloads.push(JSON.parse(text));
|
||||
} catch {
|
||||
// Ignore non-JSON lines from signal-cli output.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const payload of payloads) {
|
||||
const inbound = await this.toInboundMessage(payload);
|
||||
if (inbound) {
|
||||
this.messageHandler(inbound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async toInboundMessage(payload: unknown): Promise<InboundMessage | null> {
|
||||
const data = payload as SignalEnvelope & Record<string, unknown>;
|
||||
const envelope = (data.envelope ?? data) as Record<string, unknown>;
|
||||
const dataMessage = (envelope.dataMessage ?? data.dataMessage) as Record<string, unknown> | undefined;
|
||||
|
||||
const rawText = String(dataMessage?.message ?? dataMessage?.body ?? '').trim();
|
||||
if (!rawText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = this.normalizeNumber(String(envelope.source ?? ''));
|
||||
const sourceName = typeof envelope.sourceName === 'string' ? envelope.sourceName : undefined;
|
||||
const groupIdRaw = dataMessage?.groupInfo && typeof dataMessage.groupInfo === 'object'
|
||||
? String((dataMessage.groupInfo as { groupId?: unknown }).groupId ?? '')
|
||||
: String(dataMessage?.groupId ?? '');
|
||||
const groupId = groupIdRaw.trim();
|
||||
const isGroup = groupId.length > 0;
|
||||
|
||||
let text = rawText;
|
||||
let senderId = source;
|
||||
|
||||
if (isGroup) {
|
||||
if (!this.config.allowedGroupIds || this.config.allowedGroupIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!this.config.allowedGroupIds.includes(groupId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mentionName = (this.config.mentionName ?? 'flynn').trim();
|
||||
const mentionPattern = mentionName.length > 0
|
||||
? new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i')
|
||||
: null;
|
||||
const mentionsBot = mentionPattern ? mentionPattern.test(text) : false;
|
||||
if (shouldIgnoreForMissingMention({
|
||||
requireMention: this.config.requireMention,
|
||||
defaultRequireMention: true,
|
||||
mentionsBot,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
if (mentionPattern) {
|
||||
text = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim();
|
||||
}
|
||||
senderId = `group:${groupId}`;
|
||||
} else {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const trusted = isAllowedByAllowlist(source, this.config.allowedNumbers);
|
||||
const allowed = await allowTrustedOrPairedSender({
|
||||
pairingManager: this.config.pairingManager,
|
||||
channel: 'signal',
|
||||
senderId: source,
|
||||
text,
|
||||
isTrusted: trusted,
|
||||
});
|
||||
if (!allowed) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedText = normalizeResetCommandText(text);
|
||||
const timestamp = typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now();
|
||||
const id = `${senderId}:${timestamp}`;
|
||||
if (normalizedText === '!reset') {
|
||||
return buildResetInboundMessage({
|
||||
id,
|
||||
channel: 'signal',
|
||||
senderId,
|
||||
senderName: sourceName,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
channel: 'signal',
|
||||
senderId,
|
||||
senderName: sourceName,
|
||||
text: normalizedText,
|
||||
timestamp,
|
||||
metadata: {
|
||||
source,
|
||||
groupId: groupId || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private execSignal(args: string[]): Promise<string> {
|
||||
const command = this.config.signalCliPath ?? 'signal-cli';
|
||||
const timeout = this.config.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { timeout }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private extractGroupId(peerId: string): string | null {
|
||||
if (!peerId.startsWith('group:')) {
|
||||
return null;
|
||||
}
|
||||
const groupId = peerId.slice('group:'.length).trim();
|
||||
return groupId.length > 0 ? groupId : null;
|
||||
}
|
||||
|
||||
private normalizeNumber(value: string): string {
|
||||
return value.trim().replace(/[^\d+]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SignalAdapter, type SignalAdapterConfig } from './adapter.js';
|
||||
@@ -319,6 +319,37 @@ describe('configSchema — matrix', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('configSchema — signal', () => {
|
||||
const minimalConfig = {
|
||||
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
||||
models: { default: { provider: 'anthropic', model: 'claude-3' } },
|
||||
};
|
||||
|
||||
it('accepts signal config and defaults polling fields', () => {
|
||||
const result = configSchema.parse({
|
||||
...minimalConfig,
|
||||
signal: {
|
||||
account: '+15551234567',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.signal).toBeDefined();
|
||||
if (!result.signal) {
|
||||
throw new Error('Expected signal config');
|
||||
}
|
||||
expect(result.signal.account).toBe('+15551234567');
|
||||
expect(result.signal.signal_cli_path).toBe('signal-cli');
|
||||
expect(result.signal.poll_interval_ms).toBe(5000);
|
||||
expect(result.signal.send_timeout_ms).toBe(15000);
|
||||
expect(result.signal.require_mention).toBe(true);
|
||||
});
|
||||
|
||||
it('signal config is optional', () => {
|
||||
const result = configSchema.parse(minimalConfig);
|
||||
expect(result.signal).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configSchema — whatsapp', () => {
|
||||
const minimalConfig = {
|
||||
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
||||
|
||||
@@ -400,6 +400,17 @@ const matrixSchema = z.object({
|
||||
display_name: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
const signalSchema = z.object({
|
||||
account: z.string().min(1, 'Signal account is required'),
|
||||
signal_cli_path: z.string().default('signal-cli'),
|
||||
allowed_numbers: z.array(z.string()).default([]),
|
||||
allowed_group_ids: z.array(z.string()).default([]),
|
||||
require_mention: z.boolean().default(true),
|
||||
mention_name: z.string().default('flynn'),
|
||||
poll_interval_ms: z.number().min(1000).max(60000).default(5000),
|
||||
send_timeout_ms: z.number().min(1000).max(60000).default(15000),
|
||||
}).optional();
|
||||
|
||||
const browserSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
executable_path: z.string().optional(),
|
||||
@@ -566,6 +577,7 @@ export const configSchema = z.object({
|
||||
slack: slackSchema,
|
||||
whatsapp: whatsappSchema,
|
||||
matrix: matrixSchema,
|
||||
signal: signalSchema,
|
||||
server: serverSchema.default({}),
|
||||
models: modelsSchema,
|
||||
backends: backendsSchema.default({}),
|
||||
@@ -610,6 +622,7 @@ export type DiscordConfig = z.infer<typeof discordSchema>;
|
||||
export type SlackConfig = z.infer<typeof slackSchema>;
|
||||
export type WhatsAppConfig = z.infer<typeof whatsappSchema>;
|
||||
export type MatrixConfig = z.infer<typeof matrixSchema>;
|
||||
export type SignalConfig = z.infer<typeof signalSchema>;
|
||||
export type RetryPolicyConfig = z.infer<typeof retrySchema>;
|
||||
export type ContextLevel = z.infer<typeof contextLevelSchema>;
|
||||
export type PromptConfig = z.infer<typeof promptSchema>;
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { HookEngine } from '../hooks/index.js';
|
||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, PairingManager } from '../channels/index.js';
|
||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, PairingManager } from '../channels/index.js';
|
||||
import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
|
||||
import type { GatewayServer } from '../gateway/index.js';
|
||||
|
||||
@@ -85,6 +85,22 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
|
||||
channelRegistry.register(matrixAdapter);
|
||||
}
|
||||
|
||||
// Register Signal adapter (if configured)
|
||||
if (config.signal) {
|
||||
const signalAdapter = new SignalAdapter({
|
||||
account: config.signal.account,
|
||||
signalCliPath: config.signal.signal_cli_path,
|
||||
allowedNumbers: config.signal.allowed_numbers.length > 0 ? config.signal.allowed_numbers : undefined,
|
||||
allowedGroupIds: config.signal.allowed_group_ids.length > 0 ? config.signal.allowed_group_ids : undefined,
|
||||
requireMention: config.signal.require_mention,
|
||||
mentionName: config.signal.mention_name,
|
||||
pollIntervalMs: config.signal.poll_interval_ms,
|
||||
sendTimeoutMs: config.signal.send_timeout_ms,
|
||||
pairingManager,
|
||||
});
|
||||
channelRegistry.register(signalAdapter);
|
||||
}
|
||||
|
||||
// Register WebChat adapter (wraps the gateway)
|
||||
const webChatAdapter = new WebChatAdapter({ gateway });
|
||||
channelRegistry.register(webChatAdapter);
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeBaseConfig(): Config {
|
||||
slack: undefined,
|
||||
whatsapp: undefined,
|
||||
matrix: undefined,
|
||||
signal: undefined,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ describe('discoverServices', () => {
|
||||
expect(services).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'telegram', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'matrix', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'signal', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'cron', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
||||
|
||||
@@ -53,6 +53,7 @@ export function discoverServices(
|
||||
{ key: 'slack', name: 'slack', description: 'Slack app' },
|
||||
{ key: 'whatsapp', name: 'whatsapp', description: 'WhatsApp gateway' },
|
||||
{ key: 'matrix', name: 'matrix', description: 'Matrix bot' },
|
||||
{ key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' },
|
||||
];
|
||||
|
||||
for (const { key, name, description } of channelConfigs) {
|
||||
|
||||
Reference in New Issue
Block a user