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:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
174
packages/engine/src/notification/webhook-provider.ts
Normal file
174
packages/engine/src/notification/webhook-provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user