@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 +7 -0
- package/package.json +25 -0
- package/src/index.test.ts +187 -0
- package/src/index.ts +230 -0
- package/src/plugin-metadata.ts +5 -0
- package/tsconfig.json +3 -0
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 };
|
package/tsconfig.json
ADDED