@checkstack/notification-slack-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-slack-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cf5f245: Added Slack notification provider with incoming webhook support. Features include Block Kit layouts, mrkdwn formatting, action buttons, and color-coded importance attachments.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@checkstack/notification-slack-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,189 @@
1
+ import { describe, it, expect, spyOn } from "bun:test";
2
+ import {
3
+ slackConfigSchemaV1,
4
+ slackUserConfigSchema,
5
+ buildSlackPayload,
6
+ } from "./index";
7
+
8
+ /**
9
+ * Unit tests for the Slack Notification Strategy.
10
+ *
11
+ * Tests cover:
12
+ * - Config schema validation
13
+ * - Block Kit payload building
14
+ * - Webhook API interaction
15
+ */
16
+
17
+ describe("Slack Notification Strategy", () => {
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+ // Config Schema Validation
20
+ // ─────────────────────────────────────────────────────────────────────────
21
+
22
+ describe("config schema", () => {
23
+ it("accepts empty admin config", () => {
24
+ const result = slackConfigSchemaV1.parse({});
25
+ expect(result).toEqual({});
26
+ });
27
+
28
+ it("validates user config - requires webhookUrl", () => {
29
+ expect(() => {
30
+ slackUserConfigSchema.parse({});
31
+ }).toThrow();
32
+ });
33
+
34
+ it("validates user config - requires valid URL", () => {
35
+ expect(() => {
36
+ slackUserConfigSchema.parse({ webhookUrl: "not-a-url" });
37
+ }).toThrow();
38
+ });
39
+
40
+ it("accepts valid user config", () => {
41
+ const result = slackUserConfigSchema.parse({
42
+ webhookUrl: "https://hooks.slack.com/services/T00/B00/XXX",
43
+ });
44
+ expect(result.webhookUrl).toBe(
45
+ "https://hooks.slack.com/services/T00/B00/XXX",
46
+ );
47
+ });
48
+ });
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────
51
+ // Block Kit Payload Building
52
+ // ─────────────────────────────────────────────────────────────────────────
53
+
54
+ describe("payload builder", () => {
55
+ it("builds payload with title only", () => {
56
+ const payload = buildSlackPayload({
57
+ title: "Test Alert",
58
+ importance: "info",
59
+ });
60
+
61
+ expect(payload.text).toContain("Test Alert");
62
+ expect(payload.text).toContain("ℹ️");
63
+ expect(payload.blocks).toHaveLength(1);
64
+ expect(payload.blocks[0].type).toBe("section");
65
+ expect(payload.attachments).toHaveLength(1);
66
+ expect(payload.attachments![0].color).toBe("#3b82f6");
67
+ });
68
+
69
+ it("builds payload with title and body", () => {
70
+ const payload = buildSlackPayload({
71
+ title: "System Alert",
72
+ body: "The system has recovered.",
73
+ importance: "warning",
74
+ });
75
+
76
+ expect(payload.text).toContain("⚠️");
77
+ expect(payload.blocks).toHaveLength(2);
78
+ expect(payload.attachments![0].color).toBe("#f59e0b");
79
+ });
80
+
81
+ it("builds payload with action button", () => {
82
+ const payload = buildSlackPayload({
83
+ title: "Incident Created",
84
+ body: "A new incident requires attention.",
85
+ importance: "critical",
86
+ action: {
87
+ label: "View Incident",
88
+ url: "https://example.com/incident/123",
89
+ },
90
+ });
91
+
92
+ expect(payload.text).toContain("🚨");
93
+ expect(payload.blocks).toHaveLength(3); // header + body + actions
94
+
95
+ const actionsBlock = payload.blocks[2];
96
+ expect(actionsBlock.type).toBe("actions");
97
+
98
+ const elements = actionsBlock.elements as Array<Record<string, unknown>>;
99
+ expect(elements).toHaveLength(1);
100
+ expect(elements[0].type).toBe("button");
101
+ expect(elements[0].url).toBe("https://example.com/incident/123");
102
+ });
103
+
104
+ it("uses correct colors for importance levels", () => {
105
+ const infoPayload = buildSlackPayload({
106
+ title: "Info",
107
+ importance: "info",
108
+ });
109
+ const warningPayload = buildSlackPayload({
110
+ title: "Warning",
111
+ importance: "warning",
112
+ });
113
+ const criticalPayload = buildSlackPayload({
114
+ title: "Critical",
115
+ importance: "critical",
116
+ });
117
+
118
+ expect(infoPayload.attachments![0].color).toBe("#3b82f6");
119
+ expect(warningPayload.attachments![0].color).toBe("#f59e0b");
120
+ expect(criticalPayload.attachments![0].color).toBe("#ef4444");
121
+ });
122
+ });
123
+
124
+ // ─────────────────────────────────────────────────────────────────────────
125
+ // Webhook API Interaction
126
+ // ─────────────────────────────────────────────────────────────────────────
127
+
128
+ describe("webhook API interaction", () => {
129
+ it("sends payload to webhook URL", async () => {
130
+ let capturedBody: string | undefined;
131
+ let capturedUrl: string | undefined;
132
+
133
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
134
+ url: RequestInfo | URL,
135
+ options?: RequestInit,
136
+ ) => {
137
+ capturedUrl = url.toString();
138
+ capturedBody = options?.body as string;
139
+ return new Response("ok", { status: 200 });
140
+ }) as unknown as typeof fetch);
141
+
142
+ try {
143
+ const webhookUrl = "https://hooks.slack.com/services/T00/B00/XXX";
144
+ const payload = buildSlackPayload({
145
+ title: "Test",
146
+ importance: "info",
147
+ });
148
+
149
+ await fetch(webhookUrl, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify(payload),
153
+ });
154
+
155
+ expect(capturedUrl).toBe(webhookUrl);
156
+
157
+ const parsedBody = JSON.parse(capturedBody!);
158
+ expect(parsedBody.blocks).toBeDefined();
159
+ expect(parsedBody.text).toContain("Test");
160
+ } finally {
161
+ mockFetch.mockRestore();
162
+ }
163
+ });
164
+
165
+ it("handles API errors gracefully", async () => {
166
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
167
+ (async () => {
168
+ return new Response("invalid_payload", { status: 400 });
169
+ }) as unknown as typeof fetch,
170
+ );
171
+
172
+ try {
173
+ const response = await fetch(
174
+ "https://hooks.slack.com/services/invalid",
175
+ {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify({ text: "test" }),
179
+ },
180
+ );
181
+
182
+ expect(response.ok).toBe(false);
183
+ expect(response.status).toBe(400);
184
+ } finally {
185
+ mockFetch.mockRestore();
186
+ }
187
+ });
188
+ });
189
+ });
package/src/index.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { z } from "zod";
2
+ import {
3
+ createBackendPlugin,
4
+ configString,
5
+ Versioned,
6
+ type NotificationStrategy,
7
+ type NotificationSendContext,
8
+ type NotificationDeliveryResult,
9
+ markdownToSlackMrkdwn,
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 Slack strategy.
20
+ * Optional - no admin config required since users provide their own webhooks.
21
+ */
22
+ const slackConfigSchemaV1 = z.object({});
23
+
24
+ type SlackConfig = z.infer<typeof slackConfigSchemaV1>;
25
+
26
+ /**
27
+ * User configuration for Slack - users provide their webhook URL.
28
+ */
29
+ const slackUserConfigSchema = z.object({
30
+ webhookUrl: configString({}).url().describe("Slack Incoming Webhook URL"),
31
+ });
32
+
33
+ type SlackUserConfig = z.infer<typeof slackUserConfigSchema>;
34
+
35
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
+ // Instructions
37
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
+
39
+ const adminInstructions = `
40
+ ## Slack Notifications
41
+
42
+ Slack notifications are delivered via incoming 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 Slack Incoming Webhook
50
+
51
+ 1. Go to [Slack API Apps](https://api.slack.com/apps) and create a new app (or select existing)
52
+ 2. Under **Features**, click **Incoming Webhooks** and toggle it **On**
53
+ 3. Click **Add New Webhook to Workspace**
54
+ 4. Select a channel where you want to receive notifications
55
+ 5. Copy the **Webhook URL** and paste it in the field above
56
+
57
+ > **Tip**: You can create webhooks for private channels or your own DM channel for personal notifications.
58
+ `.trim();
59
+
60
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
61
+ // Slack Block Kit Builder
62
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
63
+
64
+ interface SlackBlockOptions {
65
+ title: string;
66
+ body?: string;
67
+ importance: "info" | "warning" | "critical";
68
+ action?: { label: string; url: string };
69
+ }
70
+
71
+ interface SlackPayload {
72
+ text: string; // Fallback text for notifications
73
+ blocks: Array<Record<string, unknown>>;
74
+ attachments?: Array<{ color: string }>;
75
+ }
76
+
77
+ function buildSlackPayload(options: SlackBlockOptions): SlackPayload {
78
+ const { title, body, importance, action } = options;
79
+
80
+ const importanceEmoji: Record<string, string> = {
81
+ info: "ℹ️",
82
+ warning: "⚠️",
83
+ critical: "🚨",
84
+ };
85
+
86
+ // Attachment colors for importance-based accent
87
+ const importanceColors: Record<string, string> = {
88
+ info: "#3b82f6", // Blue
89
+ warning: "#f59e0b", // Amber
90
+ critical: "#ef4444", // Red
91
+ };
92
+
93
+ const blocks: Array<Record<string, unknown>> = [
94
+ // Header section with title
95
+ {
96
+ type: "section",
97
+ text: {
98
+ type: "mrkdwn",
99
+ text: `${importanceEmoji[importance]} *${title}*`,
100
+ },
101
+ },
102
+ ];
103
+
104
+ // Body section (if provided)
105
+ if (body) {
106
+ const mrkdwnBody = markdownToSlackMrkdwn(body);
107
+ blocks.push({
108
+ type: "section",
109
+ text: {
110
+ type: "mrkdwn",
111
+ text: mrkdwnBody,
112
+ },
113
+ });
114
+ }
115
+
116
+ // Action button (if provided)
117
+ if (action?.url) {
118
+ blocks.push({
119
+ type: "actions",
120
+ elements: [
121
+ {
122
+ type: "button",
123
+ text: {
124
+ type: "plain_text",
125
+ text: action.label,
126
+ emoji: true,
127
+ },
128
+ url: action.url,
129
+ action_id: "notification_action",
130
+ },
131
+ ],
132
+ });
133
+ }
134
+
135
+ return {
136
+ text: `${importanceEmoji[importance]} ${title}`, // Fallback for notifications
137
+ blocks,
138
+ attachments: [{ color: importanceColors[importance] }],
139
+ };
140
+ }
141
+
142
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
143
+ // Slack Strategy Implementation
144
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
145
+
146
+ /**
147
+ * Slack notification strategy using incoming webhooks.
148
+ */
149
+ const slackStrategy: NotificationStrategy<SlackConfig, SlackUserConfig> = {
150
+ id: "slack",
151
+ displayName: "Slack",
152
+ description: "Send notifications via Slack incoming webhooks",
153
+ icon: "Hash",
154
+
155
+ config: new Versioned({
156
+ version: 1,
157
+ schema: slackConfigSchemaV1,
158
+ }),
159
+
160
+ // User-config resolution - users enter their webhook URL
161
+ contactResolution: { type: "user-config", field: "webhookUrl" },
162
+
163
+ userConfig: new Versioned({
164
+ version: 1,
165
+ schema: slackUserConfigSchema,
166
+ }),
167
+
168
+ adminInstructions,
169
+ userInstructions,
170
+
171
+ async send(
172
+ context: NotificationSendContext<SlackConfig, SlackUserConfig>,
173
+ ): Promise<NotificationDeliveryResult> {
174
+ const { userConfig, notification, logger } = context;
175
+
176
+ if (!userConfig?.webhookUrl) {
177
+ return {
178
+ success: false,
179
+ error: "User has not configured their Slack webhook URL",
180
+ };
181
+ }
182
+
183
+ try {
184
+ // Build the Slack payload
185
+ const payload = buildSlackPayload({
186
+ title: notification.title,
187
+ body: notification.body,
188
+ importance: notification.importance,
189
+ action: notification.action,
190
+ });
191
+
192
+ // Send to Slack webhook
193
+ const response = await fetch(userConfig.webhookUrl, {
194
+ method: "POST",
195
+ headers: {
196
+ "Content-Type": "application/json",
197
+ },
198
+ body: JSON.stringify(payload),
199
+ signal: AbortSignal.timeout(10_000),
200
+ });
201
+
202
+ if (!response.ok) {
203
+ const errorText = await response.text();
204
+ logger.error("Failed to send Slack message", {
205
+ status: response.status,
206
+ error: errorText.slice(0, 500),
207
+ });
208
+ return {
209
+ success: false,
210
+ error: `Failed to send Slack message: ${response.status}`,
211
+ };
212
+ }
213
+
214
+ // Slack webhooks return "ok" on success
215
+ return {
216
+ success: true,
217
+ };
218
+ } catch (error) {
219
+ const message =
220
+ error instanceof Error ? error.message : "Unknown Slack API error";
221
+ logger.error("Slack notification error", { error: message });
222
+ return {
223
+ success: false,
224
+ error: `Failed to send Slack notification: ${message}`,
225
+ };
226
+ }
227
+ },
228
+ };
229
+
230
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
231
+ // Plugin Definition
232
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
233
+
234
+ export default createBackendPlugin({
235
+ metadata: pluginMetadata,
236
+
237
+ register(env) {
238
+ // Get the notification strategy extension point
239
+ const extensionPoint = env.getExtensionPoint(
240
+ notificationStrategyExtensionPoint,
241
+ );
242
+
243
+ // Register the Slack strategy with our plugin metadata
244
+ extensionPoint.addStrategy(slackStrategy, pluginMetadata);
245
+ },
246
+ });
247
+
248
+ // Export for testing
249
+ export { slackConfigSchemaV1, slackUserConfigSchema, buildSlackPayload };
250
+ export type { SlackConfig, SlackUserConfig, SlackBlockOptions, SlackPayload };
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "notification-slack",
5
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }