feat: add Gotify as a notification provider (#337)

Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
This commit is contained in:
云与原
2026-07-16 22:20:15 +05:30
committed by GitHub
parent 97d98b82c6
commit 204922570d
8 changed files with 308 additions and 3 deletions
+88 -1
View File
@@ -93,7 +93,7 @@ export function NotificationSettings({
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onValueChange={(value: "ntfy" | "apprise" | "gotify") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
@@ -103,6 +103,7 @@ export function NotificationSettings({
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
</SelectContent>
</Select>
</div>
@@ -307,6 +308,92 @@ export function NotificationSettings({
</div>
)}
{/* Gotify configuration */}
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Gotify Settings</h3>
<div className="space-y-2">
<Label htmlFor="gotify-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Gotify server
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-token" className="text-sm">
Application token <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Create an application in Gotify and paste its token here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-priority" className="text-sm">
Default priority (0-10)
</Label>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
<p className="text-xs text-muted-foreground">
Error notifications always use priority 8 regardless of this setting
</p>
</div>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
+8 -1
View File
@@ -138,14 +138,21 @@ export const appriseConfigSchema = z.object({
tag: z.string().optional(),
});
export const gotifyConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
priority: z.number().int().min(0).max(10).default(5),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
gotify: gotifyConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
+49
View File
@@ -71,6 +71,32 @@ describe("sendNotification", () => {
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("sends gotify notification when provider is gotify", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "my-app-token",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
@@ -140,6 +166,29 @@ describe("sendNotification", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when gotify token is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
+18
View File
@@ -2,6 +2,7 @@ import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { sendGotifyNotification } from "./providers/gotify";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
@@ -52,6 +53,12 @@ export async function sendNotification(
return;
}
await sendAppriseNotification(config.apprise, event);
} else if (config.provider === "gotify") {
if (!config.gotify?.url || !config.gotify?.token) {
console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification");
return;
}
await sendGotifyNotification(config.gotify, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
@@ -83,6 +90,11 @@ export async function testNotification(
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else if (notificationConfig.provider === "gotify") {
if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) {
return { success: false, error: "Gotify URL and token are required" };
}
await sendGotifyNotification(notificationConfig.gotify, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
@@ -166,6 +178,12 @@ export async function triggerJobNotification({
token: decrypt(decryptedConfig.apprise.token),
};
}
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
decryptedConfig.gotify = {
...decryptedConfig.gotify,
token: decrypt(decryptedConfig.gotify.token),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
+106
View File
@@ -0,0 +1,106 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendGotifyNotification } from "./gotify";
import type { NotificationEvent } from "./ntfy";
import type { GotifyConfig } from "@/types/config";
describe("sendGotifyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: GotifyConfig = {
url: "https://gotify.example.com",
token: "AbCdEf123456",
priority: 5,
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("strips trailing slash from URL", async () => {
await sendGotifyNotification(
{ ...baseConfig, url: "https://gotify.example.com/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("sends token via X-Gotify-Key header", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456");
});
test("sends title and message in JSON body", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.message).toBe("This is a test");
});
test("uses priority 8 for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendGotifyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(8);
});
test("uses config priority for non-error events", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: 2 },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(2);
});
test("defaults to priority 5 when not configured", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: undefined as any },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(5);
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("unauthorized", { status: 401 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendGotifyNotification(baseConfig, baseEvent)
).rejects.toThrow("Gotify error: 401");
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { GotifyConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/message`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Gotify-Key": config.token,
};
const body = JSON.stringify({
title: event.title,
message: event.message,
priority: event.type === "sync_error" ? 8 : (config.priority ?? 5),
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`);
}
+14
View File
@@ -172,6 +172,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
token: encrypt(processedNotificationConfig.apprise.token),
};
}
// Encrypt gotify token if present
if (processedNotificationConfig.gotify?.token) {
processedNotificationConfig.gotify = {
...processedNotificationConfig.gotify,
token: encrypt(processedNotificationConfig.gotify.token),
};
}
}
if (existingConfig) {
@@ -354,6 +361,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
if (notificationConfig.gotify?.token) {
try {
notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) };
} catch {
notificationConfig.gotify = { ...notificationConfig.gotify, token: "" };
}
}
}
return new Response(JSON.stringify({
+8 -1
View File
@@ -119,14 +119,21 @@ export interface AppriseConfig {
tag?: string;
}
export interface GotifyConfig {
url: string;
token: string;
priority: number;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
provider: "ntfy" | "apprise" | "gotify";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
gotify?: GotifyConfig;
}
export interface Config extends ConfigType {}