@checkstack/notification-webex-backend 0.0.2

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,30 @@
1
+ # @checkstack/notification-webex-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/notification-backend@0.0.2
12
+
13
+ ## 0.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 4c5aa9e: Add Webex notification strategy - sends alerts to users via Webex direct messages
18
+
19
+ - Bot token configured by admin (long-lived tokens from developer.webex.com)
20
+ - Users configure their Webex Person ID to receive notifications
21
+ - Supports markdown formatting with importance emojis and action links
22
+ - Includes admin and user setup instructions
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [b4eb432]
27
+ - Updated dependencies [a65e002]
28
+ - @checkstack/backend-api@1.1.0
29
+ - @checkstack/notification-backend@0.1.2
30
+ - @checkstack/common@0.2.0
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@checkstack/notification-webex-backend",
3
+ "version": "0.0.2",
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
+ },
14
+ "dependencies": {
15
+ "@checkstack/backend-api": "workspace:*",
16
+ "@checkstack/common": "workspace:*",
17
+ "@checkstack/notification-backend": "workspace:*",
18
+ "zod": "^4.2.1"
19
+ },
20
+ "devDependencies": {
21
+ "@checkstack/tsconfig": "workspace:*",
22
+ "typescript": "^5.7.2"
23
+ }
24
+ }
@@ -0,0 +1,294 @@
1
+ import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
2
+ import type { Logger } from "@checkstack/backend-api";
3
+
4
+ // Re-export for testing since we can't import directly from index.ts without side effects
5
+ // We'll test the schemas and strategy logic indirectly through the provider
6
+
7
+ /**
8
+ * Unit tests for the Webex Notification Strategy.
9
+ *
10
+ * Tests cover:
11
+ * - Config schema validation
12
+ * - Successful message delivery
13
+ * - Error handling
14
+ * - Message formatting
15
+ */
16
+
17
+ // Mock logger
18
+ const mockLogger: Logger = {
19
+ debug: mock(() => {}),
20
+ info: mock(() => {}),
21
+ warn: mock(() => {}),
22
+ error: mock(() => {}),
23
+ };
24
+
25
+ describe("Webex Notification Strategy", () => {
26
+ beforeEach(() => {
27
+ (mockLogger.debug as ReturnType<typeof mock>).mockClear();
28
+ (mockLogger.info as ReturnType<typeof mock>).mockClear();
29
+ (mockLogger.warn as ReturnType<typeof mock>).mockClear();
30
+ (mockLogger.error as ReturnType<typeof mock>).mockClear();
31
+ });
32
+
33
+ // ─────────────────────────────────────────────────────────────────────────
34
+ // Config Schema Validation
35
+ // ─────────────────────────────────────────────────────────────────────────
36
+
37
+ describe("config schema", () => {
38
+ // Import schemas inline to avoid plugin initialization side effects
39
+ it("validates admin config - requires bot token", async () => {
40
+ const { z } = await import("zod");
41
+ const { configString } = await import("@checkstack/backend-api");
42
+
43
+ const webexConfigSchemaV1 = z.object({
44
+ botToken: configString({ "x-secret": true }).describe(
45
+ "Webex Bot Access Token"
46
+ ),
47
+ });
48
+
49
+ // Missing bot token should fail
50
+ expect(() => {
51
+ webexConfigSchemaV1.parse({});
52
+ }).toThrow();
53
+
54
+ // Valid config should pass
55
+ const result = webexConfigSchemaV1.parse({
56
+ botToken: "test-bot-token-123",
57
+ });
58
+ expect(result.botToken).toBe("test-bot-token-123");
59
+ });
60
+
61
+ it("validates user config - requires person ID", async () => {
62
+ const { z } = await import("zod");
63
+
64
+ const webexUserConfigSchema = z.object({
65
+ personId: z.string().min(1).describe("Your Webex Person ID"),
66
+ });
67
+
68
+ // Empty person ID should fail
69
+ expect(() => {
70
+ webexUserConfigSchema.parse({ personId: "" });
71
+ }).toThrow();
72
+
73
+ // Missing person ID should fail
74
+ expect(() => {
75
+ webexUserConfigSchema.parse({});
76
+ }).toThrow();
77
+
78
+ // Valid config should pass
79
+ const result = webexUserConfigSchema.parse({
80
+ personId: "Y2lzY29zcGFyazovL3VzL1BFT1BMRS8xMjM0NTY=",
81
+ });
82
+ expect(result.personId).toBe("Y2lzY29zcGFyazovL3VzL1BFT1BMRS8xMjM0NTY=");
83
+ });
84
+ });
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────
87
+ // Message Delivery
88
+ // ─────────────────────────────────────────────────────────────────────────
89
+
90
+ describe("message delivery", () => {
91
+ it("sends message with correct payload structure", async () => {
92
+ let capturedBody: string | undefined;
93
+ let capturedHeaders: Record<string, string> | undefined;
94
+ let capturedUrl: string | undefined;
95
+
96
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
97
+ url: RequestInfo | URL,
98
+ options?: RequestInit
99
+ ) => {
100
+ capturedUrl = url.toString();
101
+ capturedBody = options?.body as string;
102
+ capturedHeaders = options?.headers as Record<string, string>;
103
+ return new Response(JSON.stringify({ id: "msg-123" }), {
104
+ status: 200,
105
+ });
106
+ }) as unknown as typeof fetch);
107
+
108
+ try {
109
+ // Simulate what the send method does
110
+ const botToken = "test-bot-token";
111
+ const personId = "test-person-id";
112
+ const markdown =
113
+ "ℹ️ **Test Title**\n\nTest body\n\n[View](https://example.com)";
114
+
115
+ const response = await fetch("https://webexapis.com/v1/messages", {
116
+ method: "POST",
117
+ headers: {
118
+ Authorization: `Bearer ${botToken}`,
119
+ "Content-Type": "application/json",
120
+ },
121
+ body: JSON.stringify({
122
+ toPersonId: personId,
123
+ markdown,
124
+ }),
125
+ });
126
+
127
+ expect(capturedUrl).toBe("https://webexapis.com/v1/messages");
128
+ expect(capturedHeaders?.["Authorization"]).toBe(`Bearer ${botToken}`);
129
+ expect(capturedHeaders?.["Content-Type"]).toBe("application/json");
130
+
131
+ const parsedBody = JSON.parse(capturedBody!);
132
+ expect(parsedBody.toPersonId).toBe(personId);
133
+ expect(parsedBody.markdown).toContain("**Test Title**");
134
+ expect(parsedBody.markdown).toContain("Test body");
135
+ expect(parsedBody.markdown).toContain("[View](https://example.com)");
136
+
137
+ expect(response.ok).toBe(true);
138
+ const result = await response.json();
139
+ expect(result.id).toBe("msg-123");
140
+ } finally {
141
+ mockFetch.mockRestore();
142
+ }
143
+ });
144
+
145
+ it("formats messages with correct importance emoji", async () => {
146
+ const importanceEmoji = {
147
+ info: "ℹ️",
148
+ warning: "⚠️",
149
+ critical: "🚨",
150
+ };
151
+
152
+ // Test each importance level
153
+ for (const [importance, emoji] of Object.entries(importanceEmoji)) {
154
+ const title = "Test Alert";
155
+ const markdown = `${emoji} **${title}**`;
156
+
157
+ expect(markdown).toContain(emoji);
158
+ expect(markdown).toContain(`**${title}**`);
159
+ }
160
+ });
161
+
162
+ it("handles API errors gracefully", async () => {
163
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
164
+ (async () => {
165
+ return new Response(
166
+ JSON.stringify({ message: "Invalid personId", trackingId: "123" }),
167
+ { status: 400 }
168
+ );
169
+ }) as unknown as typeof fetch
170
+ );
171
+
172
+ try {
173
+ const response = await fetch("https://webexapis.com/v1/messages", {
174
+ method: "POST",
175
+ headers: {
176
+ Authorization: "Bearer test-token",
177
+ "Content-Type": "application/json",
178
+ },
179
+ body: JSON.stringify({
180
+ toPersonId: "invalid-person-id",
181
+ markdown: "Test message",
182
+ }),
183
+ });
184
+
185
+ expect(response.ok).toBe(false);
186
+ expect(response.status).toBe(400);
187
+ } finally {
188
+ mockFetch.mockRestore();
189
+ }
190
+ });
191
+
192
+ it("handles network errors", async () => {
193
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
194
+ (async () => {
195
+ throw new Error("Network error: ECONNREFUSED");
196
+ }) as unknown as typeof fetch
197
+ );
198
+
199
+ try {
200
+ await expect(
201
+ fetch("https://webexapis.com/v1/messages", {
202
+ method: "POST",
203
+ headers: {
204
+ Authorization: "Bearer test-token",
205
+ "Content-Type": "application/json",
206
+ },
207
+ body: JSON.stringify({
208
+ toPersonId: "test-person",
209
+ markdown: "Test",
210
+ }),
211
+ })
212
+ ).rejects.toThrow("ECONNREFUSED");
213
+ } finally {
214
+ mockFetch.mockRestore();
215
+ }
216
+ });
217
+
218
+ it("handles timeout errors", async () => {
219
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
220
+ (async () => {
221
+ throw new Error("The operation was aborted due to timeout");
222
+ }) as unknown as typeof fetch
223
+ );
224
+
225
+ try {
226
+ await expect(
227
+ fetch("https://webexapis.com/v1/messages", {
228
+ method: "POST",
229
+ signal: AbortSignal.timeout(10_000),
230
+ headers: {
231
+ Authorization: "Bearer test-token",
232
+ "Content-Type": "application/json",
233
+ },
234
+ body: JSON.stringify({
235
+ toPersonId: "test-person",
236
+ markdown: "Test",
237
+ }),
238
+ })
239
+ ).rejects.toThrow("timeout");
240
+ } finally {
241
+ mockFetch.mockRestore();
242
+ }
243
+ });
244
+ });
245
+
246
+ // ─────────────────────────────────────────────────────────────────────────
247
+ // Message Formatting
248
+ // ─────────────────────────────────────────────────────────────────────────
249
+
250
+ describe("message formatting", () => {
251
+ it("builds markdown with title only", () => {
252
+ const title = "System Alert";
253
+ const importance = "info" as const;
254
+ const importanceEmoji = { info: "ℹ️", warning: "⚠️", critical: "🚨" };
255
+
256
+ const markdown = `${importanceEmoji[importance]} **${title}**`;
257
+
258
+ expect(markdown).toBe("ℹ️ **System Alert**");
259
+ });
260
+
261
+ it("builds markdown with title and body", () => {
262
+ const title = "System Alert";
263
+ const body = "The system has recovered.";
264
+ const importance = "info" as const;
265
+ const importanceEmoji = { info: "ℹ️", warning: "⚠️", critical: "🚨" };
266
+
267
+ let markdown = `${importanceEmoji[importance]} **${title}**`;
268
+ markdown += `\n\n${body}`;
269
+
270
+ expect(markdown).toBe("ℹ️ **System Alert**\n\nThe system has recovered.");
271
+ });
272
+
273
+ it("builds markdown with action link", () => {
274
+ const title = "Incident Created";
275
+ const body = "A new incident has been reported.";
276
+ const action = {
277
+ label: "View Incident",
278
+ url: "https://example.com/incident/123",
279
+ };
280
+ const importance = "critical" as const;
281
+ const importanceEmoji = { info: "ℹ️", warning: "⚠️", critical: "🚨" };
282
+
283
+ let markdown = `${importanceEmoji[importance]} **${title}**`;
284
+ markdown += `\n\n${body}`;
285
+ markdown += `\n\n[${action.label}](${action.url})`;
286
+
287
+ expect(markdown).toContain("🚨 **Incident Created**");
288
+ expect(markdown).toContain("A new incident has been reported.");
289
+ expect(markdown).toContain(
290
+ "[View Incident](https://example.com/incident/123)"
291
+ );
292
+ });
293
+ });
294
+ });
package/src/index.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { z } from "zod";
2
+ import {
3
+ createBackendPlugin,
4
+ configString,
5
+ Versioned,
6
+ type NotificationStrategy,
7
+ type NotificationSendContext,
8
+ type NotificationDeliveryResult,
9
+ } from "@checkstack/backend-api";
10
+ import { notificationStrategyExtensionPoint } from "@checkstack/notification-backend";
11
+ import { pluginMetadata } from "./plugin-metadata";
12
+
13
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14
+ // Configuration Schemas
15
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16
+
17
+ /**
18
+ * Admin configuration for Webex strategy.
19
+ * Bot tokens are long-lived (100+ years) so no refresh is needed.
20
+ */
21
+ const webexConfigSchemaV1 = z.object({
22
+ botToken: configString({ "x-secret": true }).describe(
23
+ "Webex Bot Access Token from developer.webex.com"
24
+ ),
25
+ });
26
+
27
+ type WebexConfig = z.infer<typeof webexConfigSchemaV1>;
28
+
29
+ /**
30
+ * User configuration for Webex - users provide their Person ID for direct messages.
31
+ */
32
+ const webexUserConfigSchema = z.object({
33
+ personId: z.string().min(1).describe("Your Webex Person ID"),
34
+ });
35
+
36
+ type WebexUserConfig = z.infer<typeof webexUserConfigSchema>;
37
+
38
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
+ // Instructions
40
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
+
42
+ const adminInstructions = `
43
+ ## Create a Webex Bot
44
+
45
+ 1. Go to [developer.webex.com](https://developer.webex.com/) and sign in
46
+ 2. Navigate to **My Webex Apps** → **Create a New App** → **Create a Bot**
47
+ 3. Fill in the bot details:
48
+ - **Bot Name**: Your notification bot name (e.g., "Checkstack Alerts")
49
+ - **Bot Username**: A unique username
50
+ - **Icon**: Upload an icon or use default
51
+ - **Description**: Brief description of the bot
52
+ 4. Click **Create Bot**
53
+ 5. Copy the **Bot Access Token** — this is shown only once!
54
+
55
+ > **Important**: Bot tokens are long-lived (100+ years) but can only be viewed once. Store it securely.
56
+ `.trim();
57
+
58
+ const userInstructions = `
59
+ ## Get Your Webex Person ID
60
+
61
+ 1. Open your Webex app and start a chat with your organization's notification bot
62
+ 2. Send any message to the bot (this creates the 1:1 space)
63
+ 3. To find your Person ID, use one of these methods:
64
+
65
+ **Method 1: Via Webex Developer Portal**
66
+ - Go to [developer.webex.com/docs/api/v1/people/get-my-own-details](https://developer.webex.com/docs/api/v1/people/get-my-own-details)
67
+ - Click "Run" — your Person ID is in the response
68
+
69
+ **Method 2: Ask your admin**
70
+ - Your Webex admin can look up your Person ID via the admin console
71
+
72
+ 4. Enter your Person ID above and save
73
+
74
+ > **Note**: You must have messaged the bot at least once before notifications can be sent.
75
+ `.trim();
76
+
77
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
78
+ // Webex Strategy Implementation
79
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
80
+
81
+ const WEBEX_API_BASE = "https://webexapis.com/v1";
82
+
83
+ /**
84
+ * Webex notification strategy.
85
+ * Sends notifications as direct messages via the Webex Messages API.
86
+ */
87
+ const webexStrategy: NotificationStrategy<WebexConfig, WebexUserConfig> = {
88
+ id: "webex",
89
+ displayName: "Webex",
90
+ description: "Send notifications via Webex direct messages",
91
+ icon: "MessageSquare",
92
+
93
+ config: new Versioned({
94
+ version: 1,
95
+ schema: webexConfigSchemaV1,
96
+ }),
97
+
98
+ // User-config resolution - users enter their Person ID
99
+ contactResolution: { type: "user-config", field: "personId" },
100
+
101
+ userConfig: new Versioned({
102
+ version: 1,
103
+ schema: webexUserConfigSchema,
104
+ }),
105
+
106
+ adminInstructions,
107
+ userInstructions,
108
+
109
+ async send(
110
+ context: NotificationSendContext<WebexConfig, WebexUserConfig>
111
+ ): Promise<NotificationDeliveryResult> {
112
+ const { userConfig, notification, strategyConfig } = context;
113
+
114
+ if (!strategyConfig.botToken) {
115
+ return {
116
+ success: false,
117
+ error: "Webex bot token not configured",
118
+ };
119
+ }
120
+
121
+ if (!userConfig?.personId) {
122
+ return {
123
+ success: false,
124
+ error: "User has not configured their Webex Person ID",
125
+ };
126
+ }
127
+
128
+ try {
129
+ // Build message with markdown formatting
130
+ const importanceEmoji = {
131
+ info: "ℹ️",
132
+ warning: "⚠️",
133
+ critical: "🚨",
134
+ };
135
+
136
+ let markdown = `${importanceEmoji[notification.importance]} **${
137
+ notification.title
138
+ }**`;
139
+
140
+ if (notification.body) {
141
+ markdown += `\n\n${notification.body}`;
142
+ }
143
+
144
+ if (notification.action?.url) {
145
+ markdown += `\n\n[${notification.action.label}](${notification.action.url})`;
146
+ }
147
+
148
+ // Send message via Webex API
149
+ const response = await fetch(`${WEBEX_API_BASE}/messages`, {
150
+ method: "POST",
151
+ headers: {
152
+ Authorization: `Bearer ${strategyConfig.botToken}`,
153
+ "Content-Type": "application/json",
154
+ },
155
+ body: JSON.stringify({
156
+ toPersonId: userConfig.personId,
157
+ markdown,
158
+ }),
159
+ signal: AbortSignal.timeout(10_000),
160
+ });
161
+
162
+ if (!response.ok) {
163
+ const errorText = await response.text();
164
+ return {
165
+ success: false,
166
+ error: `Webex API error (${response.status}): ${errorText.slice(
167
+ 0,
168
+ 200
169
+ )}`,
170
+ };
171
+ }
172
+
173
+ const result = (await response.json()) as { id?: string };
174
+
175
+ return {
176
+ success: true,
177
+ externalId: result.id,
178
+ };
179
+ } catch (error) {
180
+ const message =
181
+ error instanceof Error ? error.message : "Unknown Webex API error";
182
+ return {
183
+ success: false,
184
+ error: `Failed to send Webex message: ${message}`,
185
+ };
186
+ }
187
+ },
188
+ };
189
+
190
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
191
+ // Plugin Definition
192
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
193
+
194
+ export default createBackendPlugin({
195
+ metadata: pluginMetadata,
196
+
197
+ register(env) {
198
+ // Get the notification strategy extension point
199
+ const extensionPoint = env.getExtensionPoint(
200
+ notificationStrategyExtensionPoint
201
+ );
202
+
203
+ // Register the Webex strategy with our plugin metadata
204
+ extensionPoint.addStrategy(webexStrategy, pluginMetadata);
205
+ },
206
+ });
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "notification-webex",
5
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }