@checkstack/notification-gotify-backend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +25 -0
- package/src/index.test.ts +181 -0
- package/src/index.ts +221 -0
- package/src/plugin-metadata.ts +5 -0
- package/tsconfig.json +3 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @checkstack/notification-gotify-backend
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cf5f245: Added Gotify notification provider for self-hosted push notifications. Features include priority mapping (info→5, warning→7, critical→10), action URL extras, and configurable server URL.
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/notification-gotify-backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./plugin-metadata": "./src/plugin-metadata.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"lint": "eslint src --ext .ts",
|
|
13
|
+
"test": "bun test"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@checkstack/backend-api": "workspace:*",
|
|
17
|
+
"@checkstack/common": "workspace:*",
|
|
18
|
+
"@checkstack/notification-backend": "workspace:*",
|
|
19
|
+
"zod": "^4.2.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
23
|
+
"typescript": "^5.7.2"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, spyOn } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
gotifyConfigSchemaV1,
|
|
4
|
+
gotifyUserConfigSchema,
|
|
5
|
+
mapImportanceToPriority,
|
|
6
|
+
} from "./index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unit tests for the Gotify Notification Strategy.
|
|
10
|
+
*
|
|
11
|
+
* Tests cover:
|
|
12
|
+
* - Config schema validation
|
|
13
|
+
* - Priority mapping
|
|
14
|
+
* - REST API interaction
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe("Gotify Notification Strategy", () => {
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Config Schema Validation
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe("config schema", () => {
|
|
23
|
+
it("validates admin config - requires serverUrl", () => {
|
|
24
|
+
expect(() => {
|
|
25
|
+
gotifyConfigSchemaV1.parse({});
|
|
26
|
+
}).toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("validates admin config - requires valid URL", () => {
|
|
30
|
+
expect(() => {
|
|
31
|
+
gotifyConfigSchemaV1.parse({ serverUrl: "not-a-url" });
|
|
32
|
+
}).toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts valid admin config", () => {
|
|
36
|
+
const result = gotifyConfigSchemaV1.parse({
|
|
37
|
+
serverUrl: "https://gotify.example.com",
|
|
38
|
+
});
|
|
39
|
+
expect(result.serverUrl).toBe("https://gotify.example.com");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("validates user config - requires appToken", () => {
|
|
43
|
+
expect(() => {
|
|
44
|
+
gotifyUserConfigSchema.parse({});
|
|
45
|
+
}).toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("accepts valid user config", () => {
|
|
49
|
+
const result = gotifyUserConfigSchema.parse({
|
|
50
|
+
appToken: "A-secret-token-123",
|
|
51
|
+
});
|
|
52
|
+
expect(result.appToken).toBe("A-secret-token-123");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Priority Mapping
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe("priority mapping", () => {
|
|
61
|
+
it("maps info to normal priority (5)", () => {
|
|
62
|
+
expect(mapImportanceToPriority("info")).toBe(5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("maps warning to high-normal priority (7)", () => {
|
|
66
|
+
expect(mapImportanceToPriority("warning")).toBe(7);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("maps critical to highest priority (10)", () => {
|
|
70
|
+
expect(mapImportanceToPriority("critical")).toBe(10);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// REST API Interaction
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("REST API interaction", () => {
|
|
79
|
+
it("sends message to Gotify server with token", async () => {
|
|
80
|
+
let capturedBody: string | undefined;
|
|
81
|
+
let capturedUrl: string | undefined;
|
|
82
|
+
|
|
83
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
84
|
+
url: RequestInfo | URL,
|
|
85
|
+
options?: RequestInit,
|
|
86
|
+
) => {
|
|
87
|
+
capturedUrl = url.toString();
|
|
88
|
+
capturedBody = options?.body as string;
|
|
89
|
+
return new Response(JSON.stringify({ id: 42 }), { status: 200 });
|
|
90
|
+
}) as unknown as typeof fetch);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const serverUrl = "https://gotify.example.com";
|
|
94
|
+
const appToken = "test-token";
|
|
95
|
+
|
|
96
|
+
await fetch(`${serverUrl}/message?token=${appToken}`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
title: "Test Alert",
|
|
101
|
+
message: "Test message body",
|
|
102
|
+
priority: 5,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(capturedUrl).toContain("gotify.example.com/message");
|
|
107
|
+
expect(capturedUrl).toContain("token=test-token");
|
|
108
|
+
|
|
109
|
+
const parsedBody = JSON.parse(capturedBody!);
|
|
110
|
+
expect(parsedBody.title).toBe("Test Alert");
|
|
111
|
+
expect(parsedBody.message).toBe("Test message body");
|
|
112
|
+
expect(parsedBody.priority).toBe(5);
|
|
113
|
+
} finally {
|
|
114
|
+
mockFetch.mockRestore();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("includes extras for action URL", async () => {
|
|
119
|
+
let capturedBody: string | undefined;
|
|
120
|
+
|
|
121
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
122
|
+
_url: RequestInfo | URL,
|
|
123
|
+
options?: RequestInit,
|
|
124
|
+
) => {
|
|
125
|
+
capturedBody = options?.body as string;
|
|
126
|
+
return new Response(JSON.stringify({ id: 43 }), { status: 200 });
|
|
127
|
+
}) as unknown as typeof fetch);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await fetch("https://gotify.example.com/message?token=test", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
title: "Test",
|
|
135
|
+
message: "Body",
|
|
136
|
+
priority: 10,
|
|
137
|
+
extras: {
|
|
138
|
+
"client::notification": {
|
|
139
|
+
click: { url: "https://example.com/action" },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const parsedBody = JSON.parse(capturedBody!);
|
|
146
|
+
expect(parsedBody.extras).toBeDefined();
|
|
147
|
+
expect(parsedBody.extras["client::notification"].click.url).toBe(
|
|
148
|
+
"https://example.com/action",
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
mockFetch.mockRestore();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles API errors gracefully", async () => {
|
|
156
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
157
|
+
(async () => {
|
|
158
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
159
|
+
status: 401,
|
|
160
|
+
});
|
|
161
|
+
}) as unknown as typeof fetch,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const response = await fetch(
|
|
166
|
+
"https://gotify.example.com/message?token=invalid",
|
|
167
|
+
{
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: JSON.stringify({ title: "Test", message: "Body" }),
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(response.ok).toBe(false);
|
|
175
|
+
expect(response.status).toBe(401);
|
|
176
|
+
} finally {
|
|
177
|
+
mockFetch.mockRestore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
createBackendPlugin,
|
|
4
|
+
configString,
|
|
5
|
+
Versioned,
|
|
6
|
+
type NotificationStrategy,
|
|
7
|
+
type NotificationSendContext,
|
|
8
|
+
type NotificationDeliveryResult,
|
|
9
|
+
markdownToPlainText,
|
|
10
|
+
} from "@checkstack/backend-api";
|
|
11
|
+
import { notificationStrategyExtensionPoint } from "@checkstack/notification-backend";
|
|
12
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
13
|
+
|
|
14
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
15
|
+
// Configuration Schemas
|
|
16
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Admin configuration for Gotify strategy.
|
|
20
|
+
* Admins configure the Gotify server URL.
|
|
21
|
+
*/
|
|
22
|
+
const gotifyConfigSchemaV1 = z.object({
|
|
23
|
+
serverUrl: configString({})
|
|
24
|
+
.url()
|
|
25
|
+
.describe("Gotify server URL (e.g., https://gotify.example.com)"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
type GotifyConfig = z.infer<typeof gotifyConfigSchemaV1>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* User configuration for Gotify - users provide their app token.
|
|
32
|
+
*/
|
|
33
|
+
const gotifyUserConfigSchema = z.object({
|
|
34
|
+
appToken: configString({ "x-secret": true }).describe(
|
|
35
|
+
"Gotify Application Token",
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
type GotifyUserConfig = z.infer<typeof gotifyUserConfigSchema>;
|
|
40
|
+
|
|
41
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
42
|
+
// Instructions
|
|
43
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
44
|
+
|
|
45
|
+
const adminInstructions = `
|
|
46
|
+
## Configure Gotify Server
|
|
47
|
+
|
|
48
|
+
1. Deploy a [Gotify server](https://gotify.net/) or use an existing instance
|
|
49
|
+
2. Enter the server URL below (e.g., \`https://gotify.example.com\`)
|
|
50
|
+
3. Users will create their own application tokens in the Gotify web UI
|
|
51
|
+
|
|
52
|
+
> **Note**: Ensure the server URL is accessible from this Checkstack instance.
|
|
53
|
+
`.trim();
|
|
54
|
+
|
|
55
|
+
const userInstructions = `
|
|
56
|
+
## Get Your Gotify App Token
|
|
57
|
+
|
|
58
|
+
1. Log into your organization's Gotify server
|
|
59
|
+
2. Go to the **Apps** tab in the web interface
|
|
60
|
+
3. Click **Create Application**
|
|
61
|
+
4. Give it a name (e.g., "Checkstack Notifications")
|
|
62
|
+
5. Copy the generated **Token** and paste it in the field above
|
|
63
|
+
|
|
64
|
+
> **Tip**: You can customize the app icon in Gotify for easy identification.
|
|
65
|
+
`.trim();
|
|
66
|
+
|
|
67
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
68
|
+
// Priority Mapping
|
|
69
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Maps notification importance to Gotify priority.
|
|
73
|
+
* Gotify priorities: 0=min, 1-3=low, 4-7=normal, 8-10=high
|
|
74
|
+
*/
|
|
75
|
+
function mapImportanceToPriority(
|
|
76
|
+
importance: "info" | "warning" | "critical",
|
|
77
|
+
): number {
|
|
78
|
+
const priorityMap: Record<string, number> = {
|
|
79
|
+
info: 5, // Normal
|
|
80
|
+
warning: 7, // High-normal
|
|
81
|
+
critical: 10, // Highest
|
|
82
|
+
};
|
|
83
|
+
return priorityMap[importance];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
87
|
+
// Gotify Strategy Implementation
|
|
88
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Gotify notification strategy using REST API.
|
|
92
|
+
*/
|
|
93
|
+
const gotifyStrategy: NotificationStrategy<GotifyConfig, GotifyUserConfig> = {
|
|
94
|
+
id: "gotify",
|
|
95
|
+
displayName: "Gotify",
|
|
96
|
+
description: "Send notifications via Gotify self-hosted server",
|
|
97
|
+
icon: "Bell",
|
|
98
|
+
|
|
99
|
+
config: new Versioned({
|
|
100
|
+
version: 1,
|
|
101
|
+
schema: gotifyConfigSchemaV1,
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
// User-config resolution - users enter their app token
|
|
105
|
+
contactResolution: { type: "user-config", field: "appToken" },
|
|
106
|
+
|
|
107
|
+
userConfig: new Versioned({
|
|
108
|
+
version: 1,
|
|
109
|
+
schema: gotifyUserConfigSchema,
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
adminInstructions,
|
|
113
|
+
userInstructions,
|
|
114
|
+
|
|
115
|
+
async send(
|
|
116
|
+
context: NotificationSendContext<GotifyConfig, GotifyUserConfig>,
|
|
117
|
+
): Promise<NotificationDeliveryResult> {
|
|
118
|
+
const { userConfig, notification, strategyConfig, logger } = context;
|
|
119
|
+
|
|
120
|
+
if (!strategyConfig.serverUrl) {
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: "Gotify server URL not configured",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!userConfig?.appToken) {
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
error: "User has not configured their Gotify app token",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// Build message body
|
|
136
|
+
const message = notification.body
|
|
137
|
+
? markdownToPlainText(notification.body)
|
|
138
|
+
: notification.title;
|
|
139
|
+
|
|
140
|
+
// Add action URL to extras if present
|
|
141
|
+
const extras: Record<string, unknown> = {};
|
|
142
|
+
if (notification.action?.url) {
|
|
143
|
+
extras["client::notification"] = {
|
|
144
|
+
click: { url: notification.action.url },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Build request URL with token
|
|
149
|
+
const serverUrl = strategyConfig.serverUrl.replace(/\/$/, "");
|
|
150
|
+
const url = `${serverUrl}/message?token=${encodeURIComponent(userConfig.appToken)}`;
|
|
151
|
+
|
|
152
|
+
// Send to Gotify
|
|
153
|
+
const response = await fetch(url, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
title: notification.title,
|
|
160
|
+
message,
|
|
161
|
+
priority: mapImportanceToPriority(notification.importance),
|
|
162
|
+
extras: Object.keys(extras).length > 0 ? extras : undefined,
|
|
163
|
+
}),
|
|
164
|
+
signal: AbortSignal.timeout(10_000),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const errorText = await response.text();
|
|
169
|
+
logger.error("Failed to send Gotify message", {
|
|
170
|
+
status: response.status,
|
|
171
|
+
error: errorText.slice(0, 500),
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: `Failed to send Gotify message: ${response.status}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = (await response.json()) as { id?: number };
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
success: true,
|
|
183
|
+
externalId: result.id ? String(result.id) : undefined,
|
|
184
|
+
};
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const message =
|
|
187
|
+
error instanceof Error ? error.message : "Unknown Gotify API error";
|
|
188
|
+
logger.error("Gotify notification error", { error: message });
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: `Failed to send Gotify notification: ${message}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
198
|
+
// Plugin Definition
|
|
199
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
200
|
+
|
|
201
|
+
export default createBackendPlugin({
|
|
202
|
+
metadata: pluginMetadata,
|
|
203
|
+
|
|
204
|
+
register(env) {
|
|
205
|
+
// Get the notification strategy extension point
|
|
206
|
+
const extensionPoint = env.getExtensionPoint(
|
|
207
|
+
notificationStrategyExtensionPoint,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Register the Gotify strategy with our plugin metadata
|
|
211
|
+
extensionPoint.addStrategy(gotifyStrategy, pluginMetadata);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Export for testing
|
|
216
|
+
export {
|
|
217
|
+
gotifyConfigSchemaV1,
|
|
218
|
+
gotifyUserConfigSchema,
|
|
219
|
+
mapImportanceToPriority,
|
|
220
|
+
};
|
|
221
|
+
export type { GotifyConfig, GotifyUserConfig };
|
package/tsconfig.json
ADDED