@checkstack/integration-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,33 @@
1
+ # @checkstack/integration-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/integration-backend@0.0.2
12
+
13
+ ## 0.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 4c5aa9e: Add Microsoft Teams integration provider - sends events to Teams channels via Graph API
18
+
19
+ - Connection schema for Azure AD App credentials (Tenant ID, Client ID, Client Secret)
20
+ - Dynamic team/channel selection via Graph API
21
+ - Adaptive Cards for rich event display
22
+ - Client credentials flow for app-only authentication
23
+ - Comprehensive documentation for Azure AD setup and permissions
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [4c5aa9e]
28
+ - Updated dependencies [b4eb432]
29
+ - Updated dependencies [a65e002]
30
+ - Updated dependencies [a65e002]
31
+ - @checkstack/integration-backend@0.1.0
32
+ - @checkstack/backend-api@1.1.0
33
+ - @checkstack/common@0.2.0
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@checkstack/integration-teams-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/backend-api": "workspace:*",
13
+ "@checkstack/integration-backend": "workspace:*",
14
+ "@checkstack/common": "workspace:*",
15
+ "zod": "^4.2.1"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.0.0",
19
+ "typescript": "^5.0.0",
20
+ "@checkstack/tsconfig": "workspace:*"
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { createBackendPlugin } from "@checkstack/backend-api";
2
+ import { providerExtensionPoint } from "@checkstack/integration-backend";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import { teamsProvider } from "./provider";
5
+
6
+ export default createBackendPlugin({
7
+ metadata: pluginMetadata,
8
+
9
+ register(env) {
10
+ // Get the integration provider extension point
11
+ const extensionPoint = env.getExtensionPoint(providerExtensionPoint);
12
+
13
+ // Register the Teams provider with our plugin metadata
14
+ extensionPoint.addProvider(teamsProvider, pluginMetadata);
15
+ },
16
+ });
17
+
18
+ // Re-export for testing
19
+ export {
20
+ teamsProvider,
21
+ TeamsConnectionSchema,
22
+ TeamsSubscriptionSchema,
23
+ buildAdaptiveCard,
24
+ } from "./provider";
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "integration-teams",
5
+ };
@@ -0,0 +1,486 @@
1
+ import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
2
+ import {
3
+ teamsProvider,
4
+ TeamsConnectionSchema,
5
+ TeamsSubscriptionSchema,
6
+ buildAdaptiveCard,
7
+ } from "./index";
8
+
9
+ /**
10
+ * Unit tests for the Microsoft Teams Integration Provider.
11
+ *
12
+ * Tests cover:
13
+ * - Config schema validation
14
+ * - Connection testing
15
+ * - Team/channel options resolution
16
+ * - Adaptive Card building
17
+ * - Event delivery
18
+ */
19
+
20
+ // Mock logger
21
+ const mockLogger = {
22
+ debug: mock(() => {}),
23
+ info: mock(() => {}),
24
+ warn: mock(() => {}),
25
+ error: mock(() => {}),
26
+ };
27
+
28
+ describe("Microsoft Teams Integration Provider", () => {
29
+ beforeEach(() => {
30
+ mockLogger.debug.mockClear();
31
+ mockLogger.info.mockClear();
32
+ mockLogger.warn.mockClear();
33
+ mockLogger.error.mockClear();
34
+ });
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────
37
+ // Provider Metadata
38
+ // ─────────────────────────────────────────────────────────────────────────
39
+
40
+ describe("metadata", () => {
41
+ it("has correct basic metadata", () => {
42
+ expect(teamsProvider.id).toBe("teams");
43
+ expect(teamsProvider.displayName).toBe("Microsoft Teams");
44
+ expect(teamsProvider.description).toContain("Teams");
45
+ expect(teamsProvider.icon).toBe("MessageSquareMore");
46
+ });
47
+
48
+ it("has versioned config and connection schemas", () => {
49
+ expect(teamsProvider.config).toBeDefined();
50
+ expect(teamsProvider.config.version).toBe(1);
51
+ expect(teamsProvider.connectionSchema).toBeDefined();
52
+ expect(teamsProvider.connectionSchema?.version).toBe(1);
53
+ });
54
+
55
+ it("has documentation", () => {
56
+ expect(teamsProvider.documentation).toBeDefined();
57
+ expect(teamsProvider.documentation?.setupGuide).toContain("Azure");
58
+ });
59
+ });
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────
62
+ // Config Schema Validation
63
+ // ─────────────────────────────────────────────────────────────────────────
64
+
65
+ describe("connection schema", () => {
66
+ it("requires all credentials", () => {
67
+ expect(() => {
68
+ TeamsConnectionSchema.parse({});
69
+ }).toThrow();
70
+
71
+ expect(() => {
72
+ TeamsConnectionSchema.parse({
73
+ tenantId: "tenant-1",
74
+ clientId: "client-1",
75
+ });
76
+ }).toThrow();
77
+ });
78
+
79
+ it("accepts valid connection config", () => {
80
+ const result = TeamsConnectionSchema.parse({
81
+ tenantId: "12345678-1234-1234-1234-123456789abc",
82
+ clientId: "87654321-4321-4321-4321-cba987654321",
83
+ clientSecret: "super-secret",
84
+ });
85
+ expect(result.tenantId).toBe("12345678-1234-1234-1234-123456789abc");
86
+ expect(result.clientId).toBe("87654321-4321-4321-4321-cba987654321");
87
+ expect(result.clientSecret).toBe("super-secret");
88
+ });
89
+ });
90
+
91
+ describe("subscription schema", () => {
92
+ it("requires all fields", () => {
93
+ expect(() => {
94
+ TeamsSubscriptionSchema.parse({});
95
+ }).toThrow();
96
+
97
+ expect(() => {
98
+ TeamsSubscriptionSchema.parse({ connectionId: "conn-1" });
99
+ }).toThrow();
100
+
101
+ expect(() => {
102
+ TeamsSubscriptionSchema.parse({
103
+ connectionId: "conn-1",
104
+ teamId: "team-1",
105
+ });
106
+ }).toThrow();
107
+ });
108
+
109
+ it("accepts valid subscription config", () => {
110
+ const result = TeamsSubscriptionSchema.parse({
111
+ connectionId: "conn-1",
112
+ teamId: "team-123",
113
+ channelId: "channel-456",
114
+ });
115
+ expect(result.connectionId).toBe("conn-1");
116
+ expect(result.teamId).toBe("team-123");
117
+ expect(result.channelId).toBe("channel-456");
118
+ });
119
+ });
120
+
121
+ // ─────────────────────────────────────────────────────────────────────────
122
+ // Adaptive Card Building
123
+ // ─────────────────────────────────────────────────────────────────────────
124
+
125
+ describe("adaptive card builder", () => {
126
+ it("builds card with event details", () => {
127
+ const card = buildAdaptiveCard({
128
+ eventId: "incident.created",
129
+ payload: { incidentId: "inc-123", severity: "critical" },
130
+ subscriptionName: "Critical Incidents",
131
+ timestamp: "2024-01-15T10:30:00Z",
132
+ }) as Record<string, unknown>;
133
+
134
+ expect(card.type).toBe("AdaptiveCard");
135
+ expect(card.version).toBe("1.4");
136
+
137
+ const body = card.body as Array<Record<string, unknown>>;
138
+ expect(body.length).toBeGreaterThan(0);
139
+
140
+ // Check for event info in FactSet
141
+ const factSet = body.find((b) => b.type === "FactSet") as Record<
142
+ string,
143
+ unknown
144
+ >;
145
+ expect(factSet).toBeDefined();
146
+
147
+ const facts = factSet.facts as Array<{ title: string; value: string }>;
148
+ expect(facts.some((f) => f.value === "incident.created")).toBe(true);
149
+ expect(facts.some((f) => f.value === "Critical Incidents")).toBe(true);
150
+ });
151
+
152
+ it("includes JSON payload in code block", () => {
153
+ const card = buildAdaptiveCard({
154
+ eventId: "test.event",
155
+ payload: { key: "value" },
156
+ subscriptionName: "Test",
157
+ timestamp: new Date().toISOString(),
158
+ }) as Record<string, unknown>;
159
+
160
+ const body = card.body as Array<Record<string, unknown>>;
161
+ const codeBlock = body.find((b) => b.fontType === "monospace") as Record<
162
+ string,
163
+ unknown
164
+ >;
165
+
166
+ expect(codeBlock).toBeDefined();
167
+ expect(codeBlock.text).toContain('"key"');
168
+ expect(codeBlock.text).toContain('"value"');
169
+ });
170
+ });
171
+
172
+ // ─────────────────────────────────────────────────────────────────────────
173
+ // Test Connection
174
+ // ─────────────────────────────────────────────────────────────────────────
175
+
176
+ describe("testConnection", () => {
177
+ it("returns success when Graph API is accessible", async () => {
178
+ let requestCount = 0;
179
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
180
+ url: RequestInfo | URL
181
+ ) => {
182
+ requestCount++;
183
+ const urlStr = url.toString();
184
+
185
+ // Token request
186
+ if (urlStr.includes("oauth2/v2.0/token")) {
187
+ return new Response(
188
+ JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
189
+ { status: 200 }
190
+ );
191
+ }
192
+
193
+ // Teams list request
194
+ if (urlStr.includes("/teams")) {
195
+ return new Response(
196
+ JSON.stringify({
197
+ value: [
198
+ { id: "team-1", displayName: "Engineering" },
199
+ { id: "team-2", displayName: "DevOps" },
200
+ ],
201
+ }),
202
+ { status: 200 }
203
+ );
204
+ }
205
+
206
+ return new Response("Not Found", { status: 404 });
207
+ }) as unknown as typeof fetch);
208
+
209
+ try {
210
+ const result = await teamsProvider.testConnection!({
211
+ tenantId: "tenant-123",
212
+ clientId: "client-123",
213
+ clientSecret: "secret-123",
214
+ });
215
+
216
+ expect(result.success).toBe(true);
217
+ expect(result.message).toContain("2 team(s)");
218
+ } finally {
219
+ mockFetch.mockRestore();
220
+ }
221
+ });
222
+
223
+ it("returns failure for auth errors", async () => {
224
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
225
+ (async () => {
226
+ return new Response("Unauthorized", { status: 401 });
227
+ }) as unknown as typeof fetch
228
+ );
229
+
230
+ try {
231
+ const result = await teamsProvider.testConnection!({
232
+ tenantId: "tenant-123",
233
+ clientId: "client-123",
234
+ clientSecret: "wrong-secret",
235
+ });
236
+
237
+ expect(result.success).toBe(false);
238
+ expect(result.message).toContain("failed");
239
+ } finally {
240
+ mockFetch.mockRestore();
241
+ }
242
+ });
243
+ });
244
+
245
+ // ─────────────────────────────────────────────────────────────────────────
246
+ // Get Connection Options
247
+ // ─────────────────────────────────────────────────────────────────────────
248
+
249
+ describe("getConnectionOptions", () => {
250
+ it("returns team options", async () => {
251
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
252
+ url: RequestInfo | URL
253
+ ) => {
254
+ const urlStr = url.toString();
255
+
256
+ if (urlStr.includes("oauth2/v2.0/token")) {
257
+ return new Response(
258
+ JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
259
+ { status: 200 }
260
+ );
261
+ }
262
+
263
+ if (urlStr.includes("/teams") && !urlStr.includes("/channels")) {
264
+ return new Response(
265
+ JSON.stringify({
266
+ value: [
267
+ { id: "team-1", displayName: "Engineering" },
268
+ { id: "team-2", displayName: "DevOps" },
269
+ ],
270
+ }),
271
+ { status: 200 }
272
+ );
273
+ }
274
+
275
+ return new Response("Not Found", { status: 404 });
276
+ }) as unknown as typeof fetch);
277
+
278
+ try {
279
+ const options = await teamsProvider.getConnectionOptions!({
280
+ resolverName: "teamOptions",
281
+ connectionId: "conn-1",
282
+ context: {},
283
+ logger: mockLogger,
284
+ getConnectionWithCredentials: async () => ({
285
+ config: {
286
+ tenantId: "t",
287
+ clientId: "c",
288
+ clientSecret: "s",
289
+ },
290
+ }),
291
+ });
292
+
293
+ expect(options).toHaveLength(2);
294
+ expect(options[0]).toEqual({ value: "team-1", label: "Engineering" });
295
+ expect(options[1]).toEqual({ value: "team-2", label: "DevOps" });
296
+ } finally {
297
+ mockFetch.mockRestore();
298
+ }
299
+ });
300
+
301
+ it("returns channel options when teamId is provided", async () => {
302
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
303
+ url: RequestInfo | URL
304
+ ) => {
305
+ const urlStr = url.toString();
306
+
307
+ if (urlStr.includes("oauth2/v2.0/token")) {
308
+ return new Response(
309
+ JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
310
+ { status: 200 }
311
+ );
312
+ }
313
+
314
+ if (urlStr.includes("/channels")) {
315
+ return new Response(
316
+ JSON.stringify({
317
+ value: [
318
+ { id: "ch-1", displayName: "General" },
319
+ { id: "ch-2", displayName: "Alerts" },
320
+ ],
321
+ }),
322
+ { status: 200 }
323
+ );
324
+ }
325
+
326
+ return new Response("Not Found", { status: 404 });
327
+ }) as unknown as typeof fetch);
328
+
329
+ try {
330
+ const options = await teamsProvider.getConnectionOptions!({
331
+ resolverName: "channelOptions",
332
+ connectionId: "conn-1",
333
+ context: { teamId: "team-1" },
334
+ logger: mockLogger,
335
+ getConnectionWithCredentials: async () => ({
336
+ config: {
337
+ tenantId: "t",
338
+ clientId: "c",
339
+ clientSecret: "s",
340
+ },
341
+ }),
342
+ });
343
+
344
+ expect(options).toHaveLength(2);
345
+ expect(options[0]).toEqual({ value: "ch-1", label: "General" });
346
+ expect(options[1]).toEqual({ value: "ch-2", label: "Alerts" });
347
+ } finally {
348
+ mockFetch.mockRestore();
349
+ }
350
+ });
351
+
352
+ it("returns empty array when teamId is missing for channel options", async () => {
353
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
354
+ url: RequestInfo | URL
355
+ ) => {
356
+ const urlStr = url.toString();
357
+ if (urlStr.includes("oauth2/v2.0/token")) {
358
+ return new Response(
359
+ JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
360
+ { status: 200 }
361
+ );
362
+ }
363
+ return new Response("Not Found", { status: 404 });
364
+ }) as unknown as typeof fetch);
365
+
366
+ try {
367
+ const options = await teamsProvider.getConnectionOptions!({
368
+ resolverName: "channelOptions",
369
+ connectionId: "conn-1",
370
+ context: {}, // No teamId
371
+ logger: mockLogger,
372
+ getConnectionWithCredentials: async () => ({
373
+ config: {
374
+ tenantId: "t",
375
+ clientId: "c",
376
+ clientSecret: "s",
377
+ },
378
+ }),
379
+ });
380
+
381
+ expect(options).toEqual([]);
382
+ } finally {
383
+ mockFetch.mockRestore();
384
+ }
385
+ });
386
+ });
387
+
388
+ // ─────────────────────────────────────────────────────────────────────────
389
+ // Delivery
390
+ // ─────────────────────────────────────────────────────────────────────────
391
+
392
+ describe("deliver", () => {
393
+ it("sends message to Teams channel successfully", async () => {
394
+ let capturedMessageUrl: string | undefined;
395
+ let capturedBody: string | undefined;
396
+
397
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
398
+ url: RequestInfo | URL,
399
+ options?: RequestInit
400
+ ) => {
401
+ const urlStr = url.toString();
402
+
403
+ if (urlStr.includes("oauth2/v2.0/token")) {
404
+ return new Response(
405
+ JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
406
+ { status: 200 }
407
+ );
408
+ }
409
+
410
+ if (urlStr.includes("/messages")) {
411
+ capturedMessageUrl = urlStr;
412
+ capturedBody = options?.body as string;
413
+ return new Response(JSON.stringify({ id: "msg-123" }), {
414
+ status: 200,
415
+ });
416
+ }
417
+
418
+ return new Response("Not Found", { status: 404 });
419
+ }) as unknown as typeof fetch);
420
+
421
+ try {
422
+ const result = await teamsProvider.deliver({
423
+ event: {
424
+ eventId: "incident.created",
425
+ payload: { incidentId: "inc-123" },
426
+ timestamp: new Date().toISOString(),
427
+ deliveryId: "del-789",
428
+ },
429
+ subscription: {
430
+ id: "sub-1",
431
+ name: "Incident Alerts",
432
+ },
433
+ providerConfig: {
434
+ connectionId: "conn-1",
435
+ teamId: "team-abc",
436
+ channelId: "channel-xyz",
437
+ },
438
+ logger: mockLogger,
439
+ getConnectionWithCredentials: async () => ({
440
+ id: "conn-1",
441
+ config: {
442
+ tenantId: "t",
443
+ clientId: "c",
444
+ clientSecret: "s",
445
+ },
446
+ }),
447
+ });
448
+
449
+ expect(result.success).toBe(true);
450
+ expect(result.externalId).toBe("msg-123");
451
+ expect(capturedMessageUrl).toContain("team-abc");
452
+ expect(capturedMessageUrl).toContain("channel-xyz");
453
+
454
+ const parsedBody = JSON.parse(capturedBody!);
455
+ expect(parsedBody.attachments).toHaveLength(1);
456
+ expect(parsedBody.attachments[0].contentType).toBe(
457
+ "application/vnd.microsoft.card.adaptive"
458
+ );
459
+ } finally {
460
+ mockFetch.mockRestore();
461
+ }
462
+ });
463
+
464
+ it("returns error when connection not found", async () => {
465
+ const result = await teamsProvider.deliver({
466
+ event: {
467
+ eventId: "test.event",
468
+ payload: {},
469
+ timestamp: new Date().toISOString(),
470
+ deliveryId: "del-1",
471
+ },
472
+ subscription: { id: "sub-1", name: "Test" },
473
+ providerConfig: {
474
+ connectionId: "nonexistent",
475
+ teamId: "team-1",
476
+ channelId: "ch-1",
477
+ },
478
+ logger: mockLogger,
479
+ getConnectionWithCredentials: async () => undefined,
480
+ });
481
+
482
+ expect(result.success).toBe(false);
483
+ expect(result.error).toContain("not found");
484
+ });
485
+ });
486
+ });
@@ -0,0 +1,487 @@
1
+ import { z } from "zod";
2
+ import { configString, Versioned } from "@checkstack/backend-api";
3
+ import type {
4
+ IntegrationProvider,
5
+ IntegrationDeliveryContext,
6
+ IntegrationDeliveryResult,
7
+ GetConnectionOptionsParams,
8
+ ConnectionOption,
9
+ TestConnectionResult,
10
+ } from "@checkstack/integration-backend";
11
+
12
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13
+ // Resolver Names
14
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+
16
+ const TEAMS_RESOLVERS = {
17
+ TEAM_OPTIONS: "teamOptions",
18
+ CHANNEL_OPTIONS: "channelOptions",
19
+ } as const;
20
+
21
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
+ // Configuration Schemas
23
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
24
+
25
+ /**
26
+ * Connection configuration - Azure AD App credentials for Graph API.
27
+ */
28
+ export const TeamsConnectionSchema = z.object({
29
+ tenantId: configString({}).describe("Azure AD Tenant ID"),
30
+ clientId: configString({}).describe("Azure AD Application (Client) ID"),
31
+ clientSecret: configString({ "x-secret": true }).describe(
32
+ "Azure AD Client Secret"
33
+ ),
34
+ });
35
+
36
+ export type TeamsConnectionConfig = z.infer<typeof TeamsConnectionSchema>;
37
+
38
+ /**
39
+ * Subscription configuration - which Teams channel to send events to.
40
+ */
41
+ export const TeamsSubscriptionSchema = z.object({
42
+ connectionId: configString({ "x-hidden": true }).describe("Teams connection"),
43
+ teamId: configString({
44
+ "x-options-resolver": TEAMS_RESOLVERS.TEAM_OPTIONS,
45
+ "x-depends-on": ["connectionId"],
46
+ }).describe("Target Team"),
47
+ channelId: configString({
48
+ "x-options-resolver": TEAMS_RESOLVERS.CHANNEL_OPTIONS,
49
+ "x-depends-on": ["connectionId", "teamId"],
50
+ }).describe("Target Channel"),
51
+ });
52
+
53
+ export type TeamsSubscriptionConfig = z.infer<typeof TeamsSubscriptionSchema>;
54
+
55
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56
+ // Graph API Types and Client
57
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
58
+
59
+ const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0";
60
+
61
+ interface GraphTeam {
62
+ id: string;
63
+ displayName: string;
64
+ }
65
+
66
+ interface GraphChannel {
67
+ id: string;
68
+ displayName: string;
69
+ }
70
+
71
+ interface GraphTeamsResponse {
72
+ value: GraphTeam[];
73
+ }
74
+
75
+ interface GraphChannelsResponse {
76
+ value: GraphChannel[];
77
+ }
78
+
79
+ interface GraphMessageResponse {
80
+ id: string;
81
+ }
82
+
83
+ interface TokenResponse {
84
+ access_token: string;
85
+ expires_in: number;
86
+ }
87
+
88
+ /**
89
+ * Get an app-only access token using client credentials flow.
90
+ */
91
+ async function getAppToken(
92
+ config: TeamsConnectionConfig
93
+ ): Promise<
94
+ { success: true; token: string } | { success: false; error: string }
95
+ > {
96
+ try {
97
+ const tokenUrl = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
98
+
99
+ const response = await fetch(tokenUrl, {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/x-www-form-urlencoded",
103
+ },
104
+ body: new URLSearchParams({
105
+ client_id: config.clientId,
106
+ client_secret: config.clientSecret,
107
+ scope: "https://graph.microsoft.com/.default",
108
+ grant_type: "client_credentials",
109
+ }),
110
+ signal: AbortSignal.timeout(10_000),
111
+ });
112
+
113
+ if (!response.ok) {
114
+ const errorText = await response.text();
115
+ return {
116
+ success: false,
117
+ error: `Token request failed (${response.status}): ${errorText.slice(
118
+ 0,
119
+ 200
120
+ )}`,
121
+ };
122
+ }
123
+
124
+ const data = (await response.json()) as TokenResponse;
125
+ return { success: true, token: data.access_token };
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : "Unknown error";
128
+ return { success: false, error: message };
129
+ }
130
+ }
131
+
132
+ async function fetchTeams(
133
+ token: string
134
+ ): Promise<
135
+ { success: true; teams: GraphTeam[] } | { success: false; error: string }
136
+ > {
137
+ try {
138
+ const response = await fetch(`${GRAPH_API_BASE}/teams`, {
139
+ headers: {
140
+ Authorization: `Bearer ${token}`,
141
+ },
142
+ signal: AbortSignal.timeout(10_000),
143
+ });
144
+
145
+ if (!response.ok) {
146
+ return { success: false, error: `Graph API error: ${response.status}` };
147
+ }
148
+
149
+ const data = (await response.json()) as GraphTeamsResponse;
150
+ return { success: true, teams: data.value ?? [] };
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : "Unknown error";
153
+ return { success: false, error: message };
154
+ }
155
+ }
156
+
157
+ async function fetchChannels(
158
+ token: string,
159
+ teamId: string
160
+ ): Promise<
161
+ | { success: true; channels: GraphChannel[] }
162
+ | { success: false; error: string }
163
+ > {
164
+ try {
165
+ const response = await fetch(`${GRAPH_API_BASE}/teams/${teamId}/channels`, {
166
+ headers: {
167
+ Authorization: `Bearer ${token}`,
168
+ },
169
+ signal: AbortSignal.timeout(10_000),
170
+ });
171
+
172
+ if (!response.ok) {
173
+ return { success: false, error: `Graph API error: ${response.status}` };
174
+ }
175
+
176
+ const data = (await response.json()) as GraphChannelsResponse;
177
+ return { success: true, channels: data.value ?? [] };
178
+ } catch (error) {
179
+ const message = error instanceof Error ? error.message : "Unknown error";
180
+ return { success: false, error: message };
181
+ }
182
+ }
183
+
184
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
185
+ // Adaptive Card Builder
186
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
187
+
188
+ interface AdaptiveCardOptions {
189
+ eventId: string;
190
+ payload: Record<string, unknown>;
191
+ subscriptionName: string;
192
+ timestamp: string;
193
+ }
194
+
195
+ export function buildAdaptiveCard(options: AdaptiveCardOptions): object {
196
+ const { eventId, payload, subscriptionName, timestamp } = options;
197
+
198
+ return {
199
+ type: "AdaptiveCard",
200
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
201
+ version: "1.4",
202
+ body: [
203
+ {
204
+ type: "TextBlock",
205
+ text: `📢 Integration Event`,
206
+ weight: "bolder",
207
+ size: "large",
208
+ wrap: true,
209
+ },
210
+ {
211
+ type: "FactSet",
212
+ facts: [
213
+ { title: "Event", value: eventId },
214
+ { title: "Subscription", value: subscriptionName },
215
+ { title: "Time", value: new Date(timestamp).toLocaleString() },
216
+ ],
217
+ },
218
+ {
219
+ type: "TextBlock",
220
+ text: "**Payload:**",
221
+ weight: "bolder",
222
+ spacing: "medium",
223
+ },
224
+ {
225
+ type: "TextBlock",
226
+ // eslint-disable-next-line unicorn/no-null
227
+ text: "```\n" + JSON.stringify(payload, null, 2) + "\n```",
228
+ wrap: true,
229
+ fontType: "monospace",
230
+ size: "small",
231
+ },
232
+ ],
233
+ };
234
+ }
235
+
236
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
237
+ // Provider Implementation
238
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
239
+
240
+ export const teamsProvider: IntegrationProvider<
241
+ TeamsSubscriptionConfig,
242
+ TeamsConnectionConfig
243
+ > = {
244
+ id: "teams",
245
+ displayName: "Microsoft Teams",
246
+ description: "Send integration events to Microsoft Teams channels",
247
+ icon: "MessageSquareMore",
248
+
249
+ config: new Versioned({
250
+ version: 1,
251
+ schema: TeamsSubscriptionSchema,
252
+ }),
253
+
254
+ connectionSchema: new Versioned({
255
+ version: 1,
256
+ schema: TeamsConnectionSchema,
257
+ }),
258
+
259
+ documentation: {
260
+ setupGuide: `
261
+ ## Register an Azure AD Application
262
+
263
+ 1. Go to [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID**
264
+ 2. Navigate to **App registrations** → **New registration**
265
+ 3. Fill in details and register
266
+
267
+ ## Configure API Permissions
268
+
269
+ 1. Go to **API permissions** → **Add a permission** → **Microsoft Graph**
270
+ 2. Select **Application permissions** (not Delegated)
271
+ 3. Add these permissions:
272
+ - \`Team.ReadBasic.All\` (to list teams)
273
+ - \`Channel.ReadBasic.All\` (to list channels)
274
+ - \`ChannelMessage.Send\` (to send messages)
275
+ 4. Click **Grant admin consent**
276
+
277
+ ## Create Client Secret
278
+
279
+ 1. Go to **Certificates & secrets** → **New client secret**
280
+ 2. Copy the secret value immediately
281
+
282
+ ## Add App to Teams
283
+
284
+ For the app to send messages, it must be installed in the target Team:
285
+ 1. Create a Teams app manifest or use Graph API to install
286
+ 2. Alternatively, ensure the app has \`ChannelMessage.Send\` consent
287
+ `.trim(),
288
+ },
289
+
290
+ async getConnectionOptions(
291
+ params: GetConnectionOptionsParams
292
+ ): Promise<ConnectionOption[]> {
293
+ const {
294
+ resolverName,
295
+ connectionId,
296
+ context,
297
+ getConnectionWithCredentials,
298
+ } = params;
299
+
300
+ // Get connection credentials
301
+ const connection = await getConnectionWithCredentials(connectionId);
302
+ if (!connection) {
303
+ return [];
304
+ }
305
+
306
+ const config = connection.config as TeamsConnectionConfig;
307
+
308
+ // Get app token
309
+ const tokenResult = await getAppToken(config);
310
+ if (!tokenResult.success) {
311
+ return [];
312
+ }
313
+
314
+ if (resolverName === TEAMS_RESOLVERS.TEAM_OPTIONS) {
315
+ const result = await fetchTeams(tokenResult.token);
316
+ if (!result.success) {
317
+ return [];
318
+ }
319
+ return result.teams.map((team) => ({
320
+ value: team.id,
321
+ label: team.displayName,
322
+ }));
323
+ }
324
+
325
+ if (resolverName === TEAMS_RESOLVERS.CHANNEL_OPTIONS) {
326
+ const teamId = (context as Partial<TeamsSubscriptionConfig>)?.teamId;
327
+ if (!teamId) {
328
+ return [];
329
+ }
330
+
331
+ const result = await fetchChannels(tokenResult.token, teamId);
332
+ if (!result.success) {
333
+ return [];
334
+ }
335
+ return result.channels.map((channel) => ({
336
+ value: channel.id,
337
+ label: channel.displayName,
338
+ }));
339
+ }
340
+
341
+ return [];
342
+ },
343
+
344
+ async testConnection(config: unknown): Promise<TestConnectionResult> {
345
+ try {
346
+ const parsedConfig = TeamsConnectionSchema.parse(config);
347
+ const tokenResult = await getAppToken(parsedConfig);
348
+
349
+ if (!tokenResult.success) {
350
+ return {
351
+ success: false,
352
+ message: `Authentication failed: ${tokenResult.error}`,
353
+ };
354
+ }
355
+
356
+ // Verify we can list teams
357
+ const teamsResult = await fetchTeams(tokenResult.token);
358
+ if (!teamsResult.success) {
359
+ return {
360
+ success: false,
361
+ message: `API access failed: ${teamsResult.error}`,
362
+ };
363
+ }
364
+
365
+ return {
366
+ success: true,
367
+ message: `Connected successfully. Found ${teamsResult.teams.length} team(s).`,
368
+ };
369
+ } catch (error) {
370
+ const message =
371
+ error instanceof Error ? error.message : "Invalid configuration";
372
+ return {
373
+ success: false,
374
+ message: `Validation failed: ${message}`,
375
+ };
376
+ }
377
+ },
378
+
379
+ async deliver(
380
+ context: IntegrationDeliveryContext<TeamsSubscriptionConfig>
381
+ ): Promise<IntegrationDeliveryResult> {
382
+ const { event, subscription, providerConfig, logger } = context;
383
+
384
+ // Parse and validate config
385
+ const config = TeamsSubscriptionSchema.parse(providerConfig);
386
+
387
+ // Get connection with credentials
388
+ if (!context.getConnectionWithCredentials) {
389
+ return {
390
+ success: false,
391
+ error: "Connection credentials not available",
392
+ };
393
+ }
394
+
395
+ const connection = await context.getConnectionWithCredentials(
396
+ config.connectionId
397
+ );
398
+
399
+ if (!connection) {
400
+ return {
401
+ success: false,
402
+ error: `Connection not found: ${config.connectionId}`,
403
+ };
404
+ }
405
+
406
+ const connectionConfig = connection.config as TeamsConnectionConfig;
407
+
408
+ // Get app token
409
+ const tokenResult = await getAppToken(connectionConfig);
410
+ if (!tokenResult.success) {
411
+ logger.error("Failed to get Graph API token", {
412
+ error: tokenResult.error,
413
+ });
414
+ return {
415
+ success: false,
416
+ error: `Authentication failed: ${tokenResult.error}`,
417
+ };
418
+ }
419
+
420
+ // Build Adaptive Card
421
+ const adaptiveCard = buildAdaptiveCard({
422
+ eventId: event.eventId,
423
+ payload: event.payload as Record<string, unknown>,
424
+ subscriptionName: subscription.name,
425
+ timestamp: event.timestamp,
426
+ });
427
+
428
+ // Send message to channel
429
+ try {
430
+ const response = await fetch(
431
+ `${GRAPH_API_BASE}/teams/${config.teamId}/channels/${config.channelId}/messages`,
432
+ {
433
+ method: "POST",
434
+ headers: {
435
+ Authorization: `Bearer ${tokenResult.token}`,
436
+ "Content-Type": "application/json",
437
+ },
438
+ body: JSON.stringify({
439
+ body: {
440
+ contentType: "html",
441
+ content: `<attachment id="adaptiveCard"></attachment>`,
442
+ },
443
+ attachments: [
444
+ {
445
+ id: "adaptiveCard",
446
+ contentType: "application/vnd.microsoft.card.adaptive",
447
+ content: JSON.stringify(adaptiveCard),
448
+ },
449
+ ],
450
+ }),
451
+ signal: AbortSignal.timeout(10_000),
452
+ }
453
+ );
454
+
455
+ if (!response.ok) {
456
+ const errorText = await response.text();
457
+ logger.error("Failed to send Teams message", {
458
+ status: response.status,
459
+ error: errorText.slice(0, 200),
460
+ });
461
+ return {
462
+ success: false,
463
+ error: `Graph API error (${response.status}): ${errorText.slice(
464
+ 0,
465
+ 100
466
+ )}`,
467
+ };
468
+ }
469
+
470
+ const messageData = (await response.json()) as GraphMessageResponse;
471
+
472
+ logger.info("Teams message sent", { messageId: messageData.id });
473
+ return {
474
+ success: true,
475
+ externalId: messageData.id,
476
+ };
477
+ } catch (error) {
478
+ const message =
479
+ error instanceof Error ? error.message : "Unknown Graph API error";
480
+ logger.error("Teams delivery error", { error: message });
481
+ return {
482
+ success: false,
483
+ error: `Failed to send Teams message: ${message}`,
484
+ };
485
+ }
486
+ },
487
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }