feat(FN-2878): add webhook notification provider support

- Add webhook settings fields and defaults for enablement, URL, format, and event filtering
- Implement WebhookNotificationProvider with payload formatting support for generic, Slack, and Discord endpoints
- Extend NotificationService to manage both ntfy and webhook providers with live settings sync
- Export webhook notification types/providers through engine notification entry points
This commit is contained in:
Fusion
2026-04-28 09:55:20 -07:00
committed by gsxdsm
parent 236f947907
commit 8d606b45c5
7 changed files with 414 additions and 7 deletions

View File

@@ -24,6 +24,10 @@ export const DEFAULT_GLOBAL_SETTINGS = {
ntfyBaseUrl: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", "gridlock"],
ntfyDashboardHost: undefined,
webhookEnabled: false,
webhookUrl: undefined,
webhookFormat: "generic",
webhookEvents: [],
notificationProviders: [],
defaultProjectId: undefined,
setupComplete: undefined,

View File

@@ -1117,6 +1117,23 @@ export interface GlobalSettings {
* ?project=<id>&task=<id> so the dashboard opens the correct project first.
* Example: "http://localhost:3000" or "https://fusion.example.com" */
ntfyDashboardHost?: string;
/** When true, enables webhook notifications for task lifecycle events.
* Requires webhookUrl to be set. Default: false. */
webhookEnabled?: boolean;
/** URL to send webhook notifications to.
* Must be an http:// or https:// URL. */
webhookUrl?: string;
/** Format of the webhook payload.
* - "slack": Slack incoming webhook format ({ text: message })
* - "discord": Discord webhook format ({ content: message })
* - "generic": Structured JSON with event/task/timestamp fields
* Default: "generic". */
webhookFormat?: "slack" | "discord" | "generic";
/** List of notification events to send via webhook.
* When webhookEnabled is true, only events in this list trigger webhooks.
* If undefined or empty when webhookEnabled is true, all events are sent.
* Default: [] (all events). */
webhookEvents?: string[];
/** Pluggable notification providers configuration. Additive to legacy ntfy
* settings so existing ntfy configuration continues working unchanged. */
notificationProviders?: NotificationProviderConfig[];

View File

@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WebhookNotificationProvider } from "../notification/webhook-provider.js";
describe("WebhookNotificationProvider", () => {
let provider: WebhookNotificationProvider;
const fetchMock = vi.fn();
beforeEach(() => {
provider = new WebhookNotificationProvider();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
it("getProviderId returns webhook", () => {
expect(provider.getProviderId()).toBe("webhook");
});
it("initialize stores config", async () => {
await expect(
provider.initialize({
webhookUrl: "https://example.com/hook",
webhookFormat: "slack",
events: ["in-review"],
}),
).resolves.toBeUndefined();
expect(provider.isEventSupported("in-review")).toBe(true);
expect(provider.isEventSupported("failed")).toBe(false);
});
it("initialize rejects missing URL", async () => {
await expect(provider.initialize({ webhookFormat: "slack" })).rejects.toThrow("webhookUrl is required");
});
it("sendNotification formats Slack correctly", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/hook",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(requestInit.body));
expect(body.text).toContain("My Task");
});
it("sendNotification formats Discord correctly", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "discord" });
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(requestInit.body));
expect(body.content).toContain("My Task");
});
it("sendNotification formats Generic correctly", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const payload = JSON.parse(String(requestInit.body));
expect(payload.event).toBe("in-review");
expect(payload.timestamp).toEqual(expect.any(String));
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
});
it("sendNotification returns success on HTTP 200", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
await expect(
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
).resolves.toEqual({ success: true, providerId: "webhook" });
});
it("sendNotification returns failure on HTTP error", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Internal Server Error" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
await expect(
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
).resolves.toEqual({ success: false, providerId: "webhook", error: expect.stringContaining("500") });
});
it("sendNotification returns failure on network error", async () => {
fetchMock.mockRejectedValue(new TypeError("network error"));
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
await expect(
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
).resolves.toEqual({ success: false, providerId: "webhook", error: expect.any(String) });
});
it("isEventSupported returns true when no filter", async () => {
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic", events: [] });
expect(provider.isEventSupported("in-review")).toBe(true);
expect(provider.isEventSupported("custom-event")).toBe(true);
});
it("isEventSupported filters by configured events", async () => {
await provider.initialize({
webhookUrl: "https://example.com/hook",
webhookFormat: "generic",
events: ["in-review", "merged"],
});
expect(provider.isEventSupported("in-review")).toBe(true);
expect(provider.isEventSupported("failed")).toBe(false);
});
it("shutdown aborts requests", async () => {
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
await provider.shutdown();
await expect(
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
).resolves.toEqual({ success: false, providerId: "webhook", error: "Not initialized" });
});
it.each([
["in-review", "ready for review"],
["merged", "has been merged to main"],
["failed", "has failed and needs attention"],
["awaiting-approval", "needs your approval before it can proceed"],
["awaiting-user-review", "needs human review before it can proceed"],
["planning-awaiting-input", "is awaiting your input during planning"],
["gridlock", "Pipeline gridlocked"],
["unknown-event", 'Event "unknown-event" for task My Task'],
])("message formatting for %s", async (event, expectedPart) => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
await provider.sendNotification(event, { taskId: "FN-1", taskTitle: "My Task", event });
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(requestInit.body));
expect(body.text).toContain(expectedPart);
});
it("title fallback uses taskId and truncated description snippet", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
const description = "a".repeat(220);
await provider.sendNotification("failed", { taskId: "FN-1", taskDescription: description, event: "failed" });
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(requestInit.body));
expect(body.text).toContain('Task "FN-1:');
expect(body.text).toContain("...");
});
});

View File

@@ -66,8 +66,8 @@ export {
type SendNtfyNotificationInput,
} from "./notifier.js";
// ── Notification Service ──────────────────────────────────────
export { NtfyNotificationProvider, NotificationService } from "./notification/index.js";
export type { NtfyProviderConfig, NotificationServiceOptions } from "./notification/index.js";
export { NtfyNotificationProvider, NotificationService, WebhookNotificationProvider } from "./notification/index.js";
export type { NtfyProviderConfig, NotificationServiceOptions, WebhookProviderConfig } from "./notification/index.js";
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
export { RoutineRunner, type RoutineRunnerOptions } from "./routine-runner.js";
export { RoutineScheduler, type RoutineSchedulerOptions } from "./routine-scheduler.js";

View File

@@ -1,5 +1,8 @@
export { NtfyNotificationProvider } from "./ntfy-provider.js";
export type { NtfyProviderConfig } from "./ntfy-provider.js";
export { WebhookNotificationProvider } from "./webhook-provider.js";
export type { WebhookProviderConfig } from "./webhook-provider.js";
export { NotificationService } from "./notification-service.js";
export type { NotificationServiceOptions } from "./notification-service.js";

View File

@@ -11,6 +11,7 @@ import { NotificationDispatcher } from "@fusion/core";
import { DEFAULT_NTFY_EVENTS } from "../notifier.js";
import { schedulerLog } from "../logger.js";
import { NtfyNotificationProvider } from "./ntfy-provider.js";
import { WebhookNotificationProvider } from "./webhook-provider.js";
export interface NotificationServiceOptions {
/** Project identifier for notification deep links */
@@ -29,8 +30,9 @@ export class NotificationService {
private readonly dispatcher = new NotificationDispatcher();
private readonly notifiedEvents = new Set<string>();
private started = false;
private ntfyEnabled = false;
private notificationsEnabled = false;
private ntfyProvider?: NtfyNotificationProvider;
private webhookProvider?: WebhookNotificationProvider;
constructor(
private readonly store: NotificationServiceStore,
@@ -47,7 +49,9 @@ export class NotificationService {
}
const settings = await this.store.getSettings();
this.setNotificationsEnabledFromSettings(settings);
await this.syncNtfyProvider(settings);
await this.syncWebhookProvider(settings);
await this.dispatcher.initializeAll();
@@ -79,7 +83,7 @@ export class NotificationService {
}
private handleTaskMoved = (data: { task: Task; from: Column; to: Column }): void => {
if (!this.ntfyEnabled || data.to !== "in-review") {
if (!this.notificationsEnabled || data.to !== "in-review") {
return;
}
@@ -88,7 +92,7 @@ export class NotificationService {
};
private handleTaskUpdated = (task: Task): void => {
if (!this.ntfyEnabled) {
if (!this.notificationsEnabled) {
return;
}
@@ -114,7 +118,7 @@ export class NotificationService {
};
private handleTaskMerged = (result: MergeResult): void => {
if (!this.ntfyEnabled || !result.merged) {
if (!this.notificationsEnabled || !result.merged) {
return;
}
@@ -127,6 +131,7 @@ export class NotificationService {
private handleSettingsUpdated = async (data: { settings: Settings; previous: Settings }): Promise<void> => {
const { settings, previous } = data;
this.setNotificationsEnabledFromSettings(settings);
if (
settings.ntfyEnabled !== previous.ntfyEnabled ||
@@ -154,11 +159,20 @@ export class NotificationService {
schedulerLog.log("NotificationService ntfy events updated");
}
}
if (
settings.webhookEnabled !== previous.webhookEnabled ||
settings.webhookUrl !== previous.webhookUrl ||
settings.webhookFormat !== previous.webhookFormat ||
JSON.stringify(settings.webhookEvents) !== JSON.stringify(previous.webhookEvents)
) {
await this.syncWebhookProvider(settings);
schedulerLog.log("WebhookNotificationProvider config updated");
}
};
private async syncNtfyProvider(settings: Settings): Promise<void> {
const enabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
this.ntfyEnabled = enabled;
if (!enabled) {
if (this.ntfyProvider) {
@@ -183,6 +197,37 @@ export class NotificationService {
});
}
private async syncWebhookProvider(settings: Settings): Promise<void> {
const enabled = Boolean(settings.webhookEnabled && settings.webhookUrl);
if (!enabled) {
if (this.webhookProvider) {
await this.webhookProvider.shutdown?.();
this.dispatcher.unregisterProvider(this.webhookProvider.getProviderId());
this.webhookProvider = undefined;
}
return;
}
if (!this.webhookProvider) {
this.webhookProvider = new WebhookNotificationProvider();
this.registerProvider(this.webhookProvider);
}
await this.webhookProvider.initialize?.({
webhookUrl: settings.webhookUrl,
webhookFormat: settings.webhookFormat ?? "generic",
events: settings.webhookEvents ?? [],
});
}
private setNotificationsEnabledFromSettings(settings: Settings): void {
this.notificationsEnabled = Boolean(
(settings.ntfyEnabled && settings.ntfyTopic) ||
(settings.webhookEnabled && settings.webhookUrl),
);
}
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
return {
taskId: task.id,

View File

@@ -0,0 +1,174 @@
import type {
NotificationEvent,
NotificationPayload,
NotificationProvider,
NotificationResult,
} from "@fusion/core";
import { schedulerLog } from "../logger.js";
export interface WebhookProviderConfig {
/** Webhook endpoint URL */
webhookUrl: string;
/** Payload format: slack, discord, or generic */
webhookFormat: "slack" | "discord" | "generic";
/** Events to send (empty = all events) */
events?: string[];
}
export class WebhookNotificationProvider implements NotificationProvider {
private config: WebhookProviderConfig | null = null;
private abortController: AbortController | null = null;
getProviderId(): string {
return "webhook";
}
async initialize(config: Record<string, unknown>): Promise<void> {
const webhookUrl = typeof config.webhookUrl === "string" ? config.webhookUrl.trim() : "";
if (!webhookUrl) {
throw new Error("webhookUrl is required");
}
let parsedUrl: URL;
try {
parsedUrl = new URL(webhookUrl);
} catch {
throw new Error("webhookUrl must be a valid URL");
}
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error("webhookUrl must use http:// or https://");
}
const webhookFormat =
config.webhookFormat === "slack" || config.webhookFormat === "discord" || config.webhookFormat === "generic"
? config.webhookFormat
: "generic";
this.config = {
webhookUrl,
webhookFormat,
events: Array.isArray(config.events) ? config.events.filter((event): event is string => typeof event === "string") : [],
};
this.abortController?.abort();
this.abortController = new AbortController();
}
async shutdown(): Promise<void> {
this.abortController?.abort();
this.abortController = null;
this.config = null;
}
isEventSupported(event: NotificationEvent): boolean {
if (!this.config?.events || this.config.events.length === 0) {
return true;
}
return this.config.events.includes(event);
}
async sendNotification(event: NotificationEvent, payload: NotificationPayload): Promise<NotificationResult> {
if (!this.config) {
return { success: false, providerId: this.getProviderId(), error: "Not initialized" };
}
if (!this.isEventSupported(event)) {
return {
success: false,
providerId: this.getProviderId(),
error: `unsupported event: ${event}`,
};
}
try {
const message = this.formatMessage(event, payload);
const body = this.formatPayload(payload, message);
const response = await fetch(this.config.webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: this.abortController?.signal,
});
if (!response.ok) {
const error = `Webhook notification failed: ${response.status} ${response.statusText}`;
schedulerLog.log(error);
return {
success: false,
providerId: this.getProviderId(),
error,
};
}
return { success: true, providerId: this.getProviderId() };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
schedulerLog.log(`Failed to send webhook notification: ${message}`);
return {
success: false,
providerId: this.getProviderId(),
error: message,
};
}
}
private formatMessage(event: NotificationEvent, payload: NotificationPayload): string {
const identifier = this.formatTaskIdentifier(payload);
switch (event) {
case "in-review":
return `Task "${identifier}" is ready for review`;
case "merged":
return `Task "${identifier}" has been merged to main`;
case "failed":
return `Task "${identifier}" has failed and needs attention`;
case "awaiting-approval":
return `Task "${identifier}" needs your approval before it can proceed`;
case "awaiting-user-review":
return `Task "${identifier}" needs human review before it can proceed`;
case "planning-awaiting-input":
return `Task "${identifier}" is awaiting your input during planning`;
case "gridlock":
return "Pipeline gridlocked";
default:
return `Event "${event}" for task ${identifier}`;
}
}
private formatTaskIdentifier(payload: NotificationPayload): string {
if (payload.taskTitle?.trim()) {
return payload.taskTitle;
}
const description = payload.taskDescription ?? "";
const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
return `${payload.taskId}: ${snippet}`;
}
private formatPayload(payload: NotificationPayload, message: string): Record<string, unknown> {
if (!this.config) {
return {};
}
if (this.config.webhookFormat === "slack") {
return { text: message };
}
if (this.config.webhookFormat === "discord") {
return { content: message };
}
return {
event: payload.event,
timestamp: new Date().toISOString(),
task: {
id: payload.taskId,
title: payload.taskTitle,
},
metadata: payload.metadata,
};
}
}