feat: add Docker support and inbound webhooks (Tier 2)
- Dockerfile: multi-stage build (node:22-alpine), better-sqlite3 native deps handled
- .dockerignore + docker-compose.yml for deployment
- FLYNN_DATA_DIR env var support in daemon, CLI, and TUI
- WebhookHandler: ChannelAdapter for HTTP POST /webhooks/:name
- Per-webhook HMAC auth, template rendering ({{body}}, {{json.field}})
- Config schema: automation.webhooks array with name/secret/message/output
- Gateway routes webhook requests before static files (bypasses gateway auth)
- 23 new tests for webhook functionality, 874 total tests passing
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export { CronScheduler } from './cron.js';
|
||||
export { WebhookHandler } from './webhooks.js';
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { WebhookHandler, _verifyHmac, _renderTemplate } from './webhooks.js';
|
||||
import type { WebhookConfig } from '../config/schema.js';
|
||||
import type { InboundMessage } from '../channels/types.js';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { createHmac } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
function makeWebhook(overrides?: Partial<WebhookConfig>): WebhookConfig {
|
||||
return {
|
||||
name: 'test-hook',
|
||||
message: '{{body}}',
|
||||
output: { channel: 'telegram', peer: '123' },
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a mock IncomingMessage that emits the given body. */
|
||||
function mockRequest(body: string, headers: Record<string, string> = {}): IncomingMessage {
|
||||
const emitter = new EventEmitter();
|
||||
(emitter as any).headers = headers;
|
||||
// Simulate data arriving next tick
|
||||
process.nextTick(() => {
|
||||
emitter.emit('data', Buffer.from(body));
|
||||
emitter.emit('end');
|
||||
});
|
||||
return emitter as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
/** Create a mock ServerResponse that captures writeHead and end calls. */
|
||||
function mockResponse(): ServerResponse & { statusCode_: number; body_: string; headers_: Record<string, string> } {
|
||||
const res: any = {
|
||||
statusCode_: 0,
|
||||
body_: '',
|
||||
headers_: {},
|
||||
writeHead(code: number, headers?: Record<string, string>) {
|
||||
res.statusCode_ = code;
|
||||
if (headers) res.headers_ = headers;
|
||||
return res;
|
||||
},
|
||||
end(body?: string) {
|
||||
res.body_ = body ?? '';
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('WebhookHandler', () => {
|
||||
let handler: WebhookHandler;
|
||||
let mockChannelRegistry: { get: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockChannelRegistry = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (handler) {
|
||||
await handler.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('implements ChannelAdapter interface', () => {
|
||||
handler = new WebhookHandler([], mockChannelRegistry as any);
|
||||
expect(handler.name).toBe('webhook');
|
||||
expect(handler.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('status changes to connected after connect()', async () => {
|
||||
handler = new WebhookHandler([], mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
expect(handler.status).toBe('connected');
|
||||
});
|
||||
|
||||
it('status changes to disconnected after disconnect()', async () => {
|
||||
handler = new WebhookHandler([], mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
await handler.disconnect();
|
||||
expect(handler.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('lists registered webhook names', () => {
|
||||
const webhooks = [
|
||||
makeWebhook({ name: 'hook-a' }),
|
||||
makeWebhook({ name: 'hook-b', enabled: false }),
|
||||
];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
|
||||
const names = handler.getWebhookNames();
|
||||
expect(names).toEqual(['hook-a', 'hook-b']);
|
||||
});
|
||||
|
||||
it('handleRequest produces correct InboundMessage', async () => {
|
||||
const webhooks = [makeWebhook()];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
|
||||
const messages: InboundMessage[] = [];
|
||||
handler.onMessage((msg: InboundMessage) => messages.push(msg));
|
||||
await handler.connect();
|
||||
|
||||
const req = mockRequest('hello world');
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await handler.handleRequest('test-hook', req, res);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(res.statusCode_).toBe(202);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].channel).toBe('webhook');
|
||||
expect(messages[0].senderId).toBe('test-hook');
|
||||
expect(messages[0].text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns false for unknown webhook', async () => {
|
||||
handler = new WebhookHandler([], mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
|
||||
const req = mockRequest('test');
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await handler.handleRequest('nonexistent', req, res);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(res.statusCode_).toBe(404);
|
||||
});
|
||||
|
||||
it('returns false for disabled webhook', async () => {
|
||||
const webhooks = [makeWebhook({ enabled: false })];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
|
||||
const req = mockRequest('test');
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await handler.handleRequest('test-hook', req, res);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(res.statusCode_).toBe(404);
|
||||
});
|
||||
|
||||
it('verifies valid HMAC signature', async () => {
|
||||
const secret = 'my-secret-key';
|
||||
const webhooks = [makeWebhook({ secret })];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
|
||||
const messages: InboundMessage[] = [];
|
||||
handler.onMessage((msg: InboundMessage) => messages.push(msg));
|
||||
await handler.connect();
|
||||
|
||||
const body = '{"event":"push"}';
|
||||
const signature = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
||||
|
||||
const req = mockRequest(body, { 'x-webhook-signature': signature });
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await handler.handleRequest('test-hook', req, res);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(res.statusCode_).toBe(202);
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects invalid HMAC signature', async () => {
|
||||
const secret = 'my-secret-key';
|
||||
const webhooks = [makeWebhook({ secret })];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
|
||||
const messages: InboundMessage[] = [];
|
||||
handler.onMessage((msg: InboundMessage) => messages.push(msg));
|
||||
await handler.connect();
|
||||
|
||||
const req = mockRequest('{"event":"push"}', { 'x-webhook-signature': 'sha256=invalid' });
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await handler.handleRequest('test-hook', req, res);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(res.statusCode_).toBe(401);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects missing HMAC signature when secret is configured', async () => {
|
||||
const secret = 'my-secret-key';
|
||||
const webhooks = [makeWebhook({ secret })];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
|
||||
const messages: InboundMessage[] = [];
|
||||
handler.onMessage((msg: InboundMessage) => messages.push(msg));
|
||||
await handler.connect();
|
||||
|
||||
const req = mockRequest('{"event":"push"}');
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await handler.handleRequest('test-hook', req, res);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(res.statusCode_).toBe(401);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('forwards response to output channel on send()', async () => {
|
||||
const mockOutputAdapter = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockChannelRegistry.get.mockReturnValue(mockOutputAdapter);
|
||||
|
||||
const webhooks = [makeWebhook()];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
|
||||
await handler.send('test-hook', { text: 'Agent response' });
|
||||
|
||||
expect(mockChannelRegistry.get).toHaveBeenCalledWith('telegram');
|
||||
expect(mockOutputAdapter.send).toHaveBeenCalledWith('123', { text: 'Agent response' });
|
||||
});
|
||||
|
||||
it('logs warning when output channel not found', async () => {
|
||||
mockChannelRegistry.get.mockReturnValue(undefined);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const webhooks = [makeWebhook()];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
|
||||
await handler.send('test-hook', { text: 'Agent response' });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Output channel'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs warning when webhook name not found in send()', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const webhooks = [makeWebhook()];
|
||||
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
|
||||
await handler.connect();
|
||||
|
||||
await handler.send('nonexistent-hook', { text: 'response' });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No webhook'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('replaces {{body}} with raw body', () => {
|
||||
const result = _renderTemplate('Received: {{body}}', 'hello');
|
||||
expect(result).toBe('Received: hello');
|
||||
});
|
||||
|
||||
it('replaces {{json.field}} with JSON field value', () => {
|
||||
const result = _renderTemplate('Event: {{json.action}}', '{"action":"push","repo":"test"}');
|
||||
expect(result).toBe('Event: push');
|
||||
});
|
||||
|
||||
it('replaces multiple {{json.field}} placeholders', () => {
|
||||
const result = _renderTemplate(
|
||||
'{{json.action}} on {{json.repo}}',
|
||||
'{"action":"push","repo":"my-repo"}',
|
||||
);
|
||||
expect(result).toBe('push on my-repo');
|
||||
});
|
||||
|
||||
it('returns empty string for missing JSON fields', () => {
|
||||
const result = _renderTemplate('Value: {{json.missing}}', '{"action":"push"}');
|
||||
expect(result).toBe('Value: ');
|
||||
});
|
||||
|
||||
it('returns empty string for invalid JSON body with json placeholder', () => {
|
||||
const result = _renderTemplate('Value: {{json.field}}', 'not-json');
|
||||
expect(result).toBe('Value: ');
|
||||
});
|
||||
|
||||
it('stringifies non-string JSON values', () => {
|
||||
const result = _renderTemplate('Count: {{json.count}}', '{"count":42}');
|
||||
expect(result).toBe('Count: 42');
|
||||
});
|
||||
|
||||
it('handles template with both {{body}} and {{json.field}}', () => {
|
||||
const body = '{"action":"deploy"}';
|
||||
const result = _renderTemplate('Action: {{json.action}}, Raw: {{body}}', body);
|
||||
expect(result).toBe('Action: deploy, Raw: {"action":"deploy"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyHmac', () => {
|
||||
it('returns true for valid signature with sha256= prefix', () => {
|
||||
const secret = 'test-secret';
|
||||
const body = 'test-body';
|
||||
const sig = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
||||
expect(_verifyHmac(body, secret, sig)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid signature without prefix', () => {
|
||||
const secret = 'test-secret';
|
||||
const body = 'test-body';
|
||||
const sig = createHmac('sha256', secret).update(body).digest('hex');
|
||||
expect(_verifyHmac(body, secret, sig)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid signature', () => {
|
||||
expect(_verifyHmac('body', 'secret', 'sha256=deadbeef')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { WebhookConfig } from '../config/schema.js';
|
||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||
|
||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||
interface ChannelLookup {
|
||||
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
|
||||
}
|
||||
|
||||
/** Read the full request body as a string. */
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
|
||||
function verifyHmac(body: string, secret: string, signature: string): boolean {
|
||||
const expected = createHmac('sha256', secret).update(body).digest('hex');
|
||||
const sig = signature.startsWith('sha256=') ? signature.slice(7) : signature;
|
||||
|
||||
if (expected.length !== sig.length) return false;
|
||||
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a message template with {{body}} and {{json.field}} placeholders.
|
||||
* - {{body}} is replaced with the raw request body.
|
||||
* - {{json.field}} accesses a top-level field from the parsed JSON body.
|
||||
*/
|
||||
function renderTemplate(template: string, body: string): string {
|
||||
let result = template.replace(/\{\{body\}\}/g, body);
|
||||
|
||||
// Replace {{json.field}} placeholders
|
||||
let parsed: Record<string, unknown> | undefined;
|
||||
result = result.replace(/\{\{json\.([^}]+)\}\}/g, (_match, field: string) => {
|
||||
if (!parsed) {
|
||||
try {
|
||||
parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
const value = parsed[field];
|
||||
if (value === undefined || value === null) return '';
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class WebhookHandler implements ChannelAdapter {
|
||||
readonly name = 'webhook';
|
||||
private _status: ChannelStatus = 'disconnected';
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
private webhooks: Map<string, WebhookConfig> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly webhookConfigs: WebhookConfig[],
|
||||
private readonly channelLookup: ChannelLookup,
|
||||
) {
|
||||
for (const webhook of webhookConfigs) {
|
||||
this.webhooks.set(webhook.name, webhook);
|
||||
}
|
||||
}
|
||||
|
||||
get status(): ChannelStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this._status = 'connected';
|
||||
|
||||
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
|
||||
if (enabledCount > 0) {
|
||||
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this._status = 'disconnected';
|
||||
}
|
||||
|
||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||
// peerId is the webhook name — look up its output config
|
||||
const webhook = this.webhooks.get(peerId);
|
||||
if (!webhook) {
|
||||
console.warn(`No webhook found for '${peerId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputAdapter = this.channelLookup.get(webhook.output.channel);
|
||||
if (!outputAdapter) {
|
||||
console.warn(`Output channel '${webhook.output.channel}' not found for webhook '${peerId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
await outputAdapter.send(webhook.output.peer, message);
|
||||
}
|
||||
|
||||
onMessage(handler: (msg: InboundMessage) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming HTTP webhook request.
|
||||
* Returns true if the webhook was found and processed, false otherwise.
|
||||
*/
|
||||
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||
const webhook = this.webhooks.get(webhookName);
|
||||
if (!webhook) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unknown webhook' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!webhook.enabled) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Webhook disabled' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
|
||||
// Verify HMAC if secret is configured
|
||||
if (webhook.secret) {
|
||||
const signature = req.headers['x-webhook-signature'] as string | undefined;
|
||||
if (!signature || !verifyHmac(body, webhook.secret, signature)) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Render message template
|
||||
const text = renderTemplate(webhook.message, body);
|
||||
|
||||
const msg: InboundMessage = {
|
||||
id: `webhook-${webhookName}-${Date.now()}`,
|
||||
channel: 'webhook',
|
||||
senderId: webhookName,
|
||||
senderName: `webhook:${webhookName}`,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
metadata: { webhookName, body },
|
||||
};
|
||||
|
||||
this.messageHandler?.(msg);
|
||||
|
||||
res.writeHead(202, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ accepted: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get list of all webhook names (enabled and disabled). */
|
||||
getWebhookNames(): string[] {
|
||||
return Array.from(this.webhooks.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// Export helpers for testing
|
||||
export { readBody as _readBody, verifyHmac as _verifyHmac, renderTemplate as _renderTemplate };
|
||||
+2
-2
@@ -8,9 +8,9 @@ export function getConfigPath(): string {
|
||||
return process.env.FLYNN_CONFIG ?? resolve(homedir(), '.config/flynn/config.yaml');
|
||||
}
|
||||
|
||||
/** Get the data directory path. */
|
||||
/** Get the data directory path (FLYNN_DATA_DIR overrides default for Docker/custom deployments). */
|
||||
export function getDataDir(): string {
|
||||
return resolve(homedir(), '.local/share/flynn');
|
||||
return process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
||||
}
|
||||
|
||||
/** Load config without throwing. Returns { config } or { error }. */
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export function registerTuiCommand(program: Command): void {
|
||||
const { HookEngine } = await import('../hooks/index.js');
|
||||
const { createModelRouter } = await import('../daemon/index.js');
|
||||
|
||||
const dataDir = resolve(homedir(), '.local/share/flynn');
|
||||
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
|
||||
|
||||
@@ -108,8 +108,20 @@ const cronJobSchema = z.object({
|
||||
timezone: z.string().optional(),
|
||||
});
|
||||
|
||||
const webhookSchema = z.object({
|
||||
name: z.string().min(1, 'Webhook name is required'),
|
||||
secret: z.string().optional(),
|
||||
message: z.string().default('{{body}}'),
|
||||
output: z.object({
|
||||
channel: z.string().min(1),
|
||||
peer: z.string().min(1),
|
||||
}),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const automationSchema = z.object({
|
||||
cron: z.array(cronJobSchema).default([]),
|
||||
webhooks: z.array(webhookSchema).default([]),
|
||||
}).default({});
|
||||
|
||||
const agentsSchema = z.object({
|
||||
@@ -299,6 +311,7 @@ export type Config = z.infer<typeof configSchema>;
|
||||
export type TelegramConfig = z.infer<typeof telegramSchema>;
|
||||
export type ModelConfig = z.infer<typeof modelConfigSchema>;
|
||||
export type CronJobConfig = z.infer<typeof cronJobSchema>;
|
||||
export type WebhookConfig = z.infer<typeof webhookSchema>;
|
||||
export type AgentsConfig = z.infer<typeof agentsSchema>;
|
||||
export type CompactionConfig = z.infer<typeof compactionSchema>;
|
||||
export type MemoryConfig = z.infer<typeof memorySchema>;
|
||||
|
||||
+12
-3
@@ -15,7 +15,7 @@ import { MemoryStore } from '../memory/index.js';
|
||||
import { createMemoryTools } from '../tools/builtin/index.js';
|
||||
import { GatewayServer } from '../gateway/index.js';
|
||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter } from '../channels/index.js';
|
||||
import { CronScheduler } from '../automation/index.js';
|
||||
import { CronScheduler, WebhookHandler } from '../automation/index.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||
import { McpManager } from '../mcp/index.js';
|
||||
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
|
||||
@@ -521,8 +521,8 @@ function createMessageRouter(deps: {
|
||||
export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
const lifecycle = new Lifecycle();
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = resolve(homedir(), '.local/share/flynn');
|
||||
// Ensure data directory exists (FLYNN_DATA_DIR overrides default for Docker/custom deployments)
|
||||
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
// Initialize memory store
|
||||
@@ -816,6 +816,15 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
console.log(`Registered ${config.automation.cron.length} cron job(s)`);
|
||||
}
|
||||
|
||||
// Register webhook handler adapter (if any webhooks configured)
|
||||
let webhookHandler: WebhookHandler | undefined;
|
||||
if (config.automation.webhooks.length > 0) {
|
||||
webhookHandler = new WebhookHandler(config.automation.webhooks, channelRegistry);
|
||||
channelRegistry.register(webhookHandler);
|
||||
gateway.setWebhookHandler(webhookHandler);
|
||||
console.log(`Registered ${config.automation.webhooks.length} webhook(s)`);
|
||||
}
|
||||
|
||||
// ── Register Tier 1 agent tools ─────────────────────────────
|
||||
|
||||
// Session management tools (list, history, create, delete)
|
||||
|
||||
+19
-1
@@ -24,6 +24,7 @@ import type { SessionManager } from '../session/manager.js';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { ToolRegistry } from '../tools/registry.js';
|
||||
import type { ToolExecutor } from '../tools/executor.js';
|
||||
import type { WebhookHandler } from '../automation/webhooks.js';
|
||||
|
||||
export interface GatewayServerConfig {
|
||||
port: number;
|
||||
@@ -42,6 +43,8 @@ export interface GatewayServerConfig {
|
||||
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
||||
restart?: () => Promise<void>;
|
||||
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
|
||||
/** Optional webhook handler for inbound webhook HTTP routes. */
|
||||
webhookHandler?: WebhookHandler;
|
||||
}
|
||||
|
||||
export class GatewayServer {
|
||||
@@ -207,9 +210,19 @@ export class GatewayServer {
|
||||
/**
|
||||
* Handle incoming HTTP requests.
|
||||
* Optionally applies auth (when authHttp is enabled and a token is configured).
|
||||
* Delegates to serveStatic for UI files; returns 404 if no UI dir or file not found.
|
||||
* Routes webhook requests before auth; delegates to serveStatic for UI files.
|
||||
*/
|
||||
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
// Webhook routes bypass gateway auth (they have their own HMAC auth)
|
||||
if (this.config.webhookHandler && req.method === 'POST' && req.url) {
|
||||
const match = req.url.match(/^\/webhooks\/([^/?]+)/);
|
||||
if (match) {
|
||||
const webhookName = decodeURIComponent(match[1]);
|
||||
await this.config.webhookHandler.handleRequest(webhookName, req, res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply auth to HTTP requests when configured
|
||||
const authConfig = this.config.auth ?? {};
|
||||
if (this.config.authHttp !== false && authConfig.token) {
|
||||
@@ -281,4 +294,9 @@ export class GatewayServer {
|
||||
getMethods(): string[] {
|
||||
return this.router.listMethods();
|
||||
}
|
||||
|
||||
/** Set the webhook handler for inbound webhook HTTP routes (late binding). */
|
||||
setWebhookHandler(handler: WebhookHandler): void {
|
||||
this.config.webhookHandler = handler;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user