mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-01 08:08:10 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()}`);
|
||||
}
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user