@checkstack/notification-teams-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,31 @@
1
+ # @checkstack/notification-teams-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 Microsoft Teams notification strategy - sends alerts to users via OAuth/Graph API
18
+
19
+ - Admin configures Azure AD app credentials (Tenant ID, Client ID, Client Secret)
20
+ - Users link their Microsoft account via OAuth flow
21
+ - Messages sent as Adaptive Cards to 1:1 chats via Graph API
22
+ - Supports importance-based coloring and action buttons
23
+ - Includes detailed admin setup instructions for Azure AD configuration
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [b4eb432]
28
+ - Updated dependencies [a65e002]
29
+ - @checkstack/backend-api@1.1.0
30
+ - @checkstack/notification-backend@0.1.2
31
+ - @checkstack/common@0.2.0
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@checkstack/notification-teams-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,298 @@
1
+ import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
2
+ import { teamsConfigSchemaV1, buildAdaptiveCard } from "./index";
3
+ import type { AdaptiveCardOptions } from "./index";
4
+
5
+ /**
6
+ * Unit tests for the Microsoft Teams Notification Strategy.
7
+ *
8
+ * Tests cover:
9
+ * - Config schema validation
10
+ * - Adaptive Card building
11
+ * - Message formatting
12
+ */
13
+
14
+ describe("Microsoft Teams Notification Strategy", () => {
15
+ // ─────────────────────────────────────────────────────────────────────────
16
+ // Config Schema Validation
17
+ // ─────────────────────────────────────────────────────────────────────────
18
+
19
+ describe("config schema", () => {
20
+ it("validates admin config - requires all fields", () => {
21
+ // Missing all fields should fail
22
+ expect(() => {
23
+ teamsConfigSchemaV1.parse({});
24
+ }).toThrow();
25
+
26
+ // Missing clientSecret should fail
27
+ expect(() => {
28
+ teamsConfigSchemaV1.parse({
29
+ tenantId: "test-tenant",
30
+ clientId: "test-client",
31
+ });
32
+ }).toThrow();
33
+ });
34
+
35
+ it("accepts valid config", () => {
36
+ const result = teamsConfigSchemaV1.parse({
37
+ tenantId: "12345678-1234-1234-1234-123456789abc",
38
+ clientId: "87654321-4321-4321-4321-cba987654321",
39
+ clientSecret: "super-secret-value",
40
+ });
41
+
42
+ expect(result.tenantId).toBe("12345678-1234-1234-1234-123456789abc");
43
+ expect(result.clientId).toBe("87654321-4321-4321-4321-cba987654321");
44
+ expect(result.clientSecret).toBe("super-secret-value");
45
+ });
46
+ });
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────
49
+ // Adaptive Card Building
50
+ // ─────────────────────────────────────────────────────────────────────────
51
+
52
+ describe("adaptive card builder", () => {
53
+ it("builds card with title only", () => {
54
+ const card = buildAdaptiveCard({
55
+ title: "Test Alert",
56
+ importance: "info",
57
+ }) as Record<string, unknown>;
58
+
59
+ expect(card.type).toBe("AdaptiveCard");
60
+ expect(card.version).toBe("1.4");
61
+ expect(card.$schema).toBe(
62
+ "http://adaptivecards.io/schemas/adaptive-card.json"
63
+ );
64
+
65
+ const body = card.body as Array<Record<string, unknown>>;
66
+ expect(body).toHaveLength(1);
67
+ expect(body[0].text).toContain("Test Alert");
68
+ expect(body[0].text).toContain("ℹ️");
69
+ });
70
+
71
+ it("builds card with title and body", () => {
72
+ const card = buildAdaptiveCard({
73
+ title: "System Alert",
74
+ body: "The system has recovered from an outage.",
75
+ importance: "warning",
76
+ }) as Record<string, unknown>;
77
+
78
+ const body = card.body as Array<Record<string, unknown>>;
79
+ expect(body).toHaveLength(2);
80
+ expect(body[0].text).toContain("⚠️");
81
+ expect(body[0].text).toContain("System Alert");
82
+ expect(body[1].text).toBe("The system has recovered from an outage.");
83
+ });
84
+
85
+ it("builds card with action button", () => {
86
+ const card = buildAdaptiveCard({
87
+ title: "Incident Created",
88
+ body: "A new incident requires your attention.",
89
+ importance: "critical",
90
+ action: {
91
+ label: "View Incident",
92
+ url: "https://example.com/incident/123",
93
+ },
94
+ }) as Record<string, unknown>;
95
+
96
+ const body = card.body as Array<Record<string, unknown>>;
97
+ expect(body[0].text).toContain("🚨");
98
+
99
+ const actions = card.actions as Array<Record<string, unknown>>;
100
+ expect(actions).toHaveLength(1);
101
+ expect(actions[0].type).toBe("Action.OpenUrl");
102
+ expect(actions[0].title).toBe("View Incident");
103
+ expect(actions[0].url).toBe("https://example.com/incident/123");
104
+ });
105
+
106
+ it("uses correct colors for importance levels", () => {
107
+ const infoCard = buildAdaptiveCard({
108
+ title: "Info",
109
+ importance: "info",
110
+ }) as Record<string, unknown>;
111
+ const warningCard = buildAdaptiveCard({
112
+ title: "Warning",
113
+ importance: "warning",
114
+ }) as Record<string, unknown>;
115
+ const criticalCard = buildAdaptiveCard({
116
+ title: "Critical",
117
+ importance: "critical",
118
+ }) as Record<string, unknown>;
119
+
120
+ const infoBody = infoCard.body as Array<Record<string, unknown>>;
121
+ const warningBody = warningCard.body as Array<Record<string, unknown>>;
122
+ const criticalBody = criticalCard.body as Array<Record<string, unknown>>;
123
+
124
+ expect(infoBody[0].color).toBe("accent");
125
+ expect(warningBody[0].color).toBe("warning");
126
+ expect(criticalBody[0].color).toBe("attention");
127
+ });
128
+ });
129
+
130
+ // ─────────────────────────────────────────────────────────────────────────
131
+ // Graph API Interaction
132
+ // ─────────────────────────────────────────────────────────────────────────
133
+
134
+ describe("Graph API interaction", () => {
135
+ it("creates chat with correct member binding", async () => {
136
+ let capturedBody: string | undefined;
137
+ let capturedUrl: string | undefined;
138
+
139
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
140
+ url: RequestInfo | URL,
141
+ options?: RequestInit
142
+ ) => {
143
+ capturedUrl = url.toString();
144
+ capturedBody = options?.body as string;
145
+ return new Response(JSON.stringify({ id: "chat-123" }), {
146
+ status: 200,
147
+ });
148
+ }) as unknown as typeof fetch);
149
+
150
+ try {
151
+ const userId = "user-456";
152
+ const response = await fetch("https://graph.microsoft.com/v1.0/chats", {
153
+ method: "POST",
154
+ headers: {
155
+ Authorization: "Bearer test-token",
156
+ "Content-Type": "application/json",
157
+ },
158
+ body: JSON.stringify({
159
+ chatType: "oneOnOne",
160
+ members: [
161
+ {
162
+ "@odata.type": "#microsoft.graph.aadUserConversationMember",
163
+ roles: ["owner"],
164
+ "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${userId}')`,
165
+ },
166
+ ],
167
+ }),
168
+ });
169
+
170
+ expect(capturedUrl).toBe("https://graph.microsoft.com/v1.0/chats");
171
+
172
+ const parsedBody = JSON.parse(capturedBody!);
173
+ expect(parsedBody.chatType).toBe("oneOnOne");
174
+ expect(parsedBody.members).toHaveLength(1);
175
+ expect(parsedBody.members[0]["user@odata.bind"]).toContain(userId);
176
+
177
+ const result = await response.json();
178
+ expect(result.id).toBe("chat-123");
179
+ } finally {
180
+ mockFetch.mockRestore();
181
+ }
182
+ });
183
+
184
+ it("sends message with adaptive card attachment", async () => {
185
+ let capturedBody: string | undefined;
186
+
187
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
188
+ _url: RequestInfo | URL,
189
+ options?: RequestInit
190
+ ) => {
191
+ capturedBody = options?.body as string;
192
+ return new Response(JSON.stringify({ id: "msg-789" }), {
193
+ status: 200,
194
+ });
195
+ }) as unknown as typeof fetch);
196
+
197
+ try {
198
+ const chatId = "chat-123";
199
+ const adaptiveCard = buildAdaptiveCard({
200
+ title: "Test",
201
+ importance: "info",
202
+ });
203
+
204
+ await fetch(
205
+ `https://graph.microsoft.com/v1.0/chats/${chatId}/messages`,
206
+ {
207
+ method: "POST",
208
+ headers: {
209
+ Authorization: "Bearer test-token",
210
+ "Content-Type": "application/json",
211
+ },
212
+ body: JSON.stringify({
213
+ body: {
214
+ contentType: "html",
215
+ content: `<attachment id="adaptiveCard"></attachment>`,
216
+ },
217
+ attachments: [
218
+ {
219
+ id: "adaptiveCard",
220
+ contentType: "application/vnd.microsoft.card.adaptive",
221
+ content: JSON.stringify(adaptiveCard),
222
+ },
223
+ ],
224
+ }),
225
+ }
226
+ );
227
+
228
+ const parsedBody = JSON.parse(capturedBody!);
229
+ expect(parsedBody.body.contentType).toBe("html");
230
+ expect(parsedBody.attachments).toHaveLength(1);
231
+ expect(parsedBody.attachments[0].contentType).toBe(
232
+ "application/vnd.microsoft.card.adaptive"
233
+ );
234
+
235
+ const cardContent = JSON.parse(parsedBody.attachments[0].content);
236
+ expect(cardContent.type).toBe("AdaptiveCard");
237
+ } finally {
238
+ mockFetch.mockRestore();
239
+ }
240
+ });
241
+
242
+ it("handles API errors gracefully", async () => {
243
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
244
+ (async () => {
245
+ return new Response(
246
+ JSON.stringify({
247
+ error: { code: "Forbidden", message: "Access denied" },
248
+ }),
249
+ { status: 403 }
250
+ );
251
+ }) as unknown as typeof fetch
252
+ );
253
+
254
+ try {
255
+ const response = await fetch("https://graph.microsoft.com/v1.0/chats", {
256
+ method: "POST",
257
+ headers: {
258
+ Authorization: "Bearer invalid-token",
259
+ "Content-Type": "application/json",
260
+ },
261
+ body: JSON.stringify({ chatType: "oneOnOne", members: [] }),
262
+ });
263
+
264
+ expect(response.ok).toBe(false);
265
+ expect(response.status).toBe(403);
266
+ } finally {
267
+ mockFetch.mockRestore();
268
+ }
269
+ });
270
+ });
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────
273
+ // OAuth URL Building
274
+ // ─────────────────────────────────────────────────────────────────────────
275
+
276
+ describe("OAuth URLs", () => {
277
+ it("builds correct authorization URL format", () => {
278
+ const tenantId = "12345678-1234-1234-1234-123456789abc";
279
+ const expectedAuthUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
280
+ const expectedTokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
281
+
282
+ expect(expectedAuthUrl).toContain("login.microsoftonline.com");
283
+ expect(expectedAuthUrl).toContain(tenantId);
284
+ expect(expectedAuthUrl).toContain("oauth2/v2.0/authorize");
285
+
286
+ expect(expectedTokenUrl).toContain("login.microsoftonline.com");
287
+ expect(expectedTokenUrl).toContain("oauth2/v2.0/token");
288
+ });
289
+
290
+ it("uses correct scopes for Teams messaging", () => {
291
+ const requiredScopes = ["Chat.ReadWrite", "User.Read", "offline_access"];
292
+
293
+ expect(requiredScopes).toContain("Chat.ReadWrite");
294
+ expect(requiredScopes).toContain("User.Read");
295
+ expect(requiredScopes).toContain("offline_access");
296
+ });
297
+ });
298
+ });
package/src/index.ts ADDED
@@ -0,0 +1,379 @@
1
+ import { z } from "zod";
2
+ import {
3
+ createBackendPlugin,
4
+ configString,
5
+ Versioned,
6
+ type NotificationStrategy,
7
+ type NotificationSendContext,
8
+ type NotificationDeliveryResult,
9
+ type StrategyOAuthConfig,
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 Microsoft Teams strategy.
20
+ * Uses Azure AD App for OAuth authentication.
21
+ */
22
+ const teamsConfigSchemaV1 = z.object({
23
+ tenantId: configString({}).describe("Azure AD Tenant ID"),
24
+ clientId: configString({}).describe("Azure AD Application (Client) ID"),
25
+ clientSecret: configString({ "x-secret": true }).describe(
26
+ "Azure AD Client Secret"
27
+ ),
28
+ });
29
+
30
+ type TeamsConfig = z.infer<typeof teamsConfigSchemaV1>;
31
+
32
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33
+ // Instructions
34
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
35
+
36
+ const adminInstructions = `
37
+ ## Register an Azure AD Application
38
+
39
+ 1. Go to [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** (formerly Azure AD)
40
+ 2. Navigate to **App registrations** → **New registration**
41
+ 3. Fill in the application details:
42
+ - **Name**: Your notification bot name (e.g., "Checkstack Alerts")
43
+ - **Supported account types**: Choose based on your tenant requirements
44
+ - **Redirect URI**: Select "Web" and enter: \`{YOUR_BASE_URL}/api/notification/oauth/callback/teams\`
45
+ 4. Click **Register**
46
+
47
+ ## Configure API Permissions
48
+
49
+ 1. In your app registration, go to **API permissions**
50
+ 2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions**
51
+ 3. Add these permissions:
52
+ - \`Chat.ReadWrite\` (to create and send to 1:1 chats)
53
+ - \`User.Read\` (to get user information)
54
+ - \`offline_access\` (for refresh tokens)
55
+ 4. Click **Grant admin consent** if required by your organization
56
+
57
+ ## Create Client Secret
58
+
59
+ 1. Go to **Certificates & secrets** → **Client secrets** → **New client secret**
60
+ 2. Enter a description and choose an expiration period
61
+ 3. Click **Add** and copy the secret value immediately (it won't be shown again)
62
+
63
+ ## Enter Credentials
64
+
65
+ Copy the following values from your app registration:
66
+ - **Tenant ID**: Found in **Overview** → **Directory (tenant) ID**
67
+ - **Client ID**: Found in **Overview** → **Application (client) ID**
68
+ - **Client Secret**: The value you just created
69
+ `.trim();
70
+
71
+ const userInstructions = `
72
+ ## Connect Your Microsoft Account
73
+
74
+ 1. Click the **Connect** button below
75
+ 2. Sign in with your Microsoft work or school account
76
+ 3. Review and accept the requested permissions
77
+ 4. You'll be redirected back automatically
78
+
79
+ Once connected, you'll receive notifications as personal chat messages from the Checkstack bot in Microsoft Teams.
80
+
81
+ > **Note**: Make sure you're signed into the correct Microsoft account that has access to Microsoft Teams.
82
+ `.trim();
83
+
84
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
+ // Microsoft Graph API Constants
86
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
87
+
88
+ const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0";
89
+
90
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
91
+ // Adaptive Card Builder
92
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
93
+
94
+ interface AdaptiveCardOptions {
95
+ title: string;
96
+ body?: string;
97
+ importance: "info" | "warning" | "critical";
98
+ action?: { label: string; url: string };
99
+ }
100
+
101
+ function buildAdaptiveCard(options: AdaptiveCardOptions): object {
102
+ const { title, body, importance, action } = options;
103
+
104
+ const importanceColors: Record<string, string> = {
105
+ info: "accent",
106
+ warning: "warning",
107
+ critical: "attention",
108
+ };
109
+
110
+ const importanceEmoji: Record<string, string> = {
111
+ info: "ℹ️",
112
+ warning: "⚠️",
113
+ critical: "🚨",
114
+ };
115
+
116
+ const bodyElements: object[] = [
117
+ {
118
+ type: "TextBlock",
119
+ text: `${importanceEmoji[importance]} ${title}`,
120
+ weight: "bolder",
121
+ size: "large",
122
+ wrap: true,
123
+ color: importanceColors[importance],
124
+ },
125
+ ];
126
+
127
+ if (body) {
128
+ bodyElements.push({
129
+ type: "TextBlock",
130
+ text: body,
131
+ wrap: true,
132
+ });
133
+ }
134
+
135
+ const card: Record<string, unknown> = {
136
+ type: "AdaptiveCard",
137
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
138
+ version: "1.4",
139
+ body: bodyElements,
140
+ };
141
+
142
+ if (action?.url) {
143
+ card.actions = [
144
+ {
145
+ type: "Action.OpenUrl",
146
+ title: action.label,
147
+ url: action.url,
148
+ },
149
+ ];
150
+ }
151
+
152
+ return card;
153
+ }
154
+
155
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
156
+ // Teams Strategy Implementation
157
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
158
+
159
+ /**
160
+ * Microsoft Teams notification strategy.
161
+ * Sends notifications as personal chat messages via Microsoft Graph API.
162
+ */
163
+ const teamsStrategy: NotificationStrategy<TeamsConfig> = {
164
+ id: "teams",
165
+ displayName: "Microsoft Teams",
166
+ description: "Send notifications via Microsoft Teams personal chat",
167
+ icon: "MessageSquareMore",
168
+
169
+ config: new Versioned({
170
+ version: 1,
171
+ schema: teamsConfigSchemaV1,
172
+ }),
173
+
174
+ // OAuth-based resolution - users authenticate via Microsoft
175
+ contactResolution: { type: "oauth-link" },
176
+
177
+ adminInstructions,
178
+ userInstructions,
179
+
180
+ // OAuth configuration for Microsoft identity platform
181
+ // All functions receive the strategy config - no module-scoped variables needed
182
+ oauth: {
183
+ clientId: (config) => config.clientId ?? "",
184
+ clientSecret: (config) => config.clientSecret ?? "",
185
+ scopes: ["Chat.ReadWrite", "User.Read", "offline_access"],
186
+ authorizationUrl: (config) => {
187
+ // "common" works for multi-tenant Azure AD apps
188
+ const tenantId = config.tenantId ?? "common";
189
+ return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
190
+ },
191
+ tokenUrl: (config) => {
192
+ const tenantId = config.tenantId ?? "common";
193
+ return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
194
+ },
195
+ extractExternalId: (response: Record<string, unknown>) => {
196
+ // Microsoft returns user info we need to extract from Graph API
197
+ // The ID token contains the user's object ID
198
+ const idTokenClaims = response.id_token as string | undefined;
199
+ if (idTokenClaims) {
200
+ try {
201
+ // Decode JWT payload (base64url)
202
+ const parts = idTokenClaims.split(".");
203
+ if (parts.length === 3) {
204
+ const payload = JSON.parse(
205
+ Buffer.from(parts[1], "base64url").toString()
206
+ ) as { oid?: string };
207
+ if (payload.oid) {
208
+ return payload.oid;
209
+ }
210
+ }
211
+ } catch {
212
+ // Fall through to use sub claim
213
+ }
214
+ }
215
+ // Fallback to access token parsing or sub claim
216
+ const sub = response.sub as string | undefined;
217
+ return sub ?? "";
218
+ },
219
+ extractAccessToken: (response: Record<string, unknown>) =>
220
+ response.access_token as string,
221
+ extractRefreshToken: (response: Record<string, unknown>) =>
222
+ response.refresh_token as string | undefined,
223
+ extractExpiresIn: (response: Record<string, unknown>) =>
224
+ response.expires_in as number | undefined,
225
+ } satisfies StrategyOAuthConfig<TeamsConfig>,
226
+
227
+ async send(
228
+ context: NotificationSendContext<TeamsConfig>
229
+ ): Promise<NotificationDeliveryResult> {
230
+ const { notification, strategyConfig, logger } = context;
231
+
232
+ if (!strategyConfig.clientId || !strategyConfig.clientSecret) {
233
+ return {
234
+ success: false,
235
+ error: "Microsoft Teams OAuth not configured",
236
+ };
237
+ }
238
+
239
+ // Get the user's access token from context (provided by OAuth system)
240
+ const oauthContext = context as unknown as {
241
+ accessToken?: string;
242
+ externalId?: string;
243
+ };
244
+
245
+ if (!oauthContext.accessToken) {
246
+ return {
247
+ success: false,
248
+ error: "User has not connected their Microsoft account",
249
+ };
250
+ }
251
+
252
+ if (!oauthContext.externalId) {
253
+ return {
254
+ success: false,
255
+ error: "Could not determine user's Microsoft ID",
256
+ };
257
+ }
258
+
259
+ try {
260
+ // Step 1: Create or get the 1:1 chat with the user
261
+ const chatResponse = await fetch(`${GRAPH_API_BASE}/chats`, {
262
+ method: "POST",
263
+ headers: {
264
+ Authorization: `Bearer ${oauthContext.accessToken}`,
265
+ "Content-Type": "application/json",
266
+ },
267
+ body: JSON.stringify({
268
+ chatType: "oneOnOne",
269
+ members: [
270
+ {
271
+ "@odata.type": "#microsoft.graph.aadUserConversationMember",
272
+ roles: ["owner"],
273
+ "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${oauthContext.externalId}')`,
274
+ },
275
+ ],
276
+ }),
277
+ signal: AbortSignal.timeout(10_000),
278
+ });
279
+
280
+ if (!chatResponse.ok) {
281
+ const errorText = await chatResponse.text();
282
+ logger.error("Failed to create/get Teams chat", {
283
+ status: chatResponse.status,
284
+ error: errorText.slice(0, 500),
285
+ });
286
+ return {
287
+ success: false,
288
+ error: `Failed to create Teams chat: ${chatResponse.status}`,
289
+ };
290
+ }
291
+
292
+ const chatData = (await chatResponse.json()) as { id: string };
293
+ const chatId = chatData.id;
294
+
295
+ // Step 2: Build adaptive card for the message
296
+ const card = buildAdaptiveCard({
297
+ title: notification.title,
298
+ body: notification.body,
299
+ importance: notification.importance,
300
+ action: notification.action,
301
+ });
302
+
303
+ // Step 3: Send message to the chat
304
+ const messageResponse = await fetch(
305
+ `${GRAPH_API_BASE}/chats/${chatId}/messages`,
306
+ {
307
+ method: "POST",
308
+ headers: {
309
+ Authorization: `Bearer ${oauthContext.accessToken}`,
310
+ "Content-Type": "application/json",
311
+ },
312
+ body: JSON.stringify({
313
+ body: {
314
+ contentType: "html",
315
+ content: `<attachment id="adaptiveCard"></attachment>`,
316
+ },
317
+ attachments: [
318
+ {
319
+ id: "adaptiveCard",
320
+ contentType: "application/vnd.microsoft.card.adaptive",
321
+ content: JSON.stringify(card),
322
+ },
323
+ ],
324
+ }),
325
+ signal: AbortSignal.timeout(10_000),
326
+ }
327
+ );
328
+
329
+ if (!messageResponse.ok) {
330
+ const errorText = await messageResponse.text();
331
+ logger.error("Failed to send Teams message", {
332
+ status: messageResponse.status,
333
+ error: errorText.slice(0, 500),
334
+ });
335
+ return {
336
+ success: false,
337
+ error: `Failed to send Teams message: ${messageResponse.status}`,
338
+ };
339
+ }
340
+
341
+ const messageData = (await messageResponse.json()) as { id: string };
342
+
343
+ return {
344
+ success: true,
345
+ externalId: messageData.id,
346
+ };
347
+ } catch (error) {
348
+ const message =
349
+ error instanceof Error ? error.message : "Unknown Graph API error";
350
+ logger.error("Teams notification error", { error: message });
351
+ return {
352
+ success: false,
353
+ error: `Failed to send Teams notification: ${message}`,
354
+ };
355
+ }
356
+ },
357
+ };
358
+
359
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
360
+ // Plugin Definition
361
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
362
+
363
+ export default createBackendPlugin({
364
+ metadata: pluginMetadata,
365
+
366
+ register(env) {
367
+ // Get the notification strategy extension point
368
+ const extensionPoint = env.getExtensionPoint(
369
+ notificationStrategyExtensionPoint
370
+ );
371
+
372
+ // Register the Teams strategy with our plugin metadata
373
+ extensionPoint.addStrategy(teamsStrategy, pluginMetadata);
374
+ },
375
+ });
376
+
377
+ // Export for testing
378
+ export { teamsConfigSchemaV1, buildAdaptiveCard };
379
+ export type { TeamsConfig, AdaptiveCardOptions };
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "notification-teams",
5
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }