@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 +31 -0
- package/package.json +24 -0
- package/src/index.test.ts +298 -0
- package/src/index.ts +379 -0
- package/src/plugin-metadata.ts +5 -0
- package/tsconfig.json +3 -0
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 };
|
package/tsconfig.json
ADDED