@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 +33 -0
- package/package.json +22 -0
- package/src/index.ts +24 -0
- package/src/plugin-metadata.ts +5 -0
- package/src/provider.test.ts +486 -0
- package/src/provider.ts +487 -0
- package/tsconfig.json +3 -0
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,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
|
+
});
|
package/src/provider.ts
ADDED
|
@@ -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