@dbx-tools/teams 0.3.39

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/src/plugin.ts ADDED
@@ -0,0 +1,499 @@
1
+ /**
2
+ * AppKit plugin (registered name: `teams`) that owns the Teams Adaptive Card
3
+ * runtime - the resolved card version and the optional incoming-webhook URL the
4
+ * {@link teamsCardTool} and the AppKit `teams.createCard` tool read. Registering
5
+ * it resolves and logs the effective config (which card version is in force,
6
+ * whether a webhook is wired up) so a misconfiguration is visible in the boot
7
+ * logs rather than on the first card, and installs the plugin's `execute()` as
8
+ * the runtime's executor so every build / post picks up AppKit's cache / retry /
9
+ * timeout / telemetry chain.
10
+ *
11
+ * The plugin is also a `ToolProvider`, so an AppKit agent can reach a
12
+ * `teams.createCard` tool directly; the {@link teamsCardTool} export is the same
13
+ * capability for a Mastra agent. Both share the runtime primed here.
14
+ *
15
+ * The plugin mounts four routes under its base path (`/api/teams`):
16
+ *
17
+ * - `POST /messages` is the REAL Microsoft Teams messaging endpoint - the URL
18
+ * an Azure Bot registration points at. It validates the Bot Service JWT,
19
+ * acknowledges immediately, and delivers the agent's card back over the
20
+ * Connector API. This is the route that makes a Teams channel able to chat
21
+ * with the app's agents, the same way the Mastra plugin exposes MCP at a
22
+ * path.
23
+ * - `POST /activity` runs the same turn SYNCHRONOUSLY, answering with the
24
+ * reply activities in the response body. It needs no bot registration, so
25
+ * it is what a local client (the in-repo preview chat), a test, or any
26
+ * non-Teams caller uses.
27
+ * - `POST /card` compiles a {@link card.CardSpec} into an Adaptive Card
28
+ * document (the preview page posts here).
29
+ * - `POST /post` pushes a compiled card to the configured Teams incoming
30
+ * webhook when one is set.
31
+ *
32
+ * Mirrors the node-email add-on's shape.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ import {
38
+ Plugin,
39
+ toPlugin,
40
+ type ExecutionResult,
41
+ type IAppRouter,
42
+ type PluginManifest,
43
+ } from "@databricks/appkit";
44
+ import {
45
+ defineTool,
46
+ executeFromRegistry,
47
+ toolsFromRegistry,
48
+ type AgentToolDefinition,
49
+ type ToolProvider,
50
+ type ToolRegistry,
51
+ } from "@databricks/appkit/beta";
52
+ import { error, log, object, string } from "@dbx-tools/shared-core";
53
+ import { activity as activityContract, card } from "@dbx-tools/shared-teams";
54
+ import { verifyBotToken } from "./auth";
55
+ import { TEAMS_CONFIG_SCHEMA, type TeamsPluginConfig } from "./config";
56
+ import { promptOf, resolveCardAgent, resolveCardContextFactory, runCardTurn } from "./conversation";
57
+ import { TEAMS_BUILD_SETTINGS, TEAMS_POST_SETTINGS, TEAMS_TURN_SETTINGS } from "./defaults";
58
+ import { deliverTurn, resolveServiceUrl } from "./messaging";
59
+ import {
60
+ buildCard,
61
+ getTeamsRuntime,
62
+ postCard,
63
+ resetTeamsRuntime,
64
+ setTeamsExecutor,
65
+ } from "./runtime";
66
+ import { CREATE_CARD_DESCRIPTION } from "./tool";
67
+
68
+ /** Mount-relative route (under `/api/teams`) for compiling a card. */
69
+ const CARD_ROUTE = "/card";
70
+
71
+ /** Mount-relative route (under `/api/teams`) for posting a card to a webhook. */
72
+ const POST_ROUTE = "/post";
73
+
74
+ /**
75
+ * Mount-relative route (under `/api/teams`) for one conversation turn: a Bot
76
+ * Framework activity in, activities carrying Adaptive Cards back.
77
+ */
78
+ const ACTIVITY_ROUTE = "/activity";
79
+
80
+ /**
81
+ * Mount-relative route (under `/api/teams`) for the Teams messaging endpoint.
82
+ * This is the path a bot registration's messaging endpoint points at, i.e.
83
+ * `https://<host>/api/teams/messages`.
84
+ */
85
+ const MESSAGES_ROUTE = "/messages";
86
+
87
+ /** Registry key of the agent tool, which agents address as `teams.createCard`. */
88
+ const CREATE_TOOL = "createCard";
89
+
90
+ const logger = log.logger("teams");
91
+
92
+ /**
93
+ * AppKit plugin that configures the Adaptive Card builder used by the
94
+ * `create_teams_card` tool, and exposes card building as an AppKit agent tool.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * import { createApp, server } from "@databricks/appkit";
99
+ * import { plugin as teamsPlugin } from "@dbx-tools/teams";
100
+ *
101
+ * await createApp({
102
+ * plugins: [
103
+ * server(),
104
+ * teamsPlugin.teams({ webhookUrl: process.env.TEAMS_WEBHOOK_URL }),
105
+ * ],
106
+ * });
107
+ * ```
108
+ */
109
+ export class TeamsPlugin extends Plugin<TeamsPluginConfig> implements ToolProvider {
110
+ static manifest = {
111
+ name: "teams",
112
+ displayName: "Teams",
113
+ description:
114
+ "Answers chat turns as Microsoft Teams Adaptive Cards over a Bot " +
115
+ "Framework activity endpoint, builds cards from a small structured card " +
116
+ "description, and optionally posts them to a Teams incoming webhook.",
117
+ stability: "beta",
118
+ resources: {
119
+ required: [],
120
+ optional: [],
121
+ },
122
+ config: { schema: TEAMS_CONFIG_SCHEMA },
123
+ } satisfies PluginManifest<"teams">;
124
+
125
+ /**
126
+ * The tool this plugin offers to an AppKit agent.
127
+ *
128
+ * Marked `autoInheritable`: building a card is a pure, side-effect-free
129
+ * transform (nothing leaves the building), so it is safe to hand to any
130
+ * agent by default - unlike a send.
131
+ *
132
+ * `execute` re-parses its arguments with the local schema: AppKit validates
133
+ * against the same schema first, but re-parsing is what gives the body typed
134
+ * arguments instead of `unknown`.
135
+ */
136
+ private readonly tools: ToolRegistry = {
137
+ [CREATE_TOOL]: defineTool({
138
+ description: CREATE_CARD_DESCRIPTION,
139
+ schema: card.cardSpecSchema,
140
+ annotations: { effect: "read" },
141
+ autoInheritable: true,
142
+ execute: async (args, signal) => buildCard(card.cardSpecSchema.parse(args), signal),
143
+ }),
144
+ };
145
+
146
+ /**
147
+ * Prime the shared runtime from this plugin's config (over env), route the
148
+ * tool's builds through this plugin's interceptor chain, and log the
149
+ * effective config so the resolved card version and whether a webhook is
150
+ * wired up are obvious at boot.
151
+ */
152
+ override async setup(): Promise<void> {
153
+ const { config } = getTeamsRuntime(this.config);
154
+ setTeamsExecutor((fn, settings) => this.execute(fn, settings));
155
+ logger.info("ready", {
156
+ cardVersion: config.cardVersion,
157
+ webhook: config.webhookUrl ? "configured" : "disabled",
158
+ messaging: config.allowUnauthenticated
159
+ ? "UNAUTHENTICATED (development)"
160
+ : config.appId && config.appPassword
161
+ ? "bot registration configured"
162
+ : "disabled (no appId/appPassword)",
163
+ });
164
+ // An endpoint serving agent turns with no auth is worth a warning on every
165
+ // boot, not a line buried in an info payload.
166
+ if (config.allowUnauthenticated) {
167
+ logger.warn(
168
+ "POST /messages is serving UNAUTHENTICATED turns - any caller that can " +
169
+ "reach this route can drive the agent. Development only; never expose this.",
170
+ );
171
+ }
172
+ }
173
+
174
+ /** Drop the shared runtime. Idempotent. */
175
+ async shutdown(): Promise<void> {
176
+ resetTeamsRuntime();
177
+ }
178
+
179
+ /**
180
+ * Mount the card-building and card-posting routes under the plugin base
181
+ * path (`/api/teams`). `POST /card` is what the dev display page calls to
182
+ * preview a card live; `POST /post` pushes a compiled card to the configured
183
+ * Teams incoming webhook.
184
+ *
185
+ * Neither route is wrapped in `asUser(req)`: compiling a card is a pure
186
+ * transform of the request body and posting goes to a preconfigured webhook,
187
+ * so neither reads workspace data on the caller's behalf and neither needs an
188
+ * OBO token. Wrapping them would make the routes throw
189
+ * `AuthenticationError` whenever the user-token header is absent (a local
190
+ * `curl`, a health probe), which - since AppKit does not catch a rejection
191
+ * raised inside the handler - takes the process down rather than answering
192
+ * 401.
193
+ */
194
+ override injectRoutes(router: IAppRouter): void {
195
+ this.route(router, {
196
+ name: "buildCard",
197
+ method: "post",
198
+ path: CARD_ROUTE,
199
+ handler: async (req, res) => {
200
+ const result = await this.executeBuild(req.body);
201
+ if (!result.ok) {
202
+ res.status(result.status).json({ error: result.message });
203
+ return;
204
+ }
205
+ res.json(result.data);
206
+ },
207
+ });
208
+ this.route(router, {
209
+ name: "activity",
210
+ method: "post",
211
+ path: ACTIVITY_ROUTE,
212
+ handler: async (req, res) => {
213
+ const result = await this.executeTurn(req.body);
214
+ if (!result.ok) {
215
+ res.status(result.status).json({ error: result.message });
216
+ return;
217
+ }
218
+ res.json(result.data);
219
+ },
220
+ });
221
+ this.route(router, {
222
+ name: "postCard",
223
+ method: "post",
224
+ path: POST_ROUTE,
225
+ handler: async (req, res) => {
226
+ const result = await this.executePost(req.body);
227
+ if (!result.ok) {
228
+ res.status(result.status).json({ error: result.message });
229
+ return;
230
+ }
231
+ res.json({ ok: true });
232
+ },
233
+ });
234
+ // The real Teams messaging endpoint. Unlike every other route here it is
235
+ // called by Azure Bot Service over the public internet, so it authenticates
236
+ // itself from the inbound JWT and answers `200` before the agent has run.
237
+ this.route(router, {
238
+ name: "messages",
239
+ method: "post",
240
+ path: MESSAGES_ROUTE,
241
+ handler: async (req, res) => {
242
+ await this.handleMessage(req, res);
243
+ },
244
+ });
245
+ }
246
+
247
+ override exports() {
248
+ return {
249
+ /**
250
+ * Compile a card spec into an Adaptive Card document. For agent-driven
251
+ * builds use {@link teamsCardTool} instead.
252
+ */
253
+ buildCard: (spec: card.CardSpec, signal?: AbortSignal): Promise<card.CardResult> =>
254
+ buildCard(spec, signal),
255
+ /**
256
+ * Post a compiled card to the configured Teams incoming webhook. Throws
257
+ * when no webhook is configured.
258
+ */
259
+ postCard: (cardDocument: card.AdaptiveCard, signal?: AbortSignal): Promise<void> =>
260
+ postCard(cardDocument, signal),
261
+ };
262
+ }
263
+
264
+ /** AppKit `ToolProvider`: the tool definitions offered to an agent. */
265
+ getAgentTools(): AgentToolDefinition[] {
266
+ return toolsFromRegistry(this.tools);
267
+ }
268
+
269
+ /**
270
+ * AppKit `ToolProvider`: run one tool call. Arguments are validated against
271
+ * the tool's schema first, and a validation failure comes back as an
272
+ * LLM-friendly string so the model can correct itself on the next turn.
273
+ */
274
+ async executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
275
+ return executeFromRegistry(this.tools, name, args, signal);
276
+ }
277
+
278
+ /**
279
+ * Handle one inbound request from Azure Bot Service on `POST /messages`.
280
+ *
281
+ * The order of operations is dictated by how Bot Service behaves, not by
282
+ * convenience:
283
+ *
284
+ * 1. **Refuse when unconfigured.** With no `appId` there is no audience to
285
+ * validate a token against, so the endpoint cannot be operated safely;
286
+ * it answers 503 rather than processing an unauthenticated activity.
287
+ * 2. **Validate the JWT before parsing the body.** The token is the only
288
+ * trust boundary this endpoint has.
289
+ * 3. **Pin the reply destination to the token.** `serviceUrl` arrives in the
290
+ * body, and replies carry the bot's credentials, so it is only honored
291
+ * when it matches the verified token.
292
+ * 4. **Answer 200 immediately, then run the agent.** Bot Service times out
293
+ * an unacknowledged activity in seconds and RETRIES it; a card takes far
294
+ * longer than that, and a retry would post a duplicate card.
295
+ *
296
+ * Activities that carry no prompt (`typing`, `conversationUpdate`, an
297
+ * attachment-only message) are acknowledged and dropped - exactly what a bot
298
+ * does with them.
299
+ */
300
+ private async handleMessage(
301
+ req: { headers: Record<string, unknown>; body: unknown },
302
+ res: {
303
+ status(code: number): { json(body: unknown): void };
304
+ json(body: unknown): void;
305
+ headersSent?: boolean;
306
+ },
307
+ ): Promise<void> {
308
+ const { config } = getTeamsRuntime(this.config);
309
+
310
+ // Local development mode: no bot registration, no token, and the reply comes
311
+ // back in the HTTP response rather than through the Connector API (there is
312
+ // no `serviceUrl` to call back to). This is what lets the in-repo preview
313
+ // chat and the Bot Framework Emulator drive the same route Teams uses.
314
+ if (config.allowUnauthenticated) {
315
+ // Accept both envelopes: a bare activity (what Bot Service and the
316
+ // emulator POST) and the `{ activity, agentId }` request shape the
317
+ // `/activity` route takes, so a local client can still choose an agent.
318
+ const body =
319
+ object.isRecord(req.body) && object.isRecord(req.body.activity)
320
+ ? req.body
321
+ : { activity: req.body };
322
+ const result = await this.executeTurn(body);
323
+ if (!result.ok) {
324
+ res.status(result.status).json({ error: result.message });
325
+ return;
326
+ }
327
+ res.json(result.data);
328
+ return;
329
+ }
330
+
331
+ if (!config.appId || !config.appPassword) {
332
+ res.status(503).json({
333
+ error:
334
+ "teams: messaging endpoint is not configured - set appId/appPassword " +
335
+ "(TEAMS_APP_ID / TEAMS_APP_PASSWORD) from the Azure Bot registration",
336
+ });
337
+ return;
338
+ }
339
+
340
+ const authorization = readHeader(req.headers, "authorization");
341
+ let verified;
342
+ try {
343
+ verified = await verifyBotToken(authorization, {
344
+ appId: config.appId,
345
+ ...(config.appTenantId ? { appTenantId: config.appTenantId } : {}),
346
+ });
347
+ } catch (err) {
348
+ // Deliberately terse: a caller failing authentication learns only that it
349
+ // failed, while the reason goes to the logs.
350
+ logger.warn("rejected an unauthenticated request", { error: error.errorMessage(err) });
351
+ res.status(401).json({ error: "unauthorized" });
352
+ return;
353
+ }
354
+
355
+ const parsed = activityContract.activitySchema.safeParse(req.body);
356
+ if (!parsed.success) {
357
+ res.status(400).json({ error: parsed.error.message });
358
+ return;
359
+ }
360
+ const inbound = parsed.data;
361
+
362
+ const serviceUrl = resolveServiceUrl(inbound, verified.serviceUrl);
363
+ if (!serviceUrl) {
364
+ logger.warn("rejected an activity with no usable serviceUrl", {
365
+ type: inbound.type,
366
+ });
367
+ res.status(400).json({ error: "activity carried no acceptable serviceUrl" });
368
+ return;
369
+ }
370
+
371
+ const agent = resolveCardAgent(this.context?.getPlugins(), config.agentPlugin);
372
+ if (!agent) {
373
+ logger.error("no agent available to answer a Teams turn", {
374
+ agentPlugin: config.agentPlugin,
375
+ });
376
+ res.status(503).json({ error: "no agent available" });
377
+ return;
378
+ }
379
+
380
+ // Acknowledge FIRST. Everything after this point is out-of-band work whose
381
+ // result reaches the user through the Connector API, not this response.
382
+ res.status(200).json({});
383
+
384
+ if (!promptOf(inbound)) return;
385
+
386
+ const createRequestContext = resolveCardContextFactory(
387
+ this.context?.getPlugins(),
388
+ config.agentPlugin,
389
+ );
390
+
391
+ void deliverTurn({
392
+ agent,
393
+ activity: inbound,
394
+ serviceUrl,
395
+ ...(createRequestContext ? { createRequestContext } : {}),
396
+ credentials: {
397
+ appId: config.appId,
398
+ appPassword: config.appPassword,
399
+ ...(config.appTenantId ? { appTenantId: config.appTenantId } : {}),
400
+ },
401
+ });
402
+ }
403
+
404
+ /** Compile a card, validating the request body against the spec schema. */
405
+ private async executeBuild(body: unknown): Promise<ExecutionResult<card.CardResult>> {
406
+ const parsed = card.cardSpecSchema.safeParse(body);
407
+ if (!parsed.success) {
408
+ return { ok: false, status: 400, message: parsed.error.message };
409
+ }
410
+ return this.execute(async (signal) => buildCard(parsed.data, signal), TEAMS_BUILD_SETTINGS);
411
+ }
412
+
413
+ /**
414
+ * Run one conversation turn: validate the inbound activity, resolve the agent
415
+ * from the sibling agent plugin, and answer with card-carrying activities.
416
+ *
417
+ * Resolution failures are reported distinctly because they have different
418
+ * fixes: 503 when no agent plugin is mounted at all (a wiring problem), 404
419
+ * when the caller named an `agentId` that is not registered (a request
420
+ * problem).
421
+ */
422
+ private async executeTurn(
423
+ body: unknown,
424
+ ): Promise<ExecutionResult<activityContract.ActivityResponse>> {
425
+ const parsed = activityContract.activityRequestSchema.safeParse(body);
426
+ if (!parsed.success) {
427
+ return { ok: false, status: 400, message: parsed.error.message };
428
+ }
429
+ const { activity, agentId } = parsed.data;
430
+ const { config } = getTeamsRuntime(this.config);
431
+ const agent = resolveCardAgent(this.context?.getPlugins(), config.agentPlugin, agentId);
432
+ if (!agent) {
433
+ return agentId
434
+ ? { ok: false, status: 404, message: `teams: unknown agent '${agentId}'` }
435
+ : {
436
+ ok: false,
437
+ status: 503,
438
+ message: `teams: no agent available - is the '${config.agentPlugin}' plugin registered?`,
439
+ };
440
+ }
441
+ const createRequestContext = resolveCardContextFactory(
442
+ this.context?.getPlugins(),
443
+ config.agentPlugin,
444
+ );
445
+ return this.execute(async (signal) => {
446
+ const activities = await runCardTurn(agent, activity, {
447
+ ...(createRequestContext ? { createRequestContext } : {}),
448
+ ...(signal ? { signal } : {}),
449
+ });
450
+ return { activities };
451
+ }, TEAMS_TURN_SETTINGS);
452
+ }
453
+
454
+ /**
455
+ * Compile then post a card, validating the request body against the spec
456
+ * schema. The compile is folded INTO the executed callback so a throw from
457
+ * either half (a build failure, or `postCard` refusing because no webhook is
458
+ * configured) comes back as a failed {@link ExecutionResult} the route can
459
+ * answer with, rather than escaping the handler as an unhandled rejection.
460
+ */
461
+ private async executePost(body: unknown): Promise<ExecutionResult<void>> {
462
+ const parsed = card.cardSpecSchema.safeParse(body);
463
+ if (!parsed.success) {
464
+ return { ok: false, status: 400, message: parsed.error.message };
465
+ }
466
+ return this.execute(async (signal) => {
467
+ const built = await buildCard(parsed.data, signal);
468
+ await postCard(built.card, signal);
469
+ }, TEAMS_POST_SETTINGS);
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Register the Teams plugin.
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * import { createApp, server } from "@databricks/appkit";
479
+ * import { plugin as teamsPlugin } from "@dbx-tools/teams";
480
+ *
481
+ * await createApp({
482
+ * plugins: [server(), teamsPlugin.teams()],
483
+ * });
484
+ * ```
485
+ */
486
+ export const teams = toPlugin(TeamsPlugin);
487
+
488
+ /**
489
+ * Read one header value, normalizing Node's `string | string[]` shape.
490
+ *
491
+ * Express lower-cases incoming header names, but this is written against a
492
+ * structural `headers` record (so the handler stays testable without an Express
493
+ * request), hence the explicit case-insensitive lookup.
494
+ */
495
+ function readHeader(headers: Record<string, unknown>, name: string): string | undefined {
496
+ const direct = headers[name] ?? headers[name.toLowerCase()];
497
+ const value = Array.isArray(direct) ? direct[0] : direct;
498
+ return typeof value === "string" ? (string.trimToNull(value) ?? undefined) : undefined;
499
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The Teams runtime: a lazily-resolved, process-wide config shared by the
3
+ * plugin and the `create_teams_card` tool, so both read one resolved card
4
+ * version / webhook set. The first caller (normally the plugin at setup)
5
+ * primes it from the plugin's config; later callers (the tool's `execute`)
6
+ * reuse it.
7
+ *
8
+ * The runtime also carries the {@link TeamsExecutor} every operation runs
9
+ * through. The plugin installs its own `execute()` there at setup, which is how
10
+ * the tool - a plain function with no plugin instance in scope - still gets
11
+ * AppKit's cache / retry / timeout / telemetry chain. Without a registered
12
+ * plugin (a direct call from a script or a test) the operations still run, just
13
+ * without interceptors.
14
+ *
15
+ * Like the web-search runtime and unlike the email one, there is no connection
16
+ * pool to hold - card building is in-process and a webhook post is a stateless
17
+ * HTTP call - so the runtime holds only the resolved config and that executor.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import { AppKitError, ExecutionError, type ExecutionResult } from "@databricks/appkit";
23
+ import { async, error, log } from "@dbx-tools/shared-core";
24
+ import { card } from "@dbx-tools/shared-teams";
25
+ import { buildCardResult } from "./builder";
26
+ import { resolveTeamsConfig, type ResolvedTeamsConfig, type TeamsPluginConfig } from "./config";
27
+ import { TEAMS_BUILD_SETTINGS, TEAMS_POST_SETTINGS, type TeamsExecutionSettings } from "./defaults";
28
+
29
+ const logger = log.logger("teams/runtime");
30
+
31
+ /**
32
+ * Runs one operation through AppKit's interceptor chain. Matches
33
+ * `Plugin.execute()`, which never throws: a failure comes back as
34
+ * `{ ok: false }`.
35
+ */
36
+ export type TeamsExecutor = <T>(
37
+ fn: (signal?: AbortSignal) => Promise<T>,
38
+ settings: TeamsExecutionSettings,
39
+ ) => Promise<ExecutionResult<T>>;
40
+
41
+ /** The shared resolved config plus the executor operations run through. */
42
+ export interface TeamsRuntime {
43
+ config: ResolvedTeamsConfig;
44
+ execute: TeamsExecutor;
45
+ }
46
+
47
+ /**
48
+ * Executor used until (or unless) the plugin installs its own: run the call
49
+ * directly, mapping a throw onto the same {@link ExecutionResult} shape so
50
+ * call sites branch on `ok` either way.
51
+ */
52
+ const directExecute: TeamsExecutor = async (fn) => {
53
+ try {
54
+ return { ok: true, data: await fn() };
55
+ } catch (err) {
56
+ return {
57
+ ok: false,
58
+ status: err instanceof AppKitError ? err.statusCode : 500,
59
+ message: error.errorMessage(err),
60
+ };
61
+ }
62
+ };
63
+
64
+ let runtime: TeamsRuntime | undefined;
65
+
66
+ /**
67
+ * Return the shared runtime, building it on first use from the supplied config
68
+ * layered over environment defaults. Overrides are only read when the runtime
69
+ * is first created, so prime it from the plugin's config at setup; subsequent
70
+ * calls (the tool's `execute`) pass nothing and get the same instance.
71
+ */
72
+ export function getTeamsRuntime(overrides?: TeamsPluginConfig): TeamsRuntime {
73
+ if (!runtime) {
74
+ runtime = { config: resolveTeamsConfig(overrides), execute: directExecute };
75
+ }
76
+ return runtime;
77
+ }
78
+
79
+ /**
80
+ * Install the executor operations run through. The plugin calls this at setup
81
+ * with its own `execute()`; a second call replaces the previous one, so a
82
+ * re-registered plugin does not leave the tool bound to a dead instance.
83
+ */
84
+ export function setTeamsExecutor(execute: TeamsExecutor): void {
85
+ getTeamsRuntime().execute = execute;
86
+ }
87
+
88
+ /** Drop the memoized runtime so the next {@link getTeamsRuntime} rebuilds it. */
89
+ export function resetTeamsRuntime(): void {
90
+ runtime = undefined;
91
+ }
92
+
93
+ /**
94
+ * Run one operation through the shared executor and unwrap it.
95
+ *
96
+ * `execute()` never throws, so a failed call arrives as `{ ok: false }` with a
97
+ * status the interceptors already sanitized; it is logged here and re-raised as
98
+ * a stable {@link ExecutionError} so an upstream message never becomes the
99
+ * caller's error text.
100
+ */
101
+ async function run<T>(
102
+ operation: string,
103
+ settings: TeamsExecutionSettings,
104
+ fn: (signal?: AbortSignal) => Promise<T>,
105
+ signal?: AbortSignal,
106
+ ): Promise<T> {
107
+ const { execute } = getTeamsRuntime();
108
+ const result = await execute(
109
+ (executeSignal) => fn(async.combineAbortSignals(executeSignal, signal)),
110
+ settings,
111
+ );
112
+ if (result.ok) return result.data;
113
+ if (signal?.aborted) throw ExecutionError.canceled();
114
+ logger.warn("execution-failed", {
115
+ operation,
116
+ status: result.status,
117
+ error: result.message,
118
+ });
119
+ throw new ExecutionError(`teams: ${operation} failed`, {
120
+ context: { operation, status: result.status },
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Compile a card spec into an Adaptive Card document, stamped with the runtime's
126
+ * resolved card version. Runs through the shared executor so a build picks up
127
+ * the app's telemetry / timeout chain.
128
+ */
129
+ export async function buildCard(
130
+ spec: card.CardSpec,
131
+ signal?: AbortSignal,
132
+ ): Promise<card.CardResult> {
133
+ const { config } = getTeamsRuntime();
134
+ return run(
135
+ "build",
136
+ TEAMS_BUILD_SETTINGS,
137
+ async () => {
138
+ const result = buildCardResult(spec);
139
+ result.card.version = config.cardVersion;
140
+ return result;
141
+ },
142
+ signal,
143
+ );
144
+ }
145
+
146
+ /**
147
+ * POST a compiled Adaptive Card to the configured Teams incoming webhook,
148
+ * wrapped in the `MessageCard` attachment envelope Teams expects. Throws when
149
+ * no webhook is configured, so a caller that reaches here without one gets a
150
+ * clear error rather than a silent no-op.
151
+ */
152
+ export async function postCard(
153
+ cardDocument: card.AdaptiveCard,
154
+ signal?: AbortSignal,
155
+ ): Promise<void> {
156
+ const { config } = getTeamsRuntime();
157
+ const webhookUrl = config.webhookUrl;
158
+ if (!webhookUrl) {
159
+ throw new ExecutionError("teams: no webhook configured", {
160
+ context: { operation: "post" },
161
+ });
162
+ }
163
+ await run(
164
+ "post",
165
+ TEAMS_POST_SETTINGS,
166
+ async (executeSignal) => {
167
+ const body = {
168
+ type: "message",
169
+ attachments: [
170
+ {
171
+ contentType: "application/vnd.microsoft.card.adaptive",
172
+ content: cardDocument,
173
+ },
174
+ ],
175
+ };
176
+ const response = await fetch(webhookUrl, {
177
+ method: "POST",
178
+ headers: { "content-type": "application/json" },
179
+ body: JSON.stringify(body),
180
+ ...(executeSignal ? { signal: executeSignal } : {}),
181
+ });
182
+ if (!response.ok) {
183
+ throw new ExecutionError(`teams: webhook responded ${response.status}`, {
184
+ context: { operation: "post", status: response.status },
185
+ });
186
+ }
187
+ },
188
+ signal,
189
+ );
190
+ }