@checkstack/notification-discord-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 ADDED
@@ -0,0 +1,7 @@
1
+ # @checkstack/notification-discord-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cf5f245: Added Discord notification provider with webhook support and rich embeds. Features include color-coded importance levels (info/warning/critical), action buttons as embed fields, and emoji indicators.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@checkstack/notification-discord-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,187 @@
1
+ import { describe, it, expect, spyOn } from "bun:test";
2
+ import {
3
+ discordConfigSchemaV1,
4
+ discordUserConfigSchema,
5
+ buildDiscordEmbed,
6
+ } from "./index";
7
+
8
+ /**
9
+ * Unit tests for the Discord Notification Strategy.
10
+ *
11
+ * Tests cover:
12
+ * - Config schema validation
13
+ * - Discord embed building
14
+ * - Webhook API interaction
15
+ */
16
+
17
+ describe("Discord Notification Strategy", () => {
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+ // Config Schema Validation
20
+ // ─────────────────────────────────────────────────────────────────────────
21
+
22
+ describe("config schema", () => {
23
+ it("accepts empty admin config", () => {
24
+ const result = discordConfigSchemaV1.parse({});
25
+ expect(result).toEqual({});
26
+ });
27
+
28
+ it("validates user config - requires webhookUrl", () => {
29
+ expect(() => {
30
+ discordUserConfigSchema.parse({});
31
+ }).toThrow();
32
+ });
33
+
34
+ it("validates user config - requires valid URL", () => {
35
+ expect(() => {
36
+ discordUserConfigSchema.parse({ webhookUrl: "not-a-url" });
37
+ }).toThrow();
38
+ });
39
+
40
+ it("accepts valid user config", () => {
41
+ const result = discordUserConfigSchema.parse({
42
+ webhookUrl: "https://discord.com/api/webhooks/123/abc",
43
+ });
44
+ expect(result.webhookUrl).toBe(
45
+ "https://discord.com/api/webhooks/123/abc",
46
+ );
47
+ });
48
+ });
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────
51
+ // Discord Embed Building
52
+ // ─────────────────────────────────────────────────────────────────────────
53
+
54
+ describe("embed builder", () => {
55
+ it("builds embed with title only", () => {
56
+ const embed = buildDiscordEmbed({
57
+ title: "Test Alert",
58
+ importance: "info",
59
+ });
60
+
61
+ expect(embed.title).toContain("Test Alert");
62
+ expect(embed.title).toContain("ℹ️");
63
+ expect(embed.color).toBe(0x3b_82_f6); // Blue
64
+ expect(embed.timestamp).toBeDefined();
65
+ });
66
+
67
+ it("builds embed with title and body", () => {
68
+ const embed = buildDiscordEmbed({
69
+ title: "System Alert",
70
+ body: "The system has recovered.",
71
+ importance: "warning",
72
+ });
73
+
74
+ expect(embed.title).toContain("⚠️");
75
+ expect(embed.title).toContain("System Alert");
76
+ expect(embed.description).toBe("The system has recovered.");
77
+ expect(embed.color).toBe(0xf5_9e_0b); // Amber
78
+ });
79
+
80
+ it("builds embed with action button as field", () => {
81
+ const embed = buildDiscordEmbed({
82
+ title: "Incident Created",
83
+ body: "A new incident requires attention.",
84
+ importance: "critical",
85
+ action: {
86
+ label: "View Incident",
87
+ url: "https://example.com/incident/123",
88
+ },
89
+ });
90
+
91
+ expect(embed.title).toContain("🚨");
92
+ expect(embed.color).toBe(0xef_44_44); // Red
93
+ expect(embed.fields).toHaveLength(1);
94
+ expect(embed.fields![0].name).toBe("View Incident");
95
+ expect(embed.fields![0].value).toContain(
96
+ "https://example.com/incident/123",
97
+ );
98
+ });
99
+
100
+ it("uses correct colors for importance levels", () => {
101
+ const infoEmbed = buildDiscordEmbed({
102
+ title: "Info",
103
+ importance: "info",
104
+ });
105
+ const warningEmbed = buildDiscordEmbed({
106
+ title: "Warning",
107
+ importance: "warning",
108
+ });
109
+ const criticalEmbed = buildDiscordEmbed({
110
+ title: "Critical",
111
+ importance: "critical",
112
+ });
113
+
114
+ expect(infoEmbed.color).toBe(0x3b_82_f6);
115
+ expect(warningEmbed.color).toBe(0xf5_9e_0b);
116
+ expect(criticalEmbed.color).toBe(0xef_44_44);
117
+ });
118
+ });
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+ // Webhook API Interaction
122
+ // ─────────────────────────────────────────────────────────────────────────
123
+
124
+ describe("webhook API interaction", () => {
125
+ it("sends embed to webhook URL", async () => {
126
+ let capturedBody: string | undefined;
127
+ let capturedUrl: string | undefined;
128
+
129
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
130
+ url: RequestInfo | URL,
131
+ options?: RequestInit,
132
+ ) => {
133
+ capturedUrl = url.toString();
134
+ capturedBody = options?.body as string;
135
+ return new Response(null, { status: 204 });
136
+ }) as unknown as typeof fetch);
137
+
138
+ try {
139
+ const webhookUrl = "https://discord.com/api/webhooks/123/abc";
140
+ const embed = buildDiscordEmbed({
141
+ title: "Test",
142
+ importance: "info",
143
+ });
144
+
145
+ await fetch(webhookUrl, {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({ embeds: [embed] }),
149
+ });
150
+
151
+ expect(capturedUrl).toBe(webhookUrl);
152
+
153
+ const parsedBody = JSON.parse(capturedBody!);
154
+ expect(parsedBody.embeds).toHaveLength(1);
155
+ expect(parsedBody.embeds[0].title).toContain("Test");
156
+ } finally {
157
+ mockFetch.mockRestore();
158
+ }
159
+ });
160
+
161
+ it("handles API errors gracefully", async () => {
162
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
163
+ (async () => {
164
+ return new Response(JSON.stringify({ message: "Invalid webhook" }), {
165
+ status: 404,
166
+ });
167
+ }) as unknown as typeof fetch,
168
+ );
169
+
170
+ try {
171
+ const response = await fetch(
172
+ "https://discord.com/api/webhooks/invalid",
173
+ {
174
+ method: "POST",
175
+ headers: { "Content-Type": "application/json" },
176
+ body: JSON.stringify({ embeds: [] }),
177
+ },
178
+ );
179
+
180
+ expect(response.ok).toBe(false);
181
+ expect(response.status).toBe(404);
182
+ } finally {
183
+ mockFetch.mockRestore();
184
+ }
185
+ });
186
+ });
187
+ });
package/src/index.ts ADDED
@@ -0,0 +1,230 @@
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 Discord strategy.
20
+ * Optional - no admin config required since users provide their own webhooks.
21
+ */
22
+ const discordConfigSchemaV1 = z.object({});
23
+
24
+ type DiscordConfig = z.infer<typeof discordConfigSchemaV1>;
25
+
26
+ /**
27
+ * User configuration for Discord - users provide their webhook URL.
28
+ */
29
+ const discordUserConfigSchema = z.object({
30
+ webhookUrl: configString({}).url().describe("Discord Webhook URL"),
31
+ });
32
+
33
+ type DiscordUserConfig = z.infer<typeof discordUserConfigSchema>;
34
+
35
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
+ // Instructions
37
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
+
39
+ const adminInstructions = `
40
+ ## Discord Notifications
41
+
42
+ Discord notifications are delivered via webhooks that users configure individually.
43
+ Each user provides their own webhook URL in their notification settings.
44
+
45
+ No admin configuration is required for this strategy.
46
+ `.trim();
47
+
48
+ const userInstructions = `
49
+ ## Create a Discord Webhook
50
+
51
+ 1. Open Discord and go to the channel where you want notifications
52
+ 2. Click the **gear icon** (Edit Channel) next to the channel name
53
+ 3. Go to **Integrations** → **Webhooks** → **New Webhook**
54
+ 4. Give your webhook a name (e.g., "Checkstack Alerts")
55
+ 5. Click **Copy Webhook URL** and paste it in the field above
56
+
57
+ > **Privacy Note**: This webhook URL is private to you. Only use webhooks for channels you control.
58
+ `.trim();
59
+
60
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
61
+ // Discord Embed Builder
62
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
63
+
64
+ interface DiscordEmbedOptions {
65
+ title: string;
66
+ body?: string;
67
+ importance: "info" | "warning" | "critical";
68
+ action?: { label: string; url: string };
69
+ }
70
+
71
+ interface DiscordEmbed {
72
+ title: string;
73
+ description?: string;
74
+ color: number;
75
+ fields?: Array<{ name: string; value: string; inline?: boolean }>;
76
+ timestamp?: string;
77
+ }
78
+
79
+ function buildDiscordEmbed(options: DiscordEmbedOptions): DiscordEmbed {
80
+ const { title, body, importance, action } = options;
81
+
82
+ // Discord colors are decimal values
83
+ const importanceColors: Record<string, number> = {
84
+ info: 0x3B_82_F6, // Blue
85
+ warning: 0xF5_9E_0B, // Amber
86
+ critical: 0xEF_44_44, // Red
87
+ };
88
+
89
+ const importanceEmoji: Record<string, string> = {
90
+ info: "ℹ️",
91
+ warning: "⚠️",
92
+ critical: "🚨",
93
+ };
94
+
95
+ const embed: DiscordEmbed = {
96
+ title: `${importanceEmoji[importance]} ${title}`,
97
+ color: importanceColors[importance],
98
+ timestamp: new Date().toISOString(),
99
+ };
100
+
101
+ if (body) {
102
+ // Convert markdown to plain text for better Discord compatibility
103
+ embed.description = markdownToPlainText(body);
104
+ }
105
+
106
+ if (action?.url) {
107
+ embed.fields = [
108
+ {
109
+ name: action.label,
110
+ value: `[Click here](${action.url})`,
111
+ inline: false,
112
+ },
113
+ ];
114
+ }
115
+
116
+ return embed;
117
+ }
118
+
119
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
+ // Discord Strategy Implementation
121
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
122
+
123
+ /**
124
+ * Discord notification strategy using webhooks.
125
+ */
126
+ const discordStrategy: NotificationStrategy<DiscordConfig, DiscordUserConfig> =
127
+ {
128
+ id: "discord",
129
+ displayName: "Discord",
130
+ description: "Send notifications via Discord webhooks",
131
+ icon: "MessageCircle",
132
+
133
+ config: new Versioned({
134
+ version: 1,
135
+ schema: discordConfigSchemaV1,
136
+ }),
137
+
138
+ // User-config resolution - users enter their webhook URL
139
+ contactResolution: { type: "user-config", field: "webhookUrl" },
140
+
141
+ userConfig: new Versioned({
142
+ version: 1,
143
+ schema: discordUserConfigSchema,
144
+ }),
145
+
146
+ adminInstructions,
147
+ userInstructions,
148
+
149
+ async send(
150
+ context: NotificationSendContext<DiscordConfig, DiscordUserConfig>,
151
+ ): Promise<NotificationDeliveryResult> {
152
+ const { userConfig, notification, logger } = context;
153
+
154
+ if (!userConfig?.webhookUrl) {
155
+ return {
156
+ success: false,
157
+ error: "User has not configured their Discord webhook URL",
158
+ };
159
+ }
160
+
161
+ try {
162
+ // Build the embed
163
+ const embed = buildDiscordEmbed({
164
+ title: notification.title,
165
+ body: notification.body,
166
+ importance: notification.importance,
167
+ action: notification.action,
168
+ });
169
+
170
+ // Send to Discord webhook
171
+ const response = await fetch(userConfig.webhookUrl, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/json",
175
+ },
176
+ body: JSON.stringify({
177
+ embeds: [embed],
178
+ }),
179
+ signal: AbortSignal.timeout(10_000),
180
+ });
181
+
182
+ if (!response.ok) {
183
+ const errorText = await response.text();
184
+ logger.error("Failed to send Discord message", {
185
+ status: response.status,
186
+ error: errorText.slice(0, 500),
187
+ });
188
+ return {
189
+ success: false,
190
+ error: `Failed to send Discord message: ${response.status}`,
191
+ };
192
+ }
193
+
194
+ // Discord webhooks return 204 No Content on success
195
+ return {
196
+ success: true,
197
+ };
198
+ } catch (error) {
199
+ const message =
200
+ error instanceof Error ? error.message : "Unknown Discord API error";
201
+ logger.error("Discord notification error", { error: message });
202
+ return {
203
+ success: false,
204
+ error: `Failed to send Discord notification: ${message}`,
205
+ };
206
+ }
207
+ },
208
+ };
209
+
210
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
211
+ // Plugin Definition
212
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
213
+
214
+ export default createBackendPlugin({
215
+ metadata: pluginMetadata,
216
+
217
+ register(env) {
218
+ // Get the notification strategy extension point
219
+ const extensionPoint = env.getExtensionPoint(
220
+ notificationStrategyExtensionPoint,
221
+ );
222
+
223
+ // Register the Discord strategy with our plugin metadata
224
+ extensionPoint.addStrategy(discordStrategy, pluginMetadata);
225
+ },
226
+ });
227
+
228
+ // Export for testing
229
+ export { discordConfigSchemaV1, discordUserConfigSchema, buildDiscordEmbed };
230
+ export type { DiscordConfig, DiscordUserConfig, DiscordEmbedOptions };
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "notification-discord",
5
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }