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 b4ccfc00e8
commit d52add6f8c
7 changed files with 414 additions and 7 deletions

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,
};
}
}