diff --git a/src/components/settings/WebhooksSettings.tsx b/src/components/settings/WebhooksSettings.tsx index 0d9f50f..0536021 100644 --- a/src/components/settings/WebhooksSettings.tsx +++ b/src/components/settings/WebhooksSettings.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Globe, MessageSquare, Send, Hash, Plus, Trash2, Power, PowerOff, - CheckCircle2, XCircle, ChevronDown, ChevronUp, Loader2, Zap, CheckSquare, Pencil, Columns3, + CheckCircle2, XCircle, ChevronDown, ChevronUp, Loader2, Zap, CheckSquare, Pencil, Columns3, BookOpen, } from 'lucide-react' import { useWorkspace } from '@/hooks/useWorkspace' import { useWebhooks, useCreateWebhook, useUpdateWebhook, useDeleteWebhook, useWebhookLogs } from '@/hooks/useWebhooks' @@ -14,7 +14,7 @@ import { cn } from '@/lib/utils' // ─── Constants ────────────────────────────────────────────────────────────── -type DestinationType = 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' +type DestinationType = 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' const DESTINATION_META: Record = { http: { label: 'HTTP Webhook', icon: Globe, color: 'text-blue-400', bgColor: 'bg-blue-500/10' }, @@ -24,6 +24,7 @@ const DESTINATION_META: Record -
+
{(Object.keys(DESTINATION_META) as DestinationType[]).map((type) => { const meta = DESTINATION_META[type] const Icon = meta.icon @@ -485,6 +486,44 @@ function DestinationConfigFields({
) + + case 'notion': + return ( +
+
+ + updateField('api_key', e.target.value)} + placeholder="secret_xxx..." + className={inputClass} + /> +

+ Create an internal integration at{' '} + + notion.so/my-integrations + + {' '}and copy the secret. +

+
+
+ + updateField('database_id', e.target.value)} + placeholder="abc123def456..." + className={inputClass} + /> +

+ Open the database as a full page, then copy the ID from the URL: notion.so/DATABASE_ID?v=... +
+ Tip: Add a "Name" (title), "Status" (select), and "Agent" (text) property to your database for best results. +

+
+
+ ) } } diff --git a/src/components/shared/ChannelSelector.tsx b/src/components/shared/ChannelSelector.tsx index 34e6207..5a719e9 100644 --- a/src/components/shared/ChannelSelector.tsx +++ b/src/components/shared/ChannelSelector.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 CrewForm -import { Globe, MessageSquare, Hash, Send, Users, CheckSquare, Radio, Columns3 } from 'lucide-react' +import { Globe, MessageSquare, Hash, Send, Users, CheckSquare, Radio, Columns3, BookOpen } from 'lucide-react' import { useOutputRoutes } from '@/hooks/useChannels' import { cn } from '@/lib/utils' @@ -13,6 +13,7 @@ const DESTINATION_ICONS: Record = { teams: Users, asana: CheckSquare, trello: Columns3, + notion: BookOpen, } const DESTINATION_COLORS: Record = { @@ -23,6 +24,7 @@ const DESTINATION_COLORS: Record = { teams: { text: 'text-blue-400', bg: 'bg-blue-500/10' }, asana: { text: 'text-rose-400', bg: 'bg-rose-500/10' }, trello: { text: 'text-teal-400', bg: 'bg-teal-500/10' }, + notion: { text: 'text-gray-300', bg: 'bg-gray-400/10' }, } interface OutputRouteSelectorProps { diff --git a/src/db/webhooks.ts b/src/db/webhooks.ts index 986c574..ed97a96 100644 --- a/src/db/webhooks.ts +++ b/src/db/webhooks.ts @@ -11,7 +11,7 @@ export interface OutputRoute { id: string workspace_id: string name: string - destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' + destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' config: Record events: string[] is_active: boolean @@ -34,7 +34,7 @@ export interface WebhookLog { export interface CreateRouteInput { workspace_id: string name: string - destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' + destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' config: Record events: string[] is_active?: boolean diff --git a/task-runner/src/webhookDispatcher.ts b/task-runner/src/webhookDispatcher.ts index 20b3fdf..12b3571 100644 --- a/task-runner/src/webhookDispatcher.ts +++ b/task-runner/src/webhookDispatcher.ts @@ -10,7 +10,7 @@ interface OutputRoute { id: string; workspace_id: string; name: string; - destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello'; + destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion'; config: Record; events: string[]; is_active: boolean; @@ -378,6 +378,8 @@ async function deliver( return deliverAsana(route, payload); case 'trello': return deliverTrello(route, payload); + case 'notion': + return deliverNotion(route, payload); default: throw new Error(`Unknown destination type: ${route.destination_type}`); } @@ -1022,6 +1024,182 @@ async function deliverTrello( return { ok: resp.ok, statusCode: resp.status }; } +// ── Notion ────────────────────────────────────────────────────────────────── + +/** + * Deliver a webhook payload to Notion by creating a page in a database. + * + * Config fields: + * - api_key: Notion Internal Integration Token (secret_xxx) + * - database_id: Target database ID (32 hex chars, optionally with dashes) + * + * Creates a page with: + * - Title property: task title with status emoji + * - Status property (select): completed / failed / started + * - Agent property (rich_text): agent name + * - Page body: full result as paragraph blocks + */ +async function deliverNotion( + route: OutputRoute, + payload: WebhookPayload, +): Promise<{ ok: boolean; statusCode: number }> { + const apiKey = route.config.api_key as string; + const databaseId = route.config.database_id as string; + + if (!apiKey || !databaseId) { + throw new Error('Notion integration requires api_key and database_id'); + } + + const emoji = payload.status === 'completed' ? '✅' : '❌'; + const output = payload.result_full || payload.error || 'No output'; + + // Build page content blocks from the result (Notion max block size is 2000 chars) + const contentBlocks = buildNotionBlocks(output); + + // Properties — we use common property names that most Notion databases will have. + // Unknown properties are silently ignored by the API. + const properties: Record = { + Name: { + title: [ + { + text: { + content: `${emoji} ${payload.task_title}`.substring(0, 2000), + }, + }, + ], + }, + }; + + // Optional select property for status + properties.Status = { + select: { name: payload.status === 'completed' ? 'Done' : payload.status === 'failed' ? 'Failed' : 'In Progress' }, + }; + + // Optional rich_text property for agent + properties.Agent = { + rich_text: [ + { + text: { + content: payload.agent_name.substring(0, 2000), + }, + }, + ], + }; + + // Optional rich_text property for event type + properties.Event = { + rich_text: [ + { + text: { + content: payload.event, + }, + }, + ], + }; + + const body = { + parent: { + type: 'database_id', + database_id: databaseId, + }, + properties, + children: contentBlocks, + }; + + const resp = await fetch('https://api.notion.com/v1/pages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + + if (!resp.ok) { + const errBody = await resp.text().catch(() => ''); + console.error(`[Notion] API error ${resp.status}: ${errBody.substring(0, 500)}`); + } + + return { ok: resp.ok, statusCode: resp.status }; +} + +/** + * Convert a text result into Notion block children. + * Splits by paragraphs, respects the 2000-char limit per text block, + * and preserves markdown headings as Notion heading blocks. + */ +function buildNotionBlocks(text: string): Record[] { + const blocks: Record[] = []; + const lines = text.split('\n'); + let currentParagraph = ''; + + function flushParagraph() { + if (!currentParagraph.trim()) return; + const content = currentParagraph.trim(); + for (let i = 0; i < content.length; i += 2000) { + blocks.push({ + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { content: content.substring(i, i + 2000) }, + }, + ], + }, + }); + } + currentParagraph = ''; + } + + for (const line of lines) { + const h1Match = /^#\s+(.+)$/.exec(line); + const h2Match = /^##\s+(.+)$/.exec(line); + const h3Match = /^###\s+(.+)$/.exec(line); + + if (h1Match) { + flushParagraph(); + blocks.push({ + object: 'block', + type: 'heading_1', + heading_1: { + rich_text: [{ type: 'text', text: { content: h1Match[1].substring(0, 2000) } }], + }, + }); + } else if (h2Match) { + flushParagraph(); + blocks.push({ + object: 'block', + type: 'heading_2', + heading_2: { + rich_text: [{ type: 'text', text: { content: h2Match[1].substring(0, 2000) } }], + }, + }); + } else if (h3Match) { + flushParagraph(); + blocks.push({ + object: 'block', + type: 'heading_3', + heading_3: { + rich_text: [{ type: 'text', text: { content: h3Match[1].substring(0, 2000) } }], + }, + }); + } else if (line.trim() === '') { + flushParagraph(); + } else { + currentParagraph += (currentParagraph ? '\n' : '') + line; + } + } + + flushParagraph(); + + // Notion API limits children to 100 blocks per request + return blocks.slice(0, 100); +} + // ─── Logging ──────────────────────────────────────────────────────────────── async function logDelivery(