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 { 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');
|
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 {
|
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 }. */
|
/** 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 { HookEngine } = await import('../hooks/index.js');
|
||||||
const { createModelRouter } = await import('../daemon/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 });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
|
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
|
||||||
|
|||||||
@@ -108,8 +108,20 @@ const cronJobSchema = z.object({
|
|||||||
timezone: z.string().optional(),
|
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({
|
const automationSchema = z.object({
|
||||||
cron: z.array(cronJobSchema).default([]),
|
cron: z.array(cronJobSchema).default([]),
|
||||||
|
webhooks: z.array(webhookSchema).default([]),
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|
||||||
const agentsSchema = z.object({
|
const agentsSchema = z.object({
|
||||||
@@ -299,6 +311,7 @@ export type Config = z.infer<typeof configSchema>;
|
|||||||
export type TelegramConfig = z.infer<typeof telegramSchema>;
|
export type TelegramConfig = z.infer<typeof telegramSchema>;
|
||||||
export type ModelConfig = z.infer<typeof modelConfigSchema>;
|
export type ModelConfig = z.infer<typeof modelConfigSchema>;
|
||||||
export type CronJobConfig = z.infer<typeof cronJobSchema>;
|
export type CronJobConfig = z.infer<typeof cronJobSchema>;
|
||||||
|
export type WebhookConfig = z.infer<typeof webhookSchema>;
|
||||||
export type AgentsConfig = z.infer<typeof agentsSchema>;
|
export type AgentsConfig = z.infer<typeof agentsSchema>;
|
||||||
export type CompactionConfig = z.infer<typeof compactionSchema>;
|
export type CompactionConfig = z.infer<typeof compactionSchema>;
|
||||||
export type MemoryConfig = z.infer<typeof memorySchema>;
|
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 { createMemoryTools } from '../tools/builtin/index.js';
|
||||||
import { GatewayServer } from '../gateway/index.js';
|
import { GatewayServer } from '../gateway/index.js';
|
||||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter } from '../channels/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 type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||||
import { McpManager } from '../mcp/index.js';
|
import { McpManager } from '../mcp/index.js';
|
||||||
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/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> {
|
export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||||
const lifecycle = new Lifecycle();
|
const lifecycle = new Lifecycle();
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists (FLYNN_DATA_DIR overrides default for Docker/custom deployments)
|
||||||
const dataDir = resolve(homedir(), '.local/share/flynn');
|
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
// Initialize memory store
|
// 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)`);
|
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 ─────────────────────────────
|
// ── Register Tier 1 agent tools ─────────────────────────────
|
||||||
|
|
||||||
// Session management tools (list, history, create, delete)
|
// 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 { Config } from '../config/index.js';
|
||||||
import type { ToolRegistry } from '../tools/registry.js';
|
import type { ToolRegistry } from '../tools/registry.js';
|
||||||
import type { ToolExecutor } from '../tools/executor.js';
|
import type { ToolExecutor } from '../tools/executor.js';
|
||||||
|
import type { WebhookHandler } from '../automation/webhooks.js';
|
||||||
|
|
||||||
export interface GatewayServerConfig {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -42,6 +43,8 @@ export interface GatewayServerConfig {
|
|||||||
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
||||||
restart?: () => Promise<void>;
|
restart?: () => Promise<void>;
|
||||||
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
|
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
|
||||||
|
/** Optional webhook handler for inbound webhook HTTP routes. */
|
||||||
|
webhookHandler?: WebhookHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
export class GatewayServer {
|
||||||
@@ -207,9 +210,19 @@ export class GatewayServer {
|
|||||||
/**
|
/**
|
||||||
* Handle incoming HTTP requests.
|
* Handle incoming HTTP requests.
|
||||||
* Optionally applies auth (when authHttp is enabled and a token is configured).
|
* 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> {
|
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
|
// Apply auth to HTTP requests when configured
|
||||||
const authConfig = this.config.auth ?? {};
|
const authConfig = this.config.auth ?? {};
|
||||||
if (this.config.authHttp !== false && authConfig.token) {
|
if (this.config.authHttp !== false && authConfig.token) {
|
||||||
@@ -281,4 +294,9 @@ export class GatewayServer {
|
|||||||
getMethods(): string[] {
|
getMethods(): string[] {
|
||||||
return this.router.listMethods();
|
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