diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc544ee02ef..77153168a0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,16 @@ --- +## [3.8.4] — 2026-05-25 + +### ✨ New Features + +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 3857b3064a74..7f5eadf519e3 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 1cfb50551938..7ccad1a04f62 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 1cfb50551938..7ccad1a04f62 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 0d135eedb16d..56e97d677b81 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index c404fafffe13..ff3afb623607 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index b335b25bd5e0..becf8320bebd 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 685cb2567d0a..1060a4b9e280 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 61258fd22a1d..7d89ed8e7026 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index c72c23d1626e..15161e23d7a0 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index da038e62e8c0..c12a1819dc83 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 3444f73a51b1..225db1bca568 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 934b46419f67..c18b3f317f43 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 37bbf4119fe4..a95d4f5717ce 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 7be893db5dc2..a1ed48b2b667 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 4f791c1d2048..c35f498b5ea8 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 93a5f92f5c7b..7c61efb9899d 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 0b8576e899c1..4b11d0199b60 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 875603cb90a1..23596ad3e5f7 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index e4adf9265215..ab372bff7570 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 6a7e4af64e84..29a4dac26b8a 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 200636334fb5..13668777a3e8 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index f854326ecc8d..e061ed8e1b96 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index fe55a2f1561a..af2930648d5a 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index dac0d0c8fe83..5d6faf2fd59c 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index d056d0bd90e3..ede728986fdf 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index e9b6691063ec..3c42e6de0bb6 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 6561012350a0..677d8340a683 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 5173385f0557..c1ba3ce5d285 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index ddcbdb31fa03..aa31bf930ba5 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 0066f04703fb..2c4fb6c7b955 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 09178904744b..2b80a63eee9e 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index b53dd53dd346..08ee640ce8e9 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index bab15f107b1d..8c7cecd37439 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 48e287f86f39..9f7b148a32ef 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 304de6a70a09..08db0d4df924 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index b064d9612c67..49ce0af0eaea 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 615046c9e3b7..ef48775adedc 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 93dea837eef9..df747a4299fe 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 465f44cfaf95..dadb5b84027c 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 31d58dff8840..7a4afd208a24 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 9e18951b806c..59c208fab970 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx b/src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx new file mode 100644 index 000000000000..1753fe958aed --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, ConfirmModal } from "@/shared/components"; +import { AddWebhookWizard } from "./components/AddWebhookWizard"; +import { HowItWorksSidebar } from "./components/HowItWorksSidebar"; +import { WebhooksList } from "./components/WebhooksList"; +import type { WebhookItem } from "./components/WebhookCard"; + +type FeedbackState = { type: "success" | "error"; message: string } | null; + +function getStatus(wh: WebhookItem): "active" | "inactive" | "errored" { + if (!wh.enabled) return "inactive"; + if (wh.failure_count > 0 || (wh.last_status !== null && wh.last_status >= 400)) return "errored"; + return "active"; +} + +export function WebhooksPageClient() { + const t = useTranslations("webhooks"); + + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [testingId, setTestingId] = useState(null); + const [feedback, setFeedback] = useState(null); + const [wizardOpen, setWizardOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/webhooks"); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("loadFailed")); + setWebhooks(Array.isArray(data.webhooks) ? data.webhooks : []); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("loadFailed"), + }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const stats = useMemo( + () => + webhooks.reduce( + (acc, wh) => { + acc.total += 1; + acc[getStatus(wh)] += 1; + return acc; + }, + { total: 0, active: 0, inactive: 0, errored: 0 } + ), + [webhooks] + ); + + const handleTest = async (wh: WebhookItem) => { + setTestingId(wh.id); + setFeedback(null); + try { + const res = await fetch(`/api/webhooks/${wh.id}/test`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.delivered === false) throw new Error(data.error || t("testFailed")); + setFeedback({ type: "success", message: t("testSuccess") }); + await load(); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("testFailed"), + }); + } finally { + setTestingId(null); + } + }; + + const handleToggleEnabled = async (wh: WebhookItem) => { + setFeedback(null); + try { + const res = await fetch(`/api/webhooks/${wh.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !wh.enabled }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + setWebhooks((prev) => + prev.map((item) => (item.id === wh.id ? { ...item, enabled: !wh.enabled } : item)) + ); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("saveFailed"), + }); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + setFeedback(null); + try { + const res = await fetch(`/api/webhooks/${deleteTarget.id}`, { method: "DELETE" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("deleteFailed")); + setWebhooks((prev) => prev.filter((w) => w.id !== deleteTarget.id)); + setDeleteTarget(null); + setFeedback({ type: "success", message: t("deleteSuccess") }); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("deleteFailed"), + }); + } finally { + setDeleting(false); + } + }; + + const tFn = (key: string, opts?: Record) => + opts ? t(key as any, opts as any) : t(key as any); + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+ +
+ + {feedback && ( +
+ {feedback.message} +
+ )} + +
+
+
+ {[ + { label: t("total"), value: stats.total, icon: "webhook", tone: "text-primary" }, + { + label: t("active"), + value: stats.active, + icon: "check_circle", + tone: "text-emerald-500", + }, + { + label: t("inactive"), + value: stats.inactive, + icon: "pause_circle", + tone: "text-text-muted", + }, + { label: t("errored"), value: stats.errored, icon: "error", tone: "text-red-500" }, + ].map((stat) => ( + +
+
+

+ {stat.label} +

+

{stat.value}

+
+ + {stat.icon} + +
+
+ ))} +
+ + +
+
+
+

+ {t("configuredWebhooks")} +

+

{t("configuredWebhooksDesc")}

+
+ +
+
+
+ void handleTest(wh)} + onToggleEnabled={(wh) => void handleToggleEnabled(wh)} + onEdit={() => setWizardOpen(true)} + onDelete={setDeleteTarget} + /> +
+
+
+ + +
+ + setWizardOpen(false)} + onCreated={async () => { + await load(); + }} + t={tFn} + /> + + setDeleteTarget(null)} + onConfirm={handleDelete} + title={t("delete")} + message={t("deleteConfirm")} + confirmText={t("delete")} + cancelText={t("wizard.cancel")} + loading={deleting} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx b/src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx new file mode 100644 index 000000000000..09ff7c9ea0c0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Step1ChooseIntegration } from "../components/steps/Step1ChooseIntegration"; +import { Step3EventsAndTest } from "../components/steps/Step3EventsAndTest"; +import { AddWebhookWizard } from "../components/AddWebhookWizard"; + +// Minimal t() mock that returns the key, letting tests assert on key presence. +function t(key: string): string { + const overrides: Record = { + "wizard.step1Desc": "Select the platform", + "wizard.step1Title": "Choose Integration", + "wizard.step2Title": "Configure", + "wizard.step3Title": "Events & Test", + "wizard.next": "Next", + "wizard.back": "Back", + "wizard.finish": "Add Webhook", + "wizard.cancel": "Cancel", + addWebhook: "Add Webhook", + "kinds.slack": "Slack", + "kinds.slackDesc": "Slack desc", + "kinds.telegram": "Telegram", + "kinds.telegramDesc": "Telegram desc", + "kinds.discord": "Discord", + "kinds.discordDesc": "Discord desc", + "kinds.custom": "Custom", + "kinds.customDesc": "Custom desc", + "kinds.email": "Email", + "kinds.emailDesc": "Email desc", + "kinds.pagerduty": "PagerDuty", + "kinds.pagerdutyDesc": "PagerDuty desc", + "kinds.teams": "Microsoft Teams", + "kinds.teamsDesc": "Teams desc", + "kinds.comingSoon": "Coming soon", + "slack.webhookUrl": "Slack Webhook URL", + "slack.webhookUrlPlaceholder": "https://hooks.slack.com/services/…", + "slack.webhookUrlHint": "Create one in your Slack App.", + "slack.tutorial": "How to get a Slack Webhook URL", + "slack.tutorialStep1": "step1", + "slack.tutorialStep2": "step2", + "slack.tutorialStep3": "step3", + "slack.tutorialStep4": "step4", + "howItWorks.title": "How It Works", + "howItWorks.step1": "step1", + "howItWorks.step2": "step2", + "howItWorks.step3": "step3", + "howItWorks.step4": "step4", + "howItWorks.hmacRecipeTitle": "Verify Signature", + "howItWorks.hmacRecipe": "const sig = req.headers['x-webhook-signature'];", + name: "Name", + namePlaceholder: "Production monitoring", + events: "Events", + allEvents: "All events", + enabled: "Enabled", + enabledDesc: "Disabled webhooks remain saved.", + testWebhook: "Send Test", + testSuccess: "delivery confirmed", + testFailed: "Test failed", + testPayloadSent: "Payload sent", + testResponse: "Endpoint response", + saveFailed: "Failed to save", + }; + return overrides[key] ?? key; +} + +function getButton(text: string): HTMLButtonElement | null { + return ( + (Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.includes(text)) as + | HTMLButtonElement + | undefined) ?? null + ); +} + +const roots: Array<{ unmount: () => void }> = []; + +afterEach(() => { + roots.forEach((r) => { + act(() => r.unmount()); + }); + roots.length = 0; + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +function renderIntoBody(element: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(element); + }); + roots.push({ unmount: () => root.unmount() }); + return container; +} + +// --------------------------------------------------------------------------- +// Scenario 1: Step1 renders 7 cards (4 active + 3 greyed) +// --------------------------------------------------------------------------- +describe("Step1ChooseIntegration", () => { + it("renders 4 active and 3 coming-soon cards", () => { + const onSelect = vi.fn(); + renderIntoBody(); + + const allButtons = Array.from(document.querySelectorAll("button")); + expect(allButtons).toHaveLength(7); + + const disabledButtons = allButtons.filter((b) => b.disabled); + expect(disabledButtons).toHaveLength(3); + + const activeButtons = allButtons.filter((b) => !b.disabled); + expect(activeButtons).toHaveLength(4); + }); + + it("greyed cards show 'Coming soon' badge", () => { + renderIntoBody(); + const badges = document.body.textContent?.match(/Coming soon/g) ?? []; + expect(badges).toHaveLength(3); + }); + + // --------------------------------------------------------------------------- + // Scenario 2: Clicking "Slack" → calls onSelect("slack") + // --------------------------------------------------------------------------- + it("clicking a kind card calls onSelect with that kind", () => { + const onSelect = vi.fn(); + renderIntoBody(); + + const slackButton = Array.from(document.querySelectorAll("button")).find((b) => + b.textContent?.includes("Slack") + ); + expect(slackButton).toBeTruthy(); + act(() => slackButton!.click()); + expect(onSelect).toHaveBeenCalledWith("slack"); + }); + + it("clicking a disabled coming-soon card does NOT call onSelect", () => { + const onSelect = vi.fn(); + renderIntoBody(); + + const emailButton = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Email") && b.disabled + ); + expect(emailButton).toBeTruthy(); + act(() => emailButton!.click()); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 3: Step3 "Send test ping" calls /api/webhooks/[id]/test +// --------------------------------------------------------------------------- +describe("Step3EventsAndTest — test ping", () => { + it("clicking Send Test calls the test endpoint and shows status + payload", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + delivered: true, + status: 200, + latencyMs: 42, + payloadSent: { event: "test.ping" }, + responseBody: '{"ok":true}', + error: null, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderIntoBody( + + ); + + const sendBtn = getButton("Send Test"); + expect(sendBtn).toBeTruthy(); + await act(async () => sendBtn!.click()); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/webhooks/wh-test-123/test", + expect.objectContaining({ method: "POST" }) + ); + + // Status + latency line should appear + expect(document.body.textContent).toContain("200"); + expect(document.body.textContent).toContain("42ms"); + expect(document.body.textContent).toContain("delivery confirmed"); + }); + + it("displays error message on failed test ping", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ delivered: false, error: "Connection refused", status: 0 }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderIntoBody( + + ); + + const sendBtn = getButton("Send Test"); + await act(async () => sendBtn!.click()); + + expect(document.body.textContent).toContain("Connection refused"); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 4: Wizard flow — entering step 3 POSTs with kind: "slack" +// --------------------------------------------------------------------------- +describe("AddWebhookWizard — step 2→3 creates webhook with correct kind", () => { + it("Next on step 2 posts kind:slack and saves createdId", async () => { + const postResponse = { id: "wh-created-789", url: "https://hooks.slack.com/test" }; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ webhook: postResponse }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderIntoBody(); + + // Step 1: default is "slack", click Next + const nextBtn1 = getButton("Next"); + expect(nextBtn1).toBeTruthy(); + await act(async () => nextBtn1!.click()); + + // Now in step 2 (Slack config), fill in the URL using the native setter + // so React's synthetic onChange fires properly on a controlled input. + const input = document.querySelector( + 'input[placeholder="https://hooks.slack.com/services/…"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeSetter?.call(input, "https://hooks.slack.com/services/T00/B00/xxx"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Click Next to go to step 3 (the button becomes enabled after value is set) + const nextBtn2 = getButton("Next"); + expect(nextBtn2?.disabled).toBe(false); + await act(async () => nextBtn2!.click()); + + // Verify the POST was called with kind: "slack" + const calls = mockFetch.mock.calls; + const postCall = calls.find( + ([url, opts]) => url === "/api/webhooks" && opts?.method === "POST" + ); + expect(postCall).toBeTruthy(); + const body = JSON.parse(postCall![1].body as string); + expect(body.kind).toBe("slack"); + }); +}); diff --git a/src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx b/src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx new file mode 100644 index 000000000000..9875c7346ac9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState } from "react"; +import { Modal } from "@/shared/components"; +import { Step1ChooseIntegration } from "./steps/Step1ChooseIntegration"; +import { + Step2ConfigureIntegration, + type SlackConfig, + type TelegramConfig, + type DiscordConfig, + type CustomConfig, +} from "./steps/Step2ConfigureIntegration"; +import { Step3EventsAndTest } from "./steps/Step3EventsAndTest"; +import { HowItWorksSidebar } from "./HowItWorksSidebar"; +import type { WebhookKind } from "./shared/IntegrationCard"; + +interface AddWebhookWizardProps { + isOpen: boolean; + onClose: () => void; + onCreated: () => void; + t: (key: string, opts?: Record) => string; +} + +const STEPS = [1, 2, 3] as const; + +interface WizardState { + kind: WebhookKind; + slack: SlackConfig; + telegram: TelegramConfig; + discord: DiscordConfig; + custom: CustomConfig; + events: string[]; + enabled: boolean; + description: string; +} + +const INITIAL: WizardState = { + kind: "slack", + slack: { webhookUrl: "" }, + telegram: { botToken: "", chatId: "" }, + discord: { webhookUrl: "" }, + custom: { endpointUrl: "", secretKey: "" }, + events: ["*"], + enabled: true, + description: "", +}; + +function step2Valid(state: WizardState): boolean { + const { kind } = state; + if (kind === "slack") return state.slack.webhookUrl.trim().length > 0; + if (kind === "telegram") + return state.telegram.botToken.trim().length > 0 && state.telegram.chatId.trim().length > 0; + if (kind === "discord") return state.discord.webhookUrl.trim().length > 0; + if (kind === "custom") return state.custom.endpointUrl.trim().length > 0; + return false; +} + +export function AddWebhookWizard({ isOpen, onClose, onCreated, t }: AddWebhookWizardProps) { + const [step, setStep] = useState(1); + const [state, setState] = useState(INITIAL); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [createdId, setCreatedId] = useState(null); + + const handleClose = () => { + if (saving) return; + setStep(1); + setState(INITIAL); + setError(null); + setCreatedId(null); + onClose(); + }; + + // Builds the config payload (URL + kind + metadata) — sent when entering step 3. + const buildConfigPayload = () => { + const { kind } = state; + if (kind === "slack") return { kind, url: state.slack.webhookUrl }; + if (kind === "discord") return { kind, url: state.discord.webhookUrl }; + if (kind === "telegram") { + return { kind, url: state.telegram.chatId, metadata: { botToken: state.telegram.botToken } }; + } + const payload: Record = { kind, url: state.custom.endpointUrl }; + if (state.custom.secretKey.trim()) payload.secret = state.custom.secretKey.trim(); + return payload; + }; + + // Called when Next is clicked on step 2: create (or update) the webhook so the + // test button is available at step 3 before the user clicks Finish. + const handleNextFromStep2 = async () => { + setSaving(true); + setError(null); + try { + if (createdId) { + const res = await fetch(`/api/webhooks/${createdId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildConfigPayload()), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + } else { + const res = await fetch("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildConfigPayload()), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + setCreatedId(data.webhook?.id ?? null); + } + setStep(3); + } catch (err) { + setError(err instanceof Error ? err.message : t("saveFailed")); + } finally { + setSaving(false); + } + }; + + // Finish: update events/enabled/description on the already-created webhook. + const finish = async () => { + if (!createdId) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`/api/webhooks/${createdId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + events: state.events, + enabled: state.enabled, + description: state.description, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + onCreated(); + handleClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t("saveFailed")); + } finally { + setSaving(false); + } + }; + + const canGoNext = step === 1 ? true : step === 2 ? step2Valid(state) : true; + + const stepTitle = + step === 1 + ? t("wizard.step1Title") + : step === 2 + ? t("wizard.step2Title") + : t("wizard.step3Title"); + + return ( + +
+ {STEPS.map((s) => ( + + ))} +
+
+ + {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
+ + } + > +
+
+ {error && ( +
+ {error} +
+ )} + {step === 1 && ( + setState((s) => ({ ...s, kind }))} + t={t} + /> + )} + {step === 2 && ( + setState((s) => ({ ...s, slack: v }))} + onChangeTelegram={(v) => setState((s) => ({ ...s, telegram: v }))} + onChangeDiscord={(v) => setState((s) => ({ ...s, discord: v }))} + onChangeCustom={(v) => setState((s) => ({ ...s, custom: v }))} + t={t} + /> + )} + {step === 3 && ( + setState((s) => ({ ...s, events }))} + onChangeEnabled={(enabled) => setState((s) => ({ ...s, enabled }))} + onChangeDescription={(description) => setState((s) => ({ ...s, description }))} + t={t} + /> + )} +
+
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx b/src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx new file mode 100644 index 000000000000..f9f52d6bd7da --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { HmacRecipeBlock } from "./shared/HmacRecipeBlock"; + +interface HowItWorksSidebarProps { + t: (key: string) => string; + showCustomNote?: boolean; +} + +export function HowItWorksSidebar({ t, showCustomNote }: HowItWorksSidebarProps) { + const steps = [ + t("howItWorks.step1"), + t("howItWorks.step2"), + t("howItWorks.step3"), + t("howItWorks.step4"), + ]; + + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx b/src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx new file mode 100644 index 000000000000..4d906790f402 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { WebhookDeliveriesPanel } from "./WebhookDeliveriesPanel"; + +export type WebhookKind = "slack" | "telegram" | "discord" | "custom"; + +export interface WebhookItem { + id: string; + url: string; + events: string[]; + secret: string | null; + enabled: boolean; + description: string; + created_at: string; + last_triggered_at: string | null; + last_status: number | null; + failure_count: number; + kind: WebhookKind; + metadata_encrypted?: string | null; +} + +const KIND_ICONS: Record = { + slack: "chat", + telegram: "send", + discord: "forum", + custom: "webhook", +}; + +const KIND_COLORS: Record = { + slack: "text-emerald-500", + telegram: "text-blue-500", + discord: "text-violet-500", + custom: "text-amber-500", +}; + +function getStatus(wh: WebhookItem): "active" | "inactive" | "errored" { + if (!wh.enabled) return "inactive"; + if (wh.failure_count > 0 || (wh.last_status !== null && wh.last_status >= 400)) return "errored"; + return "active"; +} + +interface WebhookCardProps { + webhook: WebhookItem; + t: (key: string, opts?: Record) => string; + testingId: string | null; + onTest: (wh: WebhookItem) => void; + onToggleEnabled: (wh: WebhookItem) => void; + onEdit: (wh: WebhookItem) => void; + onDelete: (wh: WebhookItem) => void; +} + +export function WebhookCard({ + webhook, + t, + testingId, + onTest, + onToggleEnabled, + onEdit, + onDelete, +}: WebhookCardProps) { + const [expanded, setExpanded] = useState(false); + const status = getStatus(webhook); + const isTesting = testingId === webhook.id; + + return ( +
+
+ + {KIND_ICONS[webhook.kind]} + +
+

+ {webhook.description || t("unnamedWebhook")} +

+

{webhook.url}

+
+ + {t(status)} + +
+ + + + + +
+
+ + {expanded && ( +
+
+ {webhook.events.map((ev) => ( + + {ev === "*" ? t("allEvents") : ev} + + ))} +
+

+ {t("deliveries.title")} +

+ +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx b/src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx new file mode 100644 index 000000000000..cae5c3f9bff1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { DeliveryStatusBadge } from "./shared/DeliveryStatusBadge"; + +interface Delivery { + id: number; + event_type: string; + status: string; + http_status: number | null; + latency_ms: number | null; + error: string | null; + created_at: string; +} + +interface WebhookDeliveriesPanelProps { + webhookId: string; + t: (key: string) => string; +} + +export function WebhookDeliveriesPanel({ webhookId, t }: WebhookDeliveriesPanelProps) { + const [deliveries, setDeliveries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/webhooks/${webhookId}/deliveries?limit=5`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("deliveries.loadFailed")); + setDeliveries(Array.isArray(data.deliveries) ? data.deliveries : []); + } catch (err) { + setError(err instanceof Error ? err.message : t("deliveries.loadFailed")); + } finally { + setLoading(false); + } + }, [webhookId, t]); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) { + return

{t("loading")}

; + } + + if (error) { + return

{error}

; + } + + if (deliveries.length === 0) { + return

{t("deliveries.empty")}

; + } + + return ( +
+ + + + + + + + + + + {deliveries.map((d) => ( + + + + + + + ))} + +
{t("deliveries.status")}{t("deliveries.event")}{t("deliveries.latency")}{t("deliveries.at")}
+ + {d.event_type} + {d.latency_ms != null ? `${d.latency_ms}ms` : "—"} + + {new Date(d.created_at).toLocaleString()} + {d.error && ( +

+ {d.error} +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx b/src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx new file mode 100644 index 000000000000..476311fce045 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { WebhookCard, type WebhookItem } from "./WebhookCard"; + +interface WebhooksListProps { + webhooks: WebhookItem[]; + loading: boolean; + testingId: string | null; + t: (key: string, opts?: Record) => string; + onTest: (wh: WebhookItem) => void; + onToggleEnabled: (wh: WebhookItem) => void; + onEdit: (wh: WebhookItem) => void; + onDelete: (wh: WebhookItem) => void; +} + +export function WebhooksList({ + webhooks, + loading, + testingId, + t, + onTest, + onToggleEnabled, + onEdit, + onDelete, +}: WebhooksListProps) { + if (loading) { + return

{t("loading")}

; + } + + if (webhooks.length === 0) { + return ( +
+ webhook +

{t("noWebhooks")}

+
+ ); + } + + return ( +
+ {webhooks.map((wh) => ( + + ))} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx new file mode 100644 index 000000000000..8d090f23419f --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx @@ -0,0 +1,24 @@ +"use client"; + +interface DeliveryStatusBadgeProps { + status: string; + httpStatus?: number | null; +} + +export function DeliveryStatusBadge({ status, httpStatus }: DeliveryStatusBadgeProps) { + const isSuccess = status === "success"; + return ( + + + {isSuccess ? "check_circle" : "error"} + + {httpStatus ? httpStatus : isSuccess ? "OK" : "ERR"} + + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx new file mode 100644 index 000000000000..bfd55b71d008 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx @@ -0,0 +1,65 @@ +"use client"; + +const WEBHOOK_EVENTS = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", +] as const; + +interface EventChecklistProps { + selected: string[]; + onChange: (events: string[]) => void; + allEventsLabel?: string; +} + +export function EventChecklist({ + selected, + onChange, + allEventsLabel = "All events", +}: EventChecklistProps) { + const isAll = selected.includes("*"); + + const toggle = (event: string) => { + if (event === "*") { + onChange(["*"]); + return; + } + const without = selected.filter((e) => e !== "*"); + const next = without.includes(event) ? without.filter((e) => e !== event) : [...without, event]; + onChange(next.length > 0 ? next : ["*"]); + }; + + return ( +
+ + {WEBHOOK_EVENTS.map((ev) => ( + + ))} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx new file mode 100644 index 000000000000..129b80ccb14f --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; + +interface Snippet { + label: string; + code: string; +} + +interface HmacRecipeBlockProps { + title?: string; + /** Single code block (backward compat) */ + code?: string; + /** Multiple language snippets rendered with tab switcher */ + snippets?: Snippet[]; +} + +export function HmacRecipeBlock({ code, title, snippets }: HmacRecipeBlockProps) { + const tabs: Snippet[] = snippets ?? (code ? [{ label: "Node.js", code }] : []); + const [active, setActive] = useState(0); + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(tabs[active]?.code ?? ""); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (tabs.length === 0) return null; + + return ( +
+ {title && ( +

{title}

+ )} + {tabs.length > 1 && ( +
+ {tabs.map((s, i) => ( + + ))} +
+ )} +
+
+          {tabs[active]?.code}
+        
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx new file mode 100644 index 000000000000..df4846c06cdd --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx @@ -0,0 +1,78 @@ +"use client"; + +export type WebhookKind = "slack" | "telegram" | "discord" | "custom"; +export type ComingSoonKind = "email" | "pagerduty" | "teams"; +type AnyKind = WebhookKind | ComingSoonKind; + +const KIND_ICONS: Record = { + slack: "chat", + telegram: "send", + discord: "forum", + custom: "webhook", + email: "email", + pagerduty: "notifications_active", + teams: "groups", +}; + +const KIND_COLORS: Record = { + slack: "text-emerald-500", + telegram: "text-blue-500", + discord: "text-violet-500", + custom: "text-amber-500", + email: "text-text-muted", + pagerduty: "text-text-muted", + teams: "text-text-muted", +}; + +interface IntegrationCardProps { + kind: AnyKind; + name: string; + description: string; + selected: boolean; + onSelect?: (kind: WebhookKind) => void; + disabled?: boolean; + comingSoonLabel?: string; +} + +export function IntegrationCard({ + kind, + name, + description, + selected, + onSelect, + disabled, + comingSoonLabel, +}: IntegrationCardProps) { + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx new file mode 100644 index 000000000000..c45b7ac84bf9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx @@ -0,0 +1,20 @@ +"use client"; + +interface PayloadPreviewProps { + payload: Record | null; + label?: string; +} + +export function PayloadPreview({ payload, label }: PayloadPreviewProps) { + if (!payload) return null; + return ( +
+ {label && ( +

{label}

+ )} +
+        {JSON.stringify(payload, null, 2)}
+      
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx new file mode 100644 index 000000000000..2bd26bfabebd --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { IntegrationCard, type WebhookKind, type ComingSoonKind } from "../shared/IntegrationCard"; + +interface Step1Props { + selected: WebhookKind; + onSelect: (kind: WebhookKind) => void; + t: (key: string) => string; +} + +const KINDS: WebhookKind[] = ["slack", "telegram", "discord", "custom"]; +const COMING_SOON: ComingSoonKind[] = ["email", "pagerduty", "teams"]; + +export function Step1ChooseIntegration({ selected, onSelect, t }: Step1Props) { + return ( +
+

{t("wizard.step1Desc")}

+
+ {KINDS.map((kind) => ( + + ))} + {COMING_SOON.map((kind) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx new file mode 100644 index 000000000000..4589a3d39de9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { SlackConfigForm, type SlackConfig } from "./integrations/SlackConfigForm"; +import { TelegramConfigForm, type TelegramConfig } from "./integrations/TelegramConfigForm"; +import { DiscordConfigForm, type DiscordConfig } from "./integrations/DiscordConfigForm"; +import { CustomConfigForm, type CustomConfig } from "./integrations/CustomConfigForm"; +import type { WebhookKind } from "../shared/IntegrationCard"; + +interface Step2ConfigureIntegrationProps { + kind: WebhookKind; + slack: SlackConfig; + telegram: TelegramConfig; + discord: DiscordConfig; + custom: CustomConfig; + onChangeSlack: (v: SlackConfig) => void; + onChangeTelegram: (v: TelegramConfig) => void; + onChangeDiscord: (v: DiscordConfig) => void; + onChangeCustom: (v: CustomConfig) => void; + t: (key: string, opts?: Record) => string; + isEditing?: boolean; +} + +export function Step2ConfigureIntegration({ + kind, + slack, + telegram, + discord, + custom, + onChangeSlack, + onChangeTelegram, + onChangeDiscord, + onChangeCustom, + t, + isEditing, +}: Step2ConfigureIntegrationProps) { + if (kind === "slack") return ; + if (kind === "telegram") + return ; + if (kind === "discord") + return ; + return ; +} + +export type { SlackConfig, TelegramConfig, DiscordConfig, CustomConfig }; diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx new file mode 100644 index 000000000000..f96594f816e5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { EventChecklist } from "../shared/EventChecklist"; +import { PayloadPreview } from "../shared/PayloadPreview"; + +interface Step3Props { + webhookId?: string; + events: string[]; + enabled: boolean; + description: string; + onChangeEvents: (events: string[]) => void; + onChangeEnabled: (enabled: boolean) => void; + onChangeDescription: (desc: string) => void; + t: (key: string, opts?: Record) => string; +} + +interface TestResult { + delivered: boolean; + status: number; + latencyMs: number; + payloadSent: Record | null; + responseBody: string; + error?: string | null; +} + +export function Step3EventsAndTest({ + webhookId, + events, + enabled, + description, + onChangeEvents, + onChangeEnabled, + onChangeDescription, + t, +}: Step3Props) { + const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle"); + const [testResult, setTestResult] = useState(null); + + const sendTest = async () => { + if (!webhookId) return; + setTestState("sending"); + setTestResult(null); + try { + const res = await fetch(`/api/webhooks/${webhookId}/test`, { method: "POST" }); + const data: TestResult & { error?: string } = await res.json().catch(() => ({})); + if (!res.ok || data.delivered === false) { + throw new Error(data.error || t("testFailed")); + } + setTestResult(data); + setTestState("ok"); + } catch (err) { + setTestState("fail"); + setTestResult((prev) => ({ + delivered: false, + status: 0, + latencyMs: 0, + payloadSent: prev?.payloadSent ?? null, + responseBody: prev?.responseBody ?? "", + error: err instanceof Error ? err.message : t("testFailed"), + })); + } + }; + + const parseResponseBody = (raw: string): Record | null => { + try { + return JSON.parse(raw) as Record; + } catch { + return raw ? { raw } : null; + } + }; + + return ( +
+
+ + onChangeDescription(e.target.value)} + placeholder={t("namePlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +
+ +
+ + +
+ + + + {webhookId && ( +
+

{t("testWebhook")}

+ + + {testState === "ok" && testResult && ( +
+

+ ✅ {testResult.status} · {testResult.latencyMs}ms · {t("testSuccess")} +

+ {testResult.payloadSent && ( + + )} + {testResult.responseBody && ( + + )} +
+ )} + + {testState === "fail" && testResult && ( +
+

{testResult.error ?? t("testFailed")}

+ {testResult.payloadSent && ( + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx new file mode 100644 index 000000000000..62f7a41565be --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { HmacRecipeBlock } from "../../shared/HmacRecipeBlock"; + +export interface CustomConfig { + endpointUrl: string; + secretKey: string; +} + +interface CustomConfigFormProps { + value: CustomConfig; + onChange: (v: CustomConfig) => void; + t: (key: string) => string; + isEditing?: boolean; +} + +type UrlState = "idle" | "checking" | "ok" | "blocked" | "invalid"; + +export function CustomConfigForm({ value, onChange, t, isEditing }: CustomConfigFormProps) { + const [urlState, setUrlState] = useState("idle"); + + useEffect(() => { + const url = value.endpointUrl.trim(); + const controller = new AbortController(); + const delay = url ? 600 : 0; + const timer = setTimeout(async () => { + if (!url) { + setUrlState("idle"); + return; + } + setUrlState("checking"); + try { + const res = await fetch("/api/webhooks/validate-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + setUrlState(data.valid ? "ok" : data.reason === "blocked_private" ? "blocked" : "invalid"); + } catch { + if (!controller.signal.aborted) setUrlState("invalid"); + } + }, delay); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value.endpointUrl]); + + const urlHint = + urlState === "checking" + ? t("validateUrl.checking") + : urlState === "ok" + ? t("validateUrl.ok") + : urlState === "blocked" + ? t("validateUrl.blockedPrivate") + : urlState === "invalid" && value.endpointUrl.trim() + ? t("validateUrl.invalidUrl") + : ""; + + return ( +
+
+ + onChange({ ...value, endpointUrl: e.target.value })} + placeholder={t("custom.endpointUrlPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {urlHint && ( +

+ {urlHint} +

+ )} +
+
+ + onChange({ ...value, secretKey: e.target.value })} + placeholder={isEditing ? t("secretEditPlaceholder") : t("custom.secretKeyPlaceholder")} + autoComplete="new-password" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +

{t("custom.secretKeyHint")}

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx new file mode 100644 index 000000000000..9be285c93364 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface DiscordConfig { + webhookUrl: string; +} + +interface DiscordConfigFormProps { + value: DiscordConfig; + onChange: (v: DiscordConfig) => void; + t: (key: string) => string; +} + +type UrlState = "idle" | "checking" | "ok" | "blocked" | "invalid"; + +export function DiscordConfigForm({ value, onChange, t }: DiscordConfigFormProps) { + const [urlState, setUrlState] = useState("idle"); + + useEffect(() => { + const url = value.webhookUrl.trim(); + const controller = new AbortController(); + const delay = url ? 600 : 0; + const timer = setTimeout(async () => { + if (!url) { + setUrlState("idle"); + return; + } + setUrlState("checking"); + try { + const res = await fetch("/api/webhooks/validate-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + setUrlState(data.valid ? "ok" : data.reason === "blocked_private" ? "blocked" : "invalid"); + } catch { + if (!controller.signal.aborted) setUrlState("invalid"); + } + }, delay); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value.webhookUrl]); + + const urlHint = + urlState === "checking" + ? t("validateUrl.checking") + : urlState === "ok" + ? t("validateUrl.ok") + : urlState === "blocked" + ? t("validateUrl.blockedPrivate") + : urlState === "invalid" && value.webhookUrl.trim() + ? t("validateUrl.invalidUrl") + : ""; + + return ( +
+
+ + onChange({ webhookUrl: e.target.value })} + placeholder={t("discord.webhookUrlPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {urlHint && ( +

+ {urlHint} +

+ )} +

{t("discord.webhookUrlHint")}

+
+
+ + {t("discord.tutorial")} + +
    + {[1, 2, 3].map((n) => ( +
  1. + {n}. + {t(`discord.tutorialStep${n}`)} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx new file mode 100644 index 000000000000..ee1857023d9c --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface SlackConfig { + webhookUrl: string; +} + +interface SlackConfigFormProps { + value: SlackConfig; + onChange: (v: SlackConfig) => void; + t: (key: string) => string; +} + +type UrlState = "idle" | "checking" | "ok" | "blocked" | "invalid"; + +export function SlackConfigForm({ value, onChange, t }: SlackConfigFormProps) { + const [urlState, setUrlState] = useState("idle"); + + useEffect(() => { + const url = value.webhookUrl.trim(); + const controller = new AbortController(); + const delay = url ? 600 : 0; + const timer = setTimeout(async () => { + if (!url) { + setUrlState("idle"); + return; + } + setUrlState("checking"); + try { + const res = await fetch("/api/webhooks/validate-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + setUrlState(data.valid ? "ok" : data.reason === "blocked_private" ? "blocked" : "invalid"); + } catch { + if (!controller.signal.aborted) setUrlState("invalid"); + } + }, delay); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value.webhookUrl]); + + const urlHint = + urlState === "checking" + ? t("validateUrl.checking") + : urlState === "ok" + ? t("validateUrl.ok") + : urlState === "blocked" + ? t("validateUrl.blockedPrivate") + : urlState === "invalid" && value.webhookUrl.trim() + ? t("validateUrl.invalidUrl") + : ""; + + return ( +
+
+ + onChange({ webhookUrl: e.target.value })} + placeholder={t("slack.webhookUrlPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {urlHint && ( +

+ {urlHint} +

+ )} +

{t("slack.webhookUrlHint")}

+
+
+ + {t("slack.tutorial")} + +
    + {[1, 2, 3, 4].map((n) => ( +
  1. + {n}. + {t(`slack.tutorialStep${n}`)} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx new file mode 100644 index 000000000000..cbcd37a9a278 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx @@ -0,0 +1,58 @@ +"use client"; + +export interface TelegramConfig { + botToken: string; + chatId: string; +} + +interface TelegramConfigFormProps { + value: TelegramConfig; + onChange: (v: TelegramConfig) => void; + t: (key: string) => string; +} + +export function TelegramConfigForm({ value, onChange, t }: TelegramConfigFormProps) { + return ( +
+
+ + onChange({ ...value, botToken: e.target.value })} + placeholder={t("telegram.botTokenPlaceholder")} + autoComplete="new-password" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +

{t("telegram.botTokenHint")}

+
+
+ + onChange({ ...value, chatId: e.target.value })} + placeholder={t("telegram.chatIdPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +

{t("telegram.chatIdHint")}

+
+
+ + {t("telegram.tutorial")} + +
    + {[1, 2, 3, 4].map((n) => ( +
  1. + {n}. + {t(`telegram.tutorialStep${n}`)} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/page.tsx b/src/app/(dashboard)/dashboard/webhooks/page.tsx index 5205fdd07fe7..5012429cefea 100644 --- a/src/app/(dashboard)/dashboard/webhooks/page.tsx +++ b/src/app/(dashboard)/dashboard/webhooks/page.tsx @@ -1,617 +1,5 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; -import { Card, ConfirmModal, Modal } from "@/shared/components"; - -type WebhookItem = { - id: string; - url: string; - events: string[]; - secret: string | null; - enabled: boolean; - description: string; - created_at: string; - last_triggered_at: string | null; - last_status: number | null; - failure_count: number; -}; - -type WebhookFormState = { - url: string; - name: string; - secret: string; - events: string[]; - enabled: boolean; -}; - -type FeedbackState = { - type: "success" | "error"; - message: string; -} | null; - -const WEBHOOK_EVENTS = [ - "request.completed", - "request.failed", - "provider.error", - "provider.recovered", - "quota.exceeded", - "combo.switched", -] as const; - -const EMPTY_FORM: WebhookFormState = { - url: "", - name: "", - secret: "", - events: ["*"], - enabled: true, -}; - -function getWebhookStatus(webhook: WebhookItem): "active" | "inactive" | "errored" { - if (!webhook.enabled) return "inactive"; - if (webhook.failure_count > 0 || (webhook.last_status !== null && webhook.last_status >= 400)) { - return "errored"; - } - return "active"; -} +import { WebhooksPageClient } from "./WebhooksPageClient"; export default function WebhooksPage() { - const t = useTranslations("webhooks"); - const tc = useTranslations("common"); - const [webhooks, setWebhooks] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testingId, setTestingId] = useState(null); - const [feedback, setFeedback] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formMode, setFormMode] = useState<"create" | "edit" | null>(null); - const [editingWebhook, setEditingWebhook] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - - const loadWebhooks = useCallback(async () => { - setLoading(true); - try { - const response = await fetch("/api/webhooks"); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("loadFailed")); - } - setWebhooks(Array.isArray(data.webhooks) ? data.webhooks : []); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("loadFailed"), - }); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - void loadWebhooks(); - }, [loadWebhooks]); - - const stats = useMemo(() => { - return webhooks.reduce( - (acc, webhook) => { - const status = getWebhookStatus(webhook); - acc.total += 1; - acc[status] += 1; - return acc; - }, - { total: 0, active: 0, inactive: 0, errored: 0 } - ); - }, [webhooks]); - - const resetForm = () => { - setForm(EMPTY_FORM); - setFormMode(null); - setEditingWebhook(null); - }; - - const openCreateModal = () => { - setFeedback(null); - setForm(EMPTY_FORM); - setFormMode("create"); - setEditingWebhook(null); - }; - - const openEditModal = (webhook: WebhookItem) => { - setFeedback(null); - setFormMode("edit"); - setEditingWebhook(webhook); - setForm({ - url: webhook.url, - name: webhook.description || "", - secret: "", - events: webhook.events.length > 0 ? webhook.events : ["*"], - enabled: webhook.enabled, - }); - }; - - const closeModal = () => { - if (saving) return; - resetForm(); - }; - - const toggleEvent = (eventName: string) => { - setForm((prev) => { - if (eventName === "*") { - return { ...prev, events: ["*"] }; - } - - if (prev.events.includes("*")) { - return { ...prev, events: [eventName] }; - } - - const nextEvents = prev.events.includes(eventName) - ? prev.events.filter((event) => event !== eventName) - : [...prev.events, eventName]; - - return { ...prev, events: nextEvents.length > 0 ? nextEvents : ["*"] }; - }); - }; - - const saveWebhook = async () => { - if (!form.url.trim()) return; - - setSaving(true); - setFeedback(null); - - const payload: Record = { - url: form.url.trim(), - events: form.events, - description: form.name.trim(), - enabled: form.enabled, - }; - - if (form.secret.trim()) { - payload.secret = form.secret.trim(); - } - - const isEditing = formMode === "edit" && Boolean(editingWebhook?.id); - - try { - const response = await fetch( - isEditing ? `/api/webhooks/${editingWebhook?.id}` : "/api/webhooks", - { - method: isEditing ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("saveFailed")); - } - - setFeedback({ type: "success", message: t("saveSuccess") }); - resetForm(); - await loadWebhooks(); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("saveFailed"), - }); - } finally { - setSaving(false); - } - }; - - const toggleEnabled = async (webhook: WebhookItem) => { - setFeedback(null); - try { - const response = await fetch(`/api/webhooks/${webhook.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: !webhook.enabled }), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("saveFailed")); - } - - setWebhooks((prev) => - prev.map((item) => (item.id === webhook.id ? { ...item, enabled: !webhook.enabled } : item)) - ); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("saveFailed"), - }); - } - }; - - const testWebhook = async (webhook: WebhookItem) => { - setTestingId(webhook.id); - setFeedback(null); - try { - const response = await fetch(`/api/webhooks/${webhook.id}/test`, { method: "POST" }); - const data = await response.json().catch(() => ({})); - if (!response.ok || data.delivered === false) { - throw new Error(data.error || t("testFailed")); - } - - setFeedback({ type: "success", message: t("testSuccess") }); - await loadWebhooks(); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("testFailed"), - }); - } finally { - setTestingId(null); - } - }; - - const deleteWebhook = async () => { - if (!deleteTarget) return; - - setSaving(true); - setFeedback(null); - try { - const response = await fetch(`/api/webhooks/${deleteTarget.id}`, { method: "DELETE" }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("deleteFailed")); - } - - setWebhooks((prev) => prev.filter((webhook) => webhook.id !== deleteTarget.id)); - setDeleteTarget(null); - setFeedback({ type: "success", message: t("deleteSuccess") }); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("deleteFailed"), - }); - } finally { - setSaving(false); - } - }; - - const modalTitle = formMode === "edit" ? t("editWebhook") : t("addWebhook"); - const isModalOpen = formMode !== null; - - return ( -
-
- -
- - {feedback && ( -
- {feedback.message} -
- )} - -
- {[ - { label: t("total"), value: stats.total, icon: "webhook", tone: "text-primary" }, - { - label: t("active"), - value: stats.active, - icon: "check_circle", - tone: "text-emerald-500", - }, - { - label: t("inactive"), - value: stats.inactive, - icon: "pause_circle", - tone: "text-text-muted", - }, - { label: t("errored"), value: stats.errored, icon: "error", tone: "text-red-500" }, - ].map((stat) => ( - -
-
-

- {stat.label} -

-

{stat.value}

-
- - {stat.icon} - -
-
- ))} -
- - -
-
-
-

{t("configuredWebhooks")}

-

{t("configuredWebhooksDesc")}

-
- -
-
- - {loading ? ( -
{t("loading")}
- ) : webhooks.length === 0 ? ( -
- webhook -

{t("noWebhooks")}

-
- ) : ( -
- - - - - - - - - - - - - {webhooks.map((webhook) => { - const status = getWebhookStatus(webhook); - return ( - - - - - - - - - ); - })} - -
{t("name")}{t("url")}{t("events")}{t("status")}{t("lastTriggered")}{t("actions")}
-
- {webhook.description || t("unnamedWebhook")} -
-
- {t("failureCount", { count: webhook.failure_count })} -
-
- - {webhook.url} - - -
- {webhook.events.map((eventName) => ( - - {eventName === "*" ? t("allEvents") : eventName} - - ))} -
-
- - - {status === "active" - ? "check_circle" - : status === "errored" - ? "error" - : "pause_circle"} - - {t(status)} - - - {webhook.last_triggered_at - ? new Date(webhook.last_triggered_at).toLocaleString() - : t("never")} - {webhook.last_status ? ( - ({webhook.last_status}) - ) : null} - -
- - - - -
-
-
- )} -
- - -
- vpn_key -
-

{t("signatureTitle")}

-

{t("signatureDescription")}

- - {`const sig = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");`} - -
-
-
- - - - - - } - > -
-
- - setForm((prev) => ({ ...prev, name: event.target.value }))} - placeholder={t("namePlaceholder")} - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" - /> -
-
- - setForm((prev) => ({ ...prev, url: event.target.value }))} - placeholder="https://example.com/webhook" - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" - /> -
-
- - setForm((prev) => ({ ...prev, secret: event.target.value }))} - placeholder={ - formMode === "edit" ? t("secretEditPlaceholder") : t("secretPlaceholder") - } - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" - /> -
-
- -
- - {WEBHOOK_EVENTS.map((eventName) => ( - - ))} -
-
- -
-
- - setDeleteTarget(null)} - onConfirm={deleteWebhook} - title={t("delete")} - message={t("deleteConfirm")} - confirmText={t("delete")} - cancelText={tc("cancel")} - loading={saving} - /> -
- ); + return ; } diff --git a/src/app/api/webhooks/[id]/deliveries/route.ts b/src/app/api/webhooks/[id]/deliveries/route.ts new file mode 100644 index 000000000000..0f7aee3516a9 --- /dev/null +++ b/src/app/api/webhooks/[id]/deliveries/route.ts @@ -0,0 +1,34 @@ +/** + * API: Webhook Delivery History + * GET — List recent deliveries for a webhook + */ + +import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { getWebhook, getDeliveries } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const webhook = getWebhook(id); + if (!webhook) { + return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const limitParam = url.searchParams.get("limit"); + const limit = Math.min(Math.max(1, parseInt(limitParam ?? "20", 10) || 20), 100); + + const deliveries = getDeliveries(id, limit); + return NextResponse.json({ deliveries }); + } catch (error: any) { + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to fetch deliveries" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index 220bd00cbf4d..84b7621913d5 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -11,14 +11,20 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { encryptMetadata } from "@/lib/webhookDispatcher"; +import { isEncryptionEnabled } from "@/lib/db/encryption"; + +const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const; const updateWebhookSchema = z .object({ - url: z.string().url("Invalid URL format").max(2000).optional(), + url: z.string().min(1).max(2000).optional(), events: z.array(z.string()).optional(), secret: z.string().max(500).optional(), description: z.string().max(1000).optional(), enabled: z.boolean().optional(), + kind: z.enum(WEBHOOK_KINDS).optional(), + metadata: z.record(z.string()).optional(), }) .passthrough(); @@ -50,7 +56,28 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: validation.error }, { status: 400 }); } - const webhook = updateWebhookRecord(id, validation.data); + const { metadata, ...rest } = validation.data as typeof validation.data & { + metadata?: Record; + }; + + const existingWebhook = getWebhook(id); + if (!existingWebhook) { + return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); + } + const effectiveKind = rest.kind ?? existingWebhook.kind; + if (effectiveKind === "telegram" && metadata?.botToken && !isEncryptionEnabled()) { + return NextResponse.json( + { error: "Telegram webhooks require STORAGE_ENCRYPTION_KEY to be configured" }, + { status: 400 } + ); + } + + const updateData: Parameters[1] = { ...rest }; + if (metadata !== undefined) { + (updateData as any).metadataEncrypted = encryptMetadata(metadata); + } + + const webhook = updateWebhookRecord(id, updateData); if (!webhook) { return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); } diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index 0d5065853ee8..199a2e48e923 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -1,13 +1,67 @@ /** * API: Webhook Test Delivery - * POST — Send a test ping event to a specific webhook + * POST — Send a test ping event to a specific webhook and return full diagnostics. */ import { NextResponse } from "next/server"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -import { getWebhook, recordWebhookDelivery } from "@/lib/localDb"; -import { deliverWebhook } from "@/lib/webhookDispatcher"; +import { getWebhook } from "@/lib/localDb"; +import { decryptMetadata } from "@/lib/webhookDispatcher"; +import { buildSlackPayload } from "@/lib/webhooks/integrations/slack"; +import { buildTelegramUrl, buildTelegramPayload } from "@/lib/webhooks/integrations/telegram"; +import { buildDiscordPayload } from "@/lib/webhooks/integrations/discord"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { insertDelivery } from "@/lib/db/webhookDeliveries"; +import { recordWebhookDelivery } from "@/lib/localDb"; +import crypto from "crypto"; + +const MAX_RESPONSE_BODY = 2048; + +async function testFetch( + url: string, + body: Record, + headers: Record +): Promise<{ + success: boolean; + status: number; + latencyMs: number; + responseBody: string; + error?: string; +}> { + const start = Date.now(); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "OmniRoute-Webhook/1.0", + ...headers, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeoutId); + const latencyMs = Date.now() - start; + let rawBody = ""; + try { + rawBody = await res.text(); + if (rawBody.length > MAX_RESPONSE_BODY) rawBody = rawBody.slice(0, MAX_RESPONSE_BODY) + "…"; + } catch { + rawBody = ""; + } + return { success: res.ok, status: res.status, latencyMs, responseBody: rawBody }; + } catch (error: any) { + return { + success: false, + status: 0, + latencyMs: Date.now() - start, + responseBody: "", + error: error.message || "Network error", + }; + } +} export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) { const authError = await requireManagementAuth(_); @@ -20,25 +74,69 @@ export async function POST(_: Request, { params }: { params: Promise<{ id: strin return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); } - const result = await deliverWebhook( - webhook.url, - { - event: "test.ping", - timestamp: new Date().toISOString(), - data: { - message: "Test webhook delivery from OmniRoute", - webhookId: webhook.id, - }, - }, - webhook.secret, - 0 // No retries for test - ); + const kind = webhook.kind ?? "custom"; + const testData = { + message: "Test webhook delivery from OmniRoute", + webhookId: webhook.id, + }; + const testPayload = { event: "test.ping", timestamp: new Date().toISOString(), data: testData }; + let payloadSent: Record; + let fetchUrl: string; + let extraHeaders: Record = {}; + + if (kind === "slack") { + payloadSent = buildSlackPayload("test.ping", testData) as Record; + fetchUrl = webhook.url; + } else if (kind === "discord") { + payloadSent = buildDiscordPayload("test.ping", testData) as Record; + fetchUrl = webhook.url; + } else if (kind === "telegram") { + const meta = decryptMetadata(webhook.metadata_encrypted ?? null); + const botToken = meta?.botToken; + if (!botToken) { + return NextResponse.json({ error: "Missing Telegram botToken" }, { status: 422 }); + } + fetchUrl = buildTelegramUrl(botToken); + payloadSent = buildTelegramPayload("test.ping", testData, webhook.url) as Record< + string, + unknown + >; + } else { + payloadSent = testPayload as Record; + fetchUrl = webhook.url; + if (webhook.secret) { + const bodyStr = JSON.stringify(testPayload); + extraHeaders["X-Webhook-Signature"] = + `sha256=${crypto.createHmac("sha256", webhook.secret).update(bodyStr).digest("hex")}`; + extraHeaders["X-Webhook-Event"] = "test.ping"; + extraHeaders["X-Webhook-Timestamp"] = testPayload.timestamp; + } + } + + const result = await testFetch(fetchUrl, payloadSent, extraHeaders); + + try { + insertDelivery({ + webhookId: webhook.id, + eventType: "test.ping", + status: result.success ? "success" : "failed", + httpStatus: result.status || null, + latencyMs: result.latencyMs, + error: result.error ?? null, + payloadSnapshot: kind === "custom" ? JSON.stringify(payloadSent).slice(0, 2000) : null, + }); + } catch { + // delivery logging is best-effort + } recordWebhookDelivery(webhook.id, result.status, result.success); return NextResponse.json({ delivered: result.success, status: result.status, + latencyMs: result.latencyMs, + payloadSent, + responseBody: result.responseBody, error: result.error ? sanitizeErrorMessage(result.error) : null, }); } catch (error: any) { diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index c69c4960b88d..bdb081073f75 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -10,12 +10,18 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhooks, createWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { encryptMetadata } from "@/lib/webhookDispatcher"; +import { isEncryptionEnabled } from "@/lib/db/encryption"; + +const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const; const createWebhookSchema = z.object({ - url: z.string().url("Invalid URL format").max(2000), + url: z.string().min(1).max(2000), events: z.array(z.string()).optional().default(["*"]), secret: z.string().max(500).optional(), description: z.string().max(1000).optional().default(""), + kind: z.enum(WEBHOOK_KINDS).optional().default("custom"), + metadata: z.record(z.string()).optional(), }); export async function GET(request: Request) { @@ -50,11 +56,22 @@ export async function POST(request: Request) { } const { data } = validation; + + if (data.kind === "telegram" && !isEncryptionEnabled()) { + return NextResponse.json( + { error: "Telegram webhooks require STORAGE_ENCRYPTION_KEY to be configured" }, + { status: 400 } + ); + } + + const metadataEncrypted = data.metadata ? encryptMetadata(data.metadata) : undefined; const webhook = createWebhook({ url: data.url, events: data.events, secret: data.secret, description: data.description, + kind: data.kind, + metadataEncrypted, }); return NextResponse.json({ webhook }, { status: 201 }); diff --git a/src/app/api/webhooks/validate-url/route.ts b/src/app/api/webhooks/validate-url/route.ts new file mode 100644 index 000000000000..b328bffc410a --- /dev/null +++ b/src/app/api/webhooks/validate-url/route.ts @@ -0,0 +1,46 @@ +/** + * API: Validate Webhook URL + * POST — Check if a URL is safe to use as a webhook endpoint (SSRF guard) + */ + +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + parseAndValidatePublicUrl, + OutboundUrlGuardError, +} from "@/shared/network/outboundUrlGuard"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const validateUrlSchema = z.object({ + url: z.string().min(1).max(2000), +}); + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(validateUrlSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { url } = validation.data; + + try { + parseAndValidatePublicUrl(url); + return NextResponse.json({ valid: true }); + } catch (err) { + if (err instanceof OutboundUrlGuardError) { + return NextResponse.json({ valid: false, reason: "blocked_private" }); + } + return NextResponse.json({ valid: false, reason: "invalid_url" }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d5cc0caf72f5..2d4da499dadd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -961,8 +961,10 @@ "never": "Never", "failureCount": "{count, plural, =0 {no failures} one {# failure} other {# failures}}", "testWebhook": "Send Test", - "testSuccess": "Test webhook sent successfully.", + "testSuccess": "delivery confirmed", "testFailed": "Test webhook failed.", + "testPayloadSent": "Payload sent", + "testResponse": "Endpoint response", "saveSuccess": "Webhook saved successfully.", "saveFailed": "Failed to save webhook.", "loadFailed": "Failed to load webhooks.", @@ -975,7 +977,106 @@ "disable": "Disable", "noWebhooks": "No webhooks configured yet.", "signatureTitle": "Webhook Signatures", - "signatureDescription": "Each delivery includes an X-Webhook-Signature header signed with HMAC-SHA256 using the webhook secret. Verify the signature before trusting the payload." + "signatureDescription": "Each delivery includes an X-Webhook-Signature header signed with HMAC-SHA256 using the webhook secret. Verify the signature before trusting the payload.", + "wizard": { + "step1Title": "Choose Integration", + "step1Desc": "Select the platform where you want to receive notifications.", + "step2Title": "Configure", + "step2Desc": "Enter the connection details for your integration.", + "step3Title": "Events & Test", + "step3Desc": "Select events to subscribe and send a test notification.", + "back": "Back", + "next": "Next", + "finish": "Add Webhook", + "cancel": "Cancel" + }, + "kinds": { + "slack": "Slack", + "slackDesc": "Send notifications to a Slack channel via Incoming Webhooks.", + "telegram": "Telegram", + "telegramDesc": "Send messages to a Telegram group or channel via Bot API.", + "discord": "Discord", + "discordDesc": "Send embeds to a Discord channel via Webhook URL.", + "custom": "Custom", + "customDesc": "POST JSON to any HTTP endpoint with optional HMAC-SHA256 signing.", + "email": "Email", + "emailDesc": "Send notifications to an email address.", + "pagerduty": "PagerDuty", + "pagerdutyDesc": "Trigger and resolve PagerDuty incidents automatically.", + "teams": "Microsoft Teams", + "teamsDesc": "Send messages to a Microsoft Teams channel.", + "comingSoon": "Coming soon" + }, + "slack": { + "webhookUrl": "Slack Webhook URL", + "webhookUrlPlaceholder": "https://hooks.slack.com/services/…", + "webhookUrlHint": "Create one in your Slack App under Incoming Webhooks.", + "tutorial": "How to get a Slack Webhook URL", + "tutorialStep1": "Go to api.slack.com/apps and open your app (or create one).", + "tutorialStep2": "Enable Incoming Webhooks in the Features menu.", + "tutorialStep3": "Click Add New Webhook to Workspace and choose the channel.", + "tutorialStep4": "Copy the generated webhook URL and paste it above." + }, + "telegram": { + "botToken": "Bot Token", + "botTokenPlaceholder": "123456789:ABC-DEF…", + "botTokenHint": "Get a token from @BotFather on Telegram.", + "chatId": "Chat ID", + "chatIdPlaceholder": "-100123456789", + "chatIdHint": "Use a negative ID for groups/channels (e.g. -1001234567890).", + "tutorial": "How to set up a Telegram Bot", + "tutorialStep1": "Open Telegram and search for @BotFather.", + "tutorialStep2": "Send /newbot, choose a name, and copy the bot token.", + "tutorialStep3": "Add the bot to your group or channel and make it an admin.", + "tutorialStep4": "Copy the Chat ID from the group info or use @userinfobot." + }, + "discord": { + "webhookUrl": "Discord Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/…", + "webhookUrlHint": "Create one in Discord channel settings under Integrations.", + "tutorial": "How to get a Discord Webhook URL", + "tutorialStep1": "Open Discord and go to your server's channel settings.", + "tutorialStep2": "Click Integrations → Webhooks → New Webhook.", + "tutorialStep3": "Choose a name and channel, then click Copy Webhook URL." + }, + "custom": { + "endpointUrl": "Endpoint URL", + "endpointUrlPlaceholder": "https://example.com/webhook", + "secretKey": "Signing Secret", + "secretKeyPlaceholder": "Leave blank to auto-generate", + "secretKeyHint": "Used to sign payloads with HMAC-SHA256 (X-Webhook-Signature header)." + }, + "validateUrl": { + "checking": "Checking URL…", + "blockedPrivate": "This URL points to a private/local address and is blocked for security.", + "invalidUrl": "This URL is not valid.", + "ok": "URL looks good." + }, + "deliveries": { + "title": "Delivery History", + "empty": "No deliveries recorded yet.", + "status": "Status", + "event": "Event", + "httpStatus": "HTTP", + "latency": "Latency", + "error": "Error", + "at": "At", + "success": "Success", + "failed": "Failed", + "loadFailed": "Failed to load delivery history." + }, + "howItWorks": { + "title": "How It Works", + "step1": "OmniRoute fires an event (e.g. request.failed).", + "step2": "The dispatcher builds a platform-specific payload.", + "step3": "The payload is posted to your configured endpoint.", + "step4": "You receive the notification in Slack, Telegram, Discord, or your custom handler.", + "customOnly": "For Custom webhooks, each delivery is signed with HMAC-SHA256.", + "hmacRecipeTitle": "Verifying the Signature", + "hmacRecipe": "const sig = req.headers['x-webhook-signature'];\nconst body = JSON.stringify(req.body);\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(body).digest('hex');\nif (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error('Bad sig');", + "hmacRecipePython": "import hmac, hashlib\nsig = request.headers.get('X-Webhook-Signature', '')\nbody = request.get_data()\nexpected = 'sha256=' + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()\nif not hmac.compare_digest(sig, expected):\n raise ValueError('Bad signature')", + "hmacRecipeBash": "SIG=$(echo -n \"$BODY\" | openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print \"sha256=\" $2}')\n[ \"$SIG\" = \"$RECEIVED_SIG\" ] || { echo 'Bad signature'; exit 1; }" + } }, "compliance": { "auditTitle": "Audit", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 20bfa1100bf8..b6c57dfc19ae 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -960,8 +960,10 @@ "never": "Nunca", "failureCount": "{count, plural, =0 {sem falhas} one {# falha} other {# falhas}}", "testWebhook": "Enviar Teste", - "testSuccess": "Webhook de teste enviado com sucesso.", + "testSuccess": "entrega confirmada", "testFailed": "Webhook de teste falhou.", + "testPayloadSent": "Payload enviado", + "testResponse": "Resposta do endpoint", "saveSuccess": "Webhook salvo com sucesso.", "saveFailed": "Falha ao salvar webhook.", "loadFailed": "Falha ao carregar webhooks.", @@ -974,7 +976,106 @@ "disable": "Desativar", "noWebhooks": "Nenhum webhook configurado ainda.", "signatureTitle": "Assinaturas de Webhook", - "signatureDescription": "Cada entrega inclui um cabeçalho X-Webhook-Signature assinado com HMAC-SHA256 usando o segredo do webhook. Verifique a assinatura antes de confiar no payload." + "signatureDescription": "Cada entrega inclui um cabeçalho X-Webhook-Signature assinado com HMAC-SHA256 usando o segredo do webhook. Verifique a assinatura antes de confiar no payload.", + "wizard": { + "step1Title": "Escolher Integração", + "step1Desc": "Selecione a plataforma onde deseja receber notificações.", + "step2Title": "Configurar", + "step2Desc": "Insira os dados de conexão para a integração.", + "step3Title": "Eventos e Teste", + "step3Desc": "Selecione os eventos para assinar e envie uma notificação de teste.", + "back": "Voltar", + "next": "Próximo", + "finish": "Adicionar Webhook", + "cancel": "Cancelar" + }, + "kinds": { + "slack": "Slack", + "slackDesc": "Envie notificações para um canal Slack via Incoming Webhooks.", + "telegram": "Telegram", + "telegramDesc": "Envie mensagens para um grupo ou canal do Telegram via Bot API.", + "discord": "Discord", + "discordDesc": "Envie embeds para um canal do Discord via Webhook URL.", + "custom": "Personalizado", + "customDesc": "Faça POST de JSON para qualquer endpoint HTTP com assinatura HMAC-SHA256 opcional.", + "email": "E-mail", + "emailDesc": "Envie notificações para um endereço de e-mail.", + "pagerduty": "PagerDuty", + "pagerdutyDesc": "Acione e resolva incidentes no PagerDuty automaticamente.", + "teams": "Microsoft Teams", + "teamsDesc": "Envie mensagens para um canal do Microsoft Teams.", + "comingSoon": "Em breve" + }, + "slack": { + "webhookUrl": "URL do Webhook Slack", + "webhookUrlPlaceholder": "https://hooks.slack.com/services/…", + "webhookUrlHint": "Crie em seu Slack App em Incoming Webhooks.", + "tutorial": "Como obter uma URL de Webhook do Slack", + "tutorialStep1": "Acesse api.slack.com/apps e abra seu app (ou crie um).", + "tutorialStep2": "Ative Incoming Webhooks no menu Features.", + "tutorialStep3": "Clique em Add New Webhook to Workspace e escolha o canal.", + "tutorialStep4": "Copie a URL gerada e cole acima." + }, + "telegram": { + "botToken": "Token do Bot", + "botTokenPlaceholder": "123456789:ABC-DEF…", + "botTokenHint": "Obtenha um token com @BotFather no Telegram.", + "chatId": "Chat ID", + "chatIdPlaceholder": "-100123456789", + "chatIdHint": "Use um ID negativo para grupos/canais (ex.: -1001234567890).", + "tutorial": "Como configurar um Bot do Telegram", + "tutorialStep1": "Abra o Telegram e procure por @BotFather.", + "tutorialStep2": "Envie /newbot, escolha um nome e copie o token do bot.", + "tutorialStep3": "Adicione o bot ao grupo ou canal e torne-o administrador.", + "tutorialStep4": "Copie o Chat ID das informações do grupo ou use @userinfobot." + }, + "discord": { + "webhookUrl": "URL do Webhook Discord", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/…", + "webhookUrlHint": "Crie nas configurações do canal Discord em Integrações.", + "tutorial": "Como obter uma URL de Webhook do Discord", + "tutorialStep1": "Abra o Discord e acesse as configurações do canal no servidor.", + "tutorialStep2": "Clique em Integrações → Webhooks → Novo Webhook.", + "tutorialStep3": "Escolha nome e canal, depois clique em Copiar URL do Webhook." + }, + "custom": { + "endpointUrl": "URL do Endpoint", + "endpointUrlPlaceholder": "https://exemplo.com/webhook", + "secretKey": "Segredo de Assinatura", + "secretKeyPlaceholder": "Deixe em branco para gerar automaticamente", + "secretKeyHint": "Usado para assinar payloads com HMAC-SHA256 (cabeçalho X-Webhook-Signature)." + }, + "validateUrl": { + "checking": "Verificando URL…", + "blockedPrivate": "Esta URL aponta para um endereço privado/local e está bloqueada por segurança.", + "invalidUrl": "Esta URL não é válida.", + "ok": "URL parece válida." + }, + "deliveries": { + "title": "Histórico de Entregas", + "empty": "Nenhuma entrega registrada ainda.", + "status": "Status", + "event": "Evento", + "httpStatus": "HTTP", + "latency": "Latência", + "error": "Erro", + "at": "Em", + "success": "Sucesso", + "failed": "Falhou", + "loadFailed": "Falha ao carregar histórico de entregas." + }, + "howItWorks": { + "title": "Como Funciona", + "step1": "OmniRoute dispara um evento (ex.: request.failed).", + "step2": "O dispatcher constrói um payload específico para a plataforma.", + "step3": "O payload é enviado para o endpoint configurado.", + "step4": "Você recebe a notificação no Slack, Telegram, Discord ou handler personalizado.", + "customOnly": "Para webhooks Personalizados, cada entrega é assinada com HMAC-SHA256.", + "hmacRecipeTitle": "Verificando a Assinatura", + "hmacRecipe": "const sig = req.headers['x-webhook-signature'];\nconst body = JSON.stringify(req.body);\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(body).digest('hex');\nif (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error('Assinatura inválida');", + "hmacRecipePython": "import hmac, hashlib\nsig = request.headers.get('X-Webhook-Signature', '')\nbody = request.get_data()\nexpected = 'sha256=' + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()\nif not hmac.compare_digest(sig, expected):\n raise ValueError('Assinatura inválida')", + "hmacRecipeBash": "SIG=$(echo -n \"$BODY\" | openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print \"sha256=\" $2}')\n[ \"$SIG\" = \"$RECEIVED_SIG\" ] || { echo 'Assinatura inválida'; exit 1; }" + } }, "compliance": { "auditTitle": "Auditoria", diff --git a/src/lib/db/migrations/068_webhooks_kind_metadata.sql b/src/lib/db/migrations/068_webhooks_kind_metadata.sql new file mode 100644 index 000000000000..877afe9e38a7 --- /dev/null +++ b/src/lib/db/migrations/068_webhooks_kind_metadata.sql @@ -0,0 +1,3 @@ +-- Migration 068: Add kind and encrypted metadata to webhooks +ALTER TABLE webhooks ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'; +ALTER TABLE webhooks ADD COLUMN metadata_encrypted BLOB; diff --git a/src/lib/db/migrations/069_webhook_deliveries.sql b/src/lib/db/migrations/069_webhook_deliveries.sql new file mode 100644 index 000000000000..1e343d296dcd --- /dev/null +++ b/src/lib/db/migrations/069_webhook_deliveries.sql @@ -0,0 +1,15 @@ +-- Migration 069: Webhook delivery history +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + webhook_id TEXT NOT NULL, + event_type TEXT NOT NULL, + status TEXT NOT NULL, + http_status INTEGER, + latency_ms INTEGER, + error TEXT, + payload_snapshot TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_created + ON webhook_deliveries(webhook_id, created_at DESC); diff --git a/src/lib/db/webhookDeliveries.ts b/src/lib/db/webhookDeliveries.ts new file mode 100644 index 000000000000..f57dee0ede3a --- /dev/null +++ b/src/lib/db/webhookDeliveries.ts @@ -0,0 +1,66 @@ +import { getDbInstance } from "./core"; + +export interface WebhookDelivery { + id: number; + webhook_id: string; + event_type: string; + status: string; + http_status: number | null; + latency_ms: number | null; + error: string | null; + payload_snapshot: string | null; + created_at: string; +} + +const MAX_DELIVERIES_PER_WEBHOOK = 100; + +export function insertDelivery(opts: { + webhookId: string; + eventType: string; + status: string; + httpStatus?: number | null; + latencyMs?: number | null; + error?: string | null; + payloadSnapshot?: string | null; +}): void { + const db = getDbInstance(); + const insertStmt = db.prepare( + `INSERT INTO webhook_deliveries + (webhook_id, event_type, status, http_status, latency_ms, error, payload_snapshot) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const rotateStmt = db.prepare( + `DELETE FROM webhook_deliveries + WHERE webhook_id = ? + AND id NOT IN ( + SELECT id FROM webhook_deliveries + WHERE webhook_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + )` + ); + db.transaction(() => { + insertStmt.run( + opts.webhookId, + opts.eventType, + opts.status, + opts.httpStatus ?? null, + opts.latencyMs ?? null, + opts.error ?? null, + opts.payloadSnapshot ?? null + ); + rotateStmt.run(opts.webhookId, opts.webhookId, MAX_DELIVERIES_PER_WEBHOOK); + })(); +} + +export function getDeliveries(webhookId: string, limit: number): WebhookDelivery[] { + const db = getDbInstance(); + return db + .prepare( + `SELECT * FROM webhook_deliveries + WHERE webhook_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ?` + ) + .all(webhookId, limit) as WebhookDelivery[]; +} diff --git a/src/lib/db/webhooks.ts b/src/lib/db/webhooks.ts index 8a36c67ef00e..5fd5e223388b 100644 --- a/src/lib/db/webhooks.ts +++ b/src/lib/db/webhooks.ts @@ -6,6 +6,8 @@ import { getDbInstance } from "./core"; import crypto from "crypto"; +export type WebhookKind = "slack" | "telegram" | "discord" | "custom"; + export interface Webhook { id: string; url: string; @@ -17,6 +19,8 @@ export interface Webhook { last_triggered_at: string | null; last_status: number | null; failure_count: number; + kind: WebhookKind; + metadata_encrypted: string | null; } interface WebhookRow { @@ -30,11 +34,14 @@ interface WebhookRow { last_triggered_at: string | null; last_status: number | null; failure_count: number; + kind: string; + metadata_encrypted: string | null; } function rowToWebhook(row: WebhookRow): Webhook { return { ...row, + kind: (row.kind as WebhookKind) || "custom", events: JSON.parse(row.events || '["*"]'), enabled: row.enabled === 1, }; @@ -63,15 +70,26 @@ export function createWebhook(data: { events?: string[]; secret?: string; description?: string; + kind?: WebhookKind; + metadataEncrypted?: string | null; }): Webhook { const db = getDbInstance(); const id = crypto.randomUUID(); const secret = data.secret || `whsec_${crypto.randomBytes(24).toString("hex")}`; + const kind = data.kind || "custom"; db.prepare( - `INSERT INTO webhooks (id, url, events, secret, description) - VALUES (?, ?, ?, ?, ?)` - ).run(id, data.url, JSON.stringify(data.events || ["*"]), secret, data.description || ""); + `INSERT INTO webhooks (id, url, events, secret, description, kind, metadata_encrypted) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + data.url, + JSON.stringify(data.events || ["*"]), + secret, + data.description || "", + kind, + data.metadataEncrypted ?? null + ); return getWebhook(id)!; } @@ -84,6 +102,8 @@ export function updateWebhook( secret: string; enabled: boolean; description: string; + kind: WebhookKind; + metadataEncrypted: string | null; }> ): Webhook | null { const db = getDbInstance(); @@ -113,6 +133,14 @@ export function updateWebhook( fields.push("description = ?"); values.push(data.description); } + if (data.kind !== undefined) { + fields.push("kind = ?"); + values.push(data.kind); + } + if (data.metadataEncrypted !== undefined) { + fields.push("metadata_encrypted = ?"); + values.push(data.metadataEncrypted); + } if (fields.length === 0) return existing; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3edfaf573c50..bd6a09ba67ad 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -284,7 +284,10 @@ export { disableWebhooksWithHighFailures, } from "./db/webhooks"; -export type { Webhook } from "./db/webhooks"; +export type { Webhook, WebhookKind } from "./db/webhooks"; + +export { insertDelivery, getDeliveries } from "./db/webhookDeliveries"; +export type { WebhookDelivery } from "./db/webhookDeliveries"; export { saveQuotaSnapshot, diff --git a/src/lib/webhookDispatcher.ts b/src/lib/webhookDispatcher.ts index 7182055d8b39..c5ef9addf7a0 100644 --- a/src/lib/webhookDispatcher.ts +++ b/src/lib/webhookDispatcher.ts @@ -1,18 +1,15 @@ /** * Webhook Dispatcher - * Dispatches events to registered webhooks with HMAC-SHA256 signing and retries + * Dispatches events to registered webhooks with HMAC-SHA256 signing and retries. + * Slack/Telegram/Discord use per-kind payload transformers (no HMAC wrapping). */ import crypto from "crypto"; +import { encrypt, decrypt } from "./db/encryption"; +import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard"; +import type { WebhookEvent } from "./webhooks/eventDescriptions"; -export type WebhookEvent = - | "request.completed" - | "request.failed" - | "provider.error" - | "provider.recovered" - | "quota.exceeded" - | "combo.switched" - | "test.ping"; +export type { WebhookEvent }; export interface WebhookPayload { event: WebhookEvent; @@ -24,12 +21,59 @@ function signPayload(payload: string, secret: string): string { return `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`; } +export function encryptMetadata(meta: Record): string { + return encrypt(JSON.stringify(meta)) ?? JSON.stringify(meta); +} + +export function decryptMetadata(encrypted: string | null): Record | null { + if (!encrypted) return null; + const raw = decrypt(encrypted); + if (!raw) return null; + try { + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +async function deliverRaw( + url: string, + body: Record +): Promise<{ success: boolean; status: number; latencyMs: number; error?: string }> { + const start = Date.now(); + try { + parseAndValidatePublicUrl(url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": "OmniRoute-Webhook/1.0" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeoutId); + return { success: res.ok, status: res.status, latencyMs: Date.now() - start }; + } catch (error: any) { + return { + success: false, + status: 0, + latencyMs: Date.now() - start, + error: error.message || "Network error", + }; + } +} + export async function deliverWebhook( url: string, payload: WebhookPayload, secret?: string | null, maxRetries = 3 ): Promise<{ success: boolean; status: number; error?: string }> { + try { + parseAndValidatePublicUrl(url); + } catch (error: any) { + return { success: false, status: 0, error: error.message || "Blocked outbound URL" }; + } const body = JSON.stringify(payload); const headers: Record = { "Content-Type": "application/json", @@ -45,7 +89,7 @@ export async function deliverWebhook( for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + const timeoutId = setTimeout(() => controller.abort(), 10_000); const res = await fetch(url, { method: "POST", @@ -59,7 +103,6 @@ export async function deliverWebhook( return { success: res.ok, status: res.status }; } - // Server error — retry with exponential backoff if (attempt < maxRetries) { await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000)); } @@ -75,12 +118,18 @@ export async function deliverWebhook( } /** - * Dispatch an event to all matching enabled webhooks + * Dispatch an event to all matching enabled webhooks. + * Routes by kind: slack/discord use raw payload helpers; telegram decrypts botToken from metadata; + * custom uses HMAC-signed deliverWebhook. */ export async function dispatchEvent(event: WebhookEvent, data: Record): Promise { - // Lazy import to avoid circular deps const { getEnabledWebhooks, recordWebhookDelivery, disableWebhooksWithHighFailures } = await import("./db/webhooks"); + const { insertDelivery } = await import("./db/webhookDeliveries"); + const { buildSlackPayload } = await import("./webhooks/integrations/slack"); + const { buildTelegramUrl, buildTelegramPayload } = + await import("./webhooks/integrations/telegram"); + const { buildDiscordPayload } = await import("./webhooks/integrations/discord"); const webhooks = getEnabledWebhooks(); const payload: WebhookPayload = { @@ -90,18 +139,57 @@ export async function dispatchEvent(event: WebhookEvent, data: Record { - const events = wh.events; - return events.includes("*") || events.includes(event); - }) + .filter((wh) => wh.events.includes("*") || wh.events.includes(event)) .map(async (wh) => { - const result = await deliverWebhook(wh.url, payload, wh.secret); + const kind = wh.kind ?? "custom"; + const start = Date.now(); + let result: { success: boolean; status: number; error?: string }; + + try { + if (kind === "slack") { + const slackPayload = buildSlackPayload(event, data); + result = await deliverRaw(wh.url, slackPayload as Record); + } else if (kind === "discord") { + const discordPayload = buildDiscordPayload(event, data); + result = await deliverRaw(wh.url, discordPayload as Record); + } else if (kind === "telegram") { + const meta = decryptMetadata(wh.metadata_encrypted ?? null); + const botToken = meta?.botToken; + if (!botToken) { + result = { success: false, status: 0, error: "Missing Telegram botToken in metadata" }; + } else { + const apiUrl = buildTelegramUrl(botToken); + // For Telegram, wh.url stores the chat_id + const tgPayload = buildTelegramPayload(event, data, wh.url); + result = await deliverRaw(apiUrl, tgPayload as Record); + } + } else { + result = await deliverWebhook(wh.url, payload, wh.secret); + } + } catch (err: any) { + result = { success: false, status: 0, error: err.message || "Dispatch error" }; + } + + const latencyMs = Date.now() - start; + + try { + insertDelivery({ + webhookId: wh.id, + eventType: event, + status: result.success ? "success" : "failed", + httpStatus: result.status || null, + latencyMs, + error: result.error ?? null, + payloadSnapshot: kind === "custom" ? JSON.stringify(payload).slice(0, 2000) : null, + }); + } catch { + // Delivery logging is best-effort + } + recordWebhookDelivery(wh.id, result.status, result.success); return { webhookId: wh.id, ...result }; }); await Promise.allSettled(deliveries); - - // Auto-disable webhooks with too many failures disableWebhooksWithHighFailures(10); } diff --git a/src/lib/webhooks/eventDescriptions.ts b/src/lib/webhooks/eventDescriptions.ts new file mode 100644 index 000000000000..bac4e382c604 --- /dev/null +++ b/src/lib/webhooks/eventDescriptions.ts @@ -0,0 +1,85 @@ +export type WebhookEvent = + | "request.completed" + | "request.failed" + | "provider.error" + | "provider.recovered" + | "quota.exceeded" + | "combo.switched" + | "test.ping"; + +export interface EventDescription { + label: string; + description: string; + emoji: string; + exampleData: Record; +} + +export const EVENT_DESCRIPTIONS: Record = { + "request.completed": { + label: "Request Completed", + emoji: "✅", + description: "Triggered when an upstream request completes successfully (HTTP 2xx).", + exampleData: { + model: "claude-opus-4-7", + provider: "claude", + latencyMs: 1240, + tokensIn: 142, + tokensOut: 38, + }, + }, + "request.failed": { + label: "Request Failed", + emoji: "🚨", + description: "Triggered when a request fails after all retries and fallback combo targets.", + exampleData: { + model: "claude-opus-4-7", + provider: "claude", + error: "503 Service Unavailable", + attempts: 3, + }, + }, + "provider.error": { + label: "Provider Error", + emoji: "⚠️", + description: "A provider tripped the circuit breaker due to repeated failures.", + exampleData: { provider: "openai", model: "gpt-4o", errorCode: 503, consecutiveFailures: 3 }, + }, + "provider.recovered": { + label: "Provider Recovered", + emoji: "✅", + description: "A provider recovered from a circuit-breaker OPEN state.", + exampleData: { provider: "openai", recoveredAfterMs: 60000 }, + }, + "quota.exceeded": { + label: "Quota Exceeded", + emoji: "📊", + description: "A usage threshold (e.g. 95% of quota) was reached.", + exampleData: { quota: "daily_tokens", used: 950000, limit: 1000000, pct: 95 }, + }, + "combo.switched": { + label: "Combo Switched", + emoji: "🔄", + description: "Combo routing switched to a different target.", + exampleData: { + combo: "auto-fallback", + fromModel: "gpt-4o", + toModel: "claude-opus-4-7", + reason: "provider.error", + }, + }, + "test.ping": { + label: "Test Ping", + emoji: "🏓", + description: "Manual test delivery to verify your webhook is reachable.", + exampleData: { message: "Test ping from OmniRoute", webhookId: "preview" }, + }, +}; + +export function buildExamplePayload(event: WebhookEvent): Record { + return { + event, + webhook_id: "preview", + timestamp: new Date().toISOString(), + data: EVENT_DESCRIPTIONS[event].exampleData, + }; +} diff --git a/src/lib/webhooks/integrations/discord.ts b/src/lib/webhooks/integrations/discord.ts new file mode 100644 index 000000000000..0b3fdc4199ac --- /dev/null +++ b/src/lib/webhooks/integrations/discord.ts @@ -0,0 +1,49 @@ +import type { WebhookEvent } from "../eventDescriptions"; +import { EVENT_DESCRIPTIONS } from "../eventDescriptions"; + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + footer: { text: string }; +} + +export interface DiscordPayload { + content?: string; + embeds?: DiscordEmbed[]; +} + +const EVENT_COLORS: Partial> = { + "request.completed": 0x22c55e, + "request.failed": 0xef4444, + "provider.error": 0xf97316, + "provider.recovered": 0x22c55e, + "quota.exceeded": 0xeab308, + "combo.switched": 0x3b82f6, + "test.ping": 0x8b5cf6, +}; + +export function buildDiscordPayload( + event: WebhookEvent, + data: Record +): DiscordPayload { + const desc = EVENT_DESCRIPTIONS[event]; + const model = typeof data.model === "string" ? data.model : null; + const error = typeof data.error === "string" ? data.error : null; + + const lines: string[] = []; + if (model) lines.push(`**Model:** \`${model}\``); + if (error) lines.push(`**Error:** \`${error}\``); + if (lines.length === 0) lines.push(desc.description); + + return { + embeds: [ + { + title: `${desc.emoji} ${desc.label}`, + description: lines.join("\n"), + color: EVENT_COLORS[event] ?? 0x6366f1, + footer: { text: `OmniRoute · ${new Date().toISOString()}` }, + }, + ], + }; +} diff --git a/src/lib/webhooks/integrations/slack.ts b/src/lib/webhooks/integrations/slack.ts new file mode 100644 index 000000000000..2de7efce27b7 --- /dev/null +++ b/src/lib/webhooks/integrations/slack.ts @@ -0,0 +1,45 @@ +import type { WebhookEvent } from "../eventDescriptions"; +import { EVENT_DESCRIPTIONS } from "../eventDescriptions"; + +export interface SlackPayload { + text: string; + blocks?: unknown[]; +} + +export function buildSlackPayload( + event: WebhookEvent, + data: Record +): SlackPayload { + const desc = EVENT_DESCRIPTIONS[event]; + const model = typeof data.model === "string" ? data.model : null; + const provider = typeof data.provider === "string" ? data.provider : null; + const error = typeof data.error === "string" ? data.error : null; + + const titleParts = [`${desc.emoji} *${desc.label}*`]; + if (model) titleParts.push(`on \`${model}\``); + + const lines: string[] = [titleParts.join(" ")]; + if (error) lines.push(`*Error:* \`${error}\``); + if (provider && !model) lines.push(`*Provider:* ${provider}`); + + const text = lines.join("\n"); + + return { + text, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `OmniRoute · ${new Date().toISOString()}`, + }, + ], + }, + ], + }; +} diff --git a/src/lib/webhooks/integrations/telegram.ts b/src/lib/webhooks/integrations/telegram.ts new file mode 100644 index 000000000000..90d5ea5a5b30 --- /dev/null +++ b/src/lib/webhooks/integrations/telegram.ts @@ -0,0 +1,47 @@ +import type { WebhookEvent } from "../eventDescriptions"; +import { EVENT_DESCRIPTIONS } from "../eventDescriptions"; + +export interface TelegramSendMessagePayload { + chat_id: string; + text: string; + parse_mode: "Markdown"; +} + +function escapeMd(s: string): string { + // Escape Telegram Markdown v1 special chars: _ * [ ] ` + // Hyphens and parentheses are safe in Markdown v1 + return s.replace(/[_*[\]`]/g, (c) => `\\${c}`); +} + +// Telegram bot token format: : (min 35 chars after colon) +const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/; + +export function buildTelegramUrl(botToken: string): string { + if (!BOT_TOKEN_RE.test(botToken)) { + throw new Error("Invalid Telegram bot token format (expected :)"); + } + return `https://api.telegram.org/bot${botToken}/sendMessage`; +} + +export function buildTelegramPayload( + event: WebhookEvent, + data: Record, + chatId: string +): TelegramSendMessagePayload { + const desc = EVENT_DESCRIPTIONS[event]; + const model = typeof data.model === "string" ? escapeMd(data.model) : null; + const error = typeof data.error === "string" ? escapeMd(data.error) : null; + const provider = typeof data.provider === "string" ? escapeMd(data.provider) : null; + + const lines: string[] = [`${desc.emoji} *${desc.label}*`]; + if (model) lines.push(`Model: \`${model}\``); + if (provider && !model) lines.push(`Provider: \`${provider}\``); + if (error) lines.push(`Error: \`${error}\``); + lines.push(`_OmniRoute · ${new Date().toISOString()}_`); + + return { + chat_id: chatId, + text: lines.join("\n"), + parse_mode: "Markdown", + }; +} diff --git a/tests/unit/webhook-deliveries-db.test.ts b/tests/unit/webhook-deliveries-db.test.ts new file mode 100644 index 000000000000..a57708e9f3aa --- /dev/null +++ b/tests/unit/webhook-deliveries-db.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-deliveries-")); +process.env.DATA_DIR = TEST_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const webhooksDb = await import("../../src/lib/db/webhooks.ts"); +const deliveriesDb = await import("../../src/lib/db/webhookDeliveries.ts"); + +async function resetStorage() { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +test("insertDelivery stores a row and getDeliveries returns it", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "request.failed", + status: "delivered", + httpStatus: 200, + latencyMs: 142, + payloadSnapshot: JSON.stringify({ event: "request.failed" }), + }); + const rows = deliveriesDb.getDeliveries(wh.id, 10); + assert.equal(rows.length, 1); + assert.equal(rows[0].event_type, "request.failed"); + assert.equal(rows[0].http_status, 200); + assert.equal(rows[0].latency_ms, 142); + assert.equal(rows[0].status, "delivered"); +}); + +test("rotation keeps only last 100 deliveries per webhook", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + for (let i = 0; i < 105; i++) { + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "test.ping", + status: "delivered", + httpStatus: 200, + latencyMs: 10, + }); + } + const rows = deliveriesDb.getDeliveries(wh.id, 200); + assert.equal(rows.length, 100); +}); + +test("getDeliveries respects limit parameter", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + for (let i = 0; i < 10; i++) { + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "test.ping", + status: "delivered", + httpStatus: 200, + latencyMs: 10, + }); + } + const rows = deliveriesDb.getDeliveries(wh.id, 5); + assert.equal(rows.length, 5); +}); + +test("CASCADE delete removes deliveries when webhook is deleted", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "test.ping", + status: "delivered", + httpStatus: 200, + latencyMs: 10, + }); + webhooksDb.deleteWebhook(wh.id); + const rows = deliveriesDb.getDeliveries(wh.id, 10); + assert.equal(rows.length, 0); +}); + +test("createWebhook uses kind=custom by default", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + assert.equal(wh.kind, "custom"); + assert.equal(wh.metadata_encrypted, null); +}); + +test("createWebhook accepts kind=slack", () => { + const wh = webhooksDb.createWebhook({ + url: "https://hooks.slack.com/services/T/B/xxx", + kind: "slack", + }); + assert.equal(wh.kind, "slack"); +}); + +test("updateWebhook can change kind and metadataEncrypted", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + const updated = webhooksDb.updateWebhook(wh.id, { + kind: "telegram", + metadataEncrypted: "enc:v1:test", + }); + assert.equal(updated?.kind, "telegram"); + assert.equal(updated?.metadata_encrypted, "enc:v1:test"); +}); diff --git a/tests/unit/webhook-discord-dispatcher.test.ts b/tests/unit/webhook-discord-dispatcher.test.ts new file mode 100644 index 000000000000..4a49c3ba2ef0 --- /dev/null +++ b/tests/unit/webhook-discord-dispatcher.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildDiscordPayload } = await import("../../src/lib/webhooks/integrations/discord.ts"); + +test("buildDiscordPayload — request.failed produces embed with model", () => { + const payload = buildDiscordPayload("request.failed", { + model: "claude-opus-4-7", + error: "503", + }); + assert.ok(payload.content || payload.embeds, "should have content or embeds"); + const combined = JSON.stringify(payload); + assert.ok(combined.includes("claude-opus-4-7"), "should include model name"); +}); + +test("buildDiscordPayload — all WEBHOOK_EVENTS return object with content or embeds", () => { + const events = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", + ] as const; + for (const event of events) { + const payload = buildDiscordPayload(event, {}); + assert.ok( + payload.content || (Array.isArray(payload.embeds) && payload.embeds.length > 0), + `event ${event} must have content or embeds` + ); + } +}); + +test("buildDiscordPayload — embeds have title and color fields", () => { + const payload = buildDiscordPayload("provider.error", { provider: "openai" }); + assert.ok(Array.isArray(payload.embeds) && payload.embeds.length > 0, "should have embeds"); + const embed = payload.embeds![0]; + assert.ok(typeof embed.title === "string" && embed.title.length > 0, "embed must have title"); + assert.ok(typeof embed.color === "number", "embed must have color"); +}); diff --git a/tests/unit/webhook-dispatcher-routing.test.ts b/tests/unit/webhook-dispatcher-routing.test.ts new file mode 100644 index 000000000000..13302811cdc5 --- /dev/null +++ b/tests/unit/webhook-dispatcher-routing.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { encryptMetadata, decryptMetadata } = await import("../../src/lib/webhookDispatcher.ts"); + +test("encryptMetadata roundtrips without encryption key (passthrough mode)", () => { + const original = { botToken: "123456:ABC-DEF", extra: "value" }; + const encrypted = encryptMetadata(original); + assert.ok(typeof encrypted === "string" && encrypted.length > 0); + const decrypted = decryptMetadata(encrypted); + assert.deepEqual(decrypted, original); +}); + +test("decryptMetadata returns null for null input", () => { + const result = decryptMetadata(null); + assert.equal(result, null); +}); + +test("decryptMetadata returns null for malformed JSON", () => { + // Passthrough mode stores plaintext — inject bad JSON + const result = decryptMetadata("not-valid-json"); + assert.equal(result, null); +}); + +test("encryptMetadata produces stable parseable JSON in passthrough mode", () => { + const meta = { botToken: "tok", chatId: "-100123" }; + const enc = encryptMetadata(meta); + const dec = decryptMetadata(enc); + assert.equal(dec?.botToken, "tok"); + assert.equal(dec?.chatId, "-100123"); +}); diff --git a/tests/unit/webhook-slack-dispatcher.test.ts b/tests/unit/webhook-slack-dispatcher.test.ts new file mode 100644 index 000000000000..44b34626cf28 --- /dev/null +++ b/tests/unit/webhook-slack-dispatcher.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildSlackPayload } = await import("../../src/lib/webhooks/integrations/slack.ts"); + +test("buildSlackPayload — request.failed produces Block Kit with model in title", () => { + const payload = buildSlackPayload("request.failed", { + model: "claude-opus-4-7", + error: "503 Service Unavailable", + attempts: 3, + }); + assert.ok(payload.blocks, "should have blocks array"); + const sectionText = JSON.stringify(payload.blocks); + assert.ok(sectionText.includes("claude-opus-4-7"), "should include model name"); + assert.ok( + sectionText.toLowerCase().includes("request failed") || + payload.text.toLowerCase().includes("request failed"), + "should include event label" + ); +}); + +test("buildSlackPayload — test.ping produces a ping/test message", () => { + const payload = buildSlackPayload("test.ping", { message: "Test ping from OmniRoute" }); + const combined = JSON.stringify(payload); + assert.ok( + combined.toLowerCase().includes("ping") || + combined.toLowerCase().includes("test") || + combined.toLowerCase().includes("🏓"), + "should reference test/ping" + ); +}); + +test("buildSlackPayload — provider.error includes provider context", () => { + const payload = buildSlackPayload("provider.error", { provider: "openai", model: "gpt-4" }); + const combined = JSON.stringify(payload); + assert.ok( + combined.includes("Provider") || + combined.includes("provider") || + combined.includes("error") || + combined.includes("⚠️"), + "should reference provider/error" + ); +}); + +test("buildSlackPayload — all WEBHOOK_EVENTS produce valid payloads with text field", () => { + const events = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", + ] as const; + for (const event of events) { + const payload = buildSlackPayload(event, {}); + assert.ok( + typeof payload.text === "string" && payload.text.length > 0, + `event ${event} must produce non-empty text` + ); + } +}); diff --git a/tests/unit/webhook-telegram-dispatcher.test.ts b/tests/unit/webhook-telegram-dispatcher.test.ts new file mode 100644 index 000000000000..6d717b2d2158 --- /dev/null +++ b/tests/unit/webhook-telegram-dispatcher.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildTelegramPayload, buildTelegramUrl } = + await import("../../src/lib/webhooks/integrations/telegram.ts"); + +const VALID_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"; + +test("buildTelegramUrl constructs correct API URL from valid botToken", () => { + const url = buildTelegramUrl(VALID_TOKEN); + assert.equal(url, `https://api.telegram.org/bot${VALID_TOKEN}/sendMessage`); +}); + +test("buildTelegramUrl throws on invalid botToken format", () => { + assert.throws(() => buildTelegramUrl("123456:SHORT"), /Invalid Telegram bot token/); + assert.throws( + () => buildTelegramUrl("notanumber:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"), + /Invalid Telegram bot token/ + ); + assert.throws(() => buildTelegramUrl(""), /Invalid Telegram bot token/); +}); + +test("buildTelegramPayload — request.failed includes model and event label", () => { + const payload = buildTelegramPayload( + "request.failed", + { model: "claude-opus-4-7", error: "503" }, + "-100123" + ); + assert.equal(payload.chat_id, "-100123"); + assert.ok(payload.text.includes("claude-opus-4-7"), "should include model name"); + assert.ok( + payload.text.toLowerCase().includes("request failed") || + payload.text.toLowerCase().includes("failed"), + "should include event label" + ); + assert.equal(payload.parse_mode, "Markdown"); +}); + +test("buildTelegramPayload — chat_id matches provided value for groups", () => { + const payload = buildTelegramPayload("test.ping", { message: "ping" }, "-1001234567890"); + assert.equal(payload.chat_id, "-1001234567890"); +}); + +test("buildTelegramPayload — all WEBHOOK_EVENTS produce valid payloads with chat_id", () => { + const events = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", + ] as const; + for (const event of events) { + const payload = buildTelegramPayload(event, {}, "99999"); + assert.equal(payload.chat_id, "99999"); + assert.ok( + typeof payload.text === "string" && payload.text.length > 0, + `event ${event} must produce non-empty text` + ); + } +}); diff --git a/vitest.config.ts b/vitest.config.ts index 95d3dc286f71..e64af80a717b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ include: [ "src/app/**/dashboard/cache/__tests__/**/*.test.tsx", "src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx", + "src/app/**/dashboard/webhooks/__tests__/**/*.test.tsx", "src/shared/hooks/__tests__/**/*.test.tsx", "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts",