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:
@@ -0,0 +1,35 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.worktrees
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Documentation (keep README)
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
!SOUL.md
|
||||
|
||||
# Test files
|
||||
src/**/*.test.ts
|
||||
vitest.config.*
|
||||
|
||||
# Lint config
|
||||
eslint.config.*
|
||||
|
||||
# Editor / IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Claude config
|
||||
.claude
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# ── Builder stage ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Install native build tools (needed for better-sqlite3)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency manifests first (layer caching)
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Install dependencies (frozen lockfile for reproducibility)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source and config
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ src/
|
||||
COPY config/ config/
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
|
||||
# Label
|
||||
LABEL org.opencontainers.image.title="Flynn" \
|
||||
org.opencontainers.image.description="Self-hosted personal AI agent" \
|
||||
org.opencontainers.image.source="https://github.com/will666/flynn"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy node_modules from builder (includes compiled native deps like better-sqlite3)
|
||||
COPY --from=builder /app/node_modules/ node_modules/
|
||||
|
||||
# Copy compiled output
|
||||
COPY --from=builder /app/dist/ dist/
|
||||
|
||||
# Copy gateway UI static files into dist/gateway/ui so import.meta.dirname
|
||||
# resolution from dist/daemon/index.js (../gateway/ui) resolves correctly
|
||||
COPY --from=builder /app/src/gateway/ui/ dist/gateway/ui/
|
||||
|
||||
# Copy default config
|
||||
COPY --from=builder /app/config/ config/
|
||||
|
||||
# Copy package.json (needed for bin resolution / metadata)
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Copy SOUL.md if it exists (prompt template loaded at runtime)
|
||||
COPY --from=builder /app/SOUL.md ./
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/memory /data/sessions /config
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production \
|
||||
FLYNN_CONFIG=/config/config.yaml \
|
||||
FLYNN_DATA_DIR=/data
|
||||
|
||||
# Gateway port
|
||||
EXPOSE 18800
|
||||
|
||||
# Health check — verify the gateway is responding
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:18800/ || exit 1
|
||||
|
||||
ENTRYPOINT ["node", "dist/cli/index.js"]
|
||||
CMD ["start"]
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
flynn:
|
||||
build: .
|
||||
container_name: flynn
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18800:18800"
|
||||
volumes:
|
||||
# Persistent data (sessions DB, memory store)
|
||||
- flynn-data:/data
|
||||
# Mount your config file
|
||||
- ./config/default.yaml:/config/config.yaml:ro
|
||||
environment:
|
||||
# Required: at least one model provider API key
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
# Optional: additional provider keys
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
# Optional: Telegram integration
|
||||
- FLYNN_TELEGRAM_TOKEN=${FLYNN_TELEGRAM_TOKEN:-}
|
||||
# Optional: Discord integration
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
# Optional: Gateway auth token
|
||||
- FLYNN_SERVER_TOKEN=${FLYNN_SERVER_TOKEN:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:18800/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
flynn-data:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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