@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/builder.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Compile a high-level {@link card.CardSpec} into a valid Adaptive Card 1.5
3
+ * document. The model drafts the small semantic spec; this is the single place
4
+ * that turns it into the element tree the `adaptivecards` renderer consumes and
5
+ * Teams accepts, so a card is well-formed by construction rather than by hoping
6
+ * the model produced correct schema.
7
+ *
8
+ * Element choices are deliberate and conservative:
9
+ * - the title is a bold, large `TextBlock`; the subtitle a lighter one;
10
+ * - body text is a wrapping `TextBlock` (Teams renders its Markdown subset);
11
+ * - facts become a single `FactSet` (the right control for key/value detail);
12
+ * - each action becomes an `Action.OpenUrl` - the one action safe to render in
13
+ * any host with no back end wired up.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import { card } from "@dbx-tools/shared-teams";
19
+
20
+ /** An Adaptive Card element (loosely typed; the builder owns the exact shapes). */
21
+ type CardElement = Record<string, unknown>;
22
+
23
+ /** Build the title / subtitle heading block(s). */
24
+ function headingElements(spec: card.CardSpec): CardElement[] {
25
+ const elements: CardElement[] = [
26
+ {
27
+ type: "TextBlock",
28
+ text: spec.title,
29
+ size: "Large",
30
+ weight: "Bolder",
31
+ wrap: true,
32
+ },
33
+ ];
34
+ if (spec.subtitle) {
35
+ elements.push({
36
+ type: "TextBlock",
37
+ text: spec.subtitle,
38
+ isSubtle: true,
39
+ spacing: "None",
40
+ wrap: true,
41
+ });
42
+ }
43
+ return elements;
44
+ }
45
+
46
+ /** Build the optional body `TextBlock`. */
47
+ function textElements(spec: card.CardSpec): CardElement[] {
48
+ if (!spec.text) return [];
49
+ return [{ type: "TextBlock", text: spec.text, wrap: true }];
50
+ }
51
+
52
+ /** Build the optional `FactSet` from the spec's facts. */
53
+ function factElements(spec: card.CardSpec): CardElement[] {
54
+ if (!spec.facts || spec.facts.length === 0) return [];
55
+ return [
56
+ {
57
+ type: "FactSet",
58
+ facts: spec.facts.map((fact) => ({ title: fact.title, value: fact.value })),
59
+ },
60
+ ];
61
+ }
62
+
63
+ /** Build the `Action.OpenUrl` actions from the spec's link buttons. */
64
+ function actionElements(spec: card.CardSpec): CardElement[] {
65
+ if (!spec.actions || spec.actions.length === 0) return [];
66
+ return spec.actions.map((action) => ({
67
+ type: "Action.OpenUrl",
68
+ title: action.title,
69
+ url: action.url,
70
+ }));
71
+ }
72
+
73
+ /**
74
+ * Compile a semantic card spec into a full Adaptive Card document. The result
75
+ * validates against {@link card.adaptiveCardSchema} and is ready to render with
76
+ * the `adaptivecards` package or post to a Teams incoming webhook.
77
+ */
78
+ export function buildAdaptiveCard(spec: card.CardSpec): card.AdaptiveCard {
79
+ const body: CardElement[] = [
80
+ ...headingElements(spec),
81
+ ...textElements(spec),
82
+ ...factElements(spec),
83
+ ];
84
+ const actions = actionElements(spec);
85
+ const document: card.AdaptiveCard = {
86
+ type: "AdaptiveCard",
87
+ $schema: card.ADAPTIVE_CARD_SCHEMA_URL,
88
+ version: card.ADAPTIVE_CARD_VERSION,
89
+ body,
90
+ ...(actions.length > 0 ? { actions } : {}),
91
+ };
92
+ return document;
93
+ }
94
+
95
+ /**
96
+ * Compile a spec into the tool/route result shape: the validated Adaptive Card
97
+ * document plus the title echoed back for a caller that wants a label without
98
+ * re-reading the document.
99
+ */
100
+ export function buildCardResult(spec: card.CardSpec): card.CardResult {
101
+ return { title: spec.title, card: buildAdaptiveCard(spec) };
102
+ }
package/src/config.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Configuration for the Teams plugin: the typed {@link TeamsPluginConfig}
3
+ * (the plugin's slice of AppKit config), the JSON Schema the manifest
4
+ * publishes for it, and {@link resolveTeamsConfig} which layers that config
5
+ * over environment defaults into the concrete {@link ResolvedTeamsConfig} the
6
+ * runtime + tools read.
7
+ *
8
+ * Building a card is a pure transform, so the config is deliberately small:
9
+ * the Adaptive Card version the builder targets, an optional Teams
10
+ * incoming-webhook URL a deployment can wire up to actually POST cards to a
11
+ * channel, and which sibling plugin owns the agents the conversation endpoint
12
+ * answers with.
13
+ *
14
+ * The remaining fields are the Azure Bot registration a real Teams channel
15
+ * needs: `appId` / `appPassword` (the bot's Entra application credentials, used
16
+ * both to validate the inbound JWT audience and to fetch the outbound Connector
17
+ * token) and `appTenantId` (set only for a single-tenant bot). When no `appId`
18
+ * is configured the `/messages` endpoint stays MOUNTED but refuses every
19
+ * request, so an unconfigured deployment cannot accidentally expose an
20
+ * unauthenticated bot endpoint.
21
+ *
22
+ * Env fallbacks: `TEAMS_CARD_VERSION`, `TEAMS_WEBHOOK_URL`,
23
+ * `TEAMS_AGENT_PLUGIN`, `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
24
+ * `TEAMS_APP_TENANT_ID`. The `MICROSOFT_APP_*` spellings the Bot Framework SDK
25
+ * uses are accepted as aliases so an existing bot's environment drops in
26
+ * unchanged.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ import { ValidationError, type BasePluginConfig } from "@databricks/appkit";
32
+ import { object, string } from "@dbx-tools/shared-core";
33
+ import { card } from "@dbx-tools/shared-teams";
34
+ import type { JSONSchema7 } from "json-schema";
35
+
36
+ /** Dedicated override for the Adaptive Card schema version the builder targets. */
37
+ export const CARD_VERSION_ENV = "TEAMS_CARD_VERSION";
38
+
39
+ /** Environment name for an optional Teams incoming-webhook URL. */
40
+ export const WEBHOOK_URL_ENV = "TEAMS_WEBHOOK_URL";
41
+
42
+ /** Environment name overriding which sibling plugin provides the agents. */
43
+ export const AGENT_PLUGIN_ENV = "TEAMS_AGENT_PLUGIN";
44
+
45
+ /**
46
+ * Environment names for the bot's Entra app id, in precedence order. The
47
+ * `MICROSOFT_APP_ID` alias is what the Bot Framework SDK and the Azure portal's
48
+ * generated settings use, so an existing bot deployment needs no new variable.
49
+ */
50
+ export const APP_ID_ENVS = ["TEAMS_APP_ID", "MICROSOFT_APP_ID"] as const;
51
+
52
+ /** Environment names for the bot's client secret, in precedence order. */
53
+ export const APP_PASSWORD_ENVS = ["TEAMS_APP_PASSWORD", "MICROSOFT_APP_PASSWORD"] as const;
54
+
55
+ /**
56
+ * Environment names for the bot's tenant, in precedence order. Set only for a
57
+ * single-tenant bot; a multi-tenant bot leaves it unset.
58
+ */
59
+ export const APP_TENANT_ENVS = ["TEAMS_APP_TENANT_ID", "MICROSOFT_APP_TENANT_ID"] as const;
60
+
61
+ /**
62
+ * Environment name for the unauthenticated-messaging escape hatch. Named
63
+ * `TEAMS_ALLOW_UNAUTHENTICATED` (rather than something softer like `TEAMS_DEV`)
64
+ * so what it disables is unmistakable in a shell history or deploy manifest.
65
+ */
66
+ export const ALLOW_UNAUTHENTICATED_ENV = "TEAMS_ALLOW_UNAUTHENTICATED";
67
+
68
+ /**
69
+ * Default registered name of the plugin the conversation endpoint asks for an
70
+ * agent. `mastra` is the name `@dbx-tools/appkit-mastra` registers under.
71
+ */
72
+ export const DEFAULT_AGENT_PLUGIN = "mastra";
73
+
74
+ /** AppKit config accepted by the Teams plugin. */
75
+ export interface TeamsPluginConfig extends BasePluginConfig {
76
+ /**
77
+ * Adaptive Card schema version the builder targets. Defaults to
78
+ * {@link card.ADAPTIVE_CARD_VERSION} (`"1.5"`, what Teams supports). Falls
79
+ * back to `TEAMS_CARD_VERSION`.
80
+ */
81
+ cardVersion?: string;
82
+ /**
83
+ * Optional Teams incoming-webhook URL. When set, the plugin's `postCard`
84
+ * export / route posts the compiled card to this channel; when unset,
85
+ * posting is disabled and the plugin only builds cards for the UI to render.
86
+ * Falls back to `TEAMS_WEBHOOK_URL`.
87
+ */
88
+ webhookUrl?: string;
89
+ /**
90
+ * Registered name of the sibling plugin whose agents answer a conversation
91
+ * turn. Defaults to {@link DEFAULT_AGENT_PLUGIN} (`"mastra"`); set it when the
92
+ * Mastra plugin is mounted under a `config.name` override. Falls back to
93
+ * `TEAMS_AGENT_PLUGIN`.
94
+ */
95
+ agentPlugin?: string;
96
+ /**
97
+ * The bot's Entra application (client) id from its Azure Bot registration.
98
+ * Required before `POST /api/teams/messages` will accept a request: it is the
99
+ * audience an inbound Bot Framework token must carry, and the client id used
100
+ * to fetch an outbound Connector token. Falls back to `TEAMS_APP_ID` /
101
+ * `MICROSOFT_APP_ID`.
102
+ */
103
+ appId?: string;
104
+ /**
105
+ * Client secret for {@link appId}. Needed to send replies through the
106
+ * Connector API; without it inbound activities still validate but the bot
107
+ * cannot answer. Falls back to `TEAMS_APP_PASSWORD` /
108
+ * `MICROSOFT_APP_PASSWORD`.
109
+ */
110
+ appPassword?: string;
111
+ /**
112
+ * Tenant id for a SINGLE-tenant bot registration. Leave unset for a
113
+ * multi-tenant bot. Falls back to `TEAMS_APP_TENANT_ID` /
114
+ * `MICROSOFT_APP_TENANT_ID`.
115
+ */
116
+ appTenantId?: string;
117
+ /**
118
+ * Serve `POST /messages` with NO inbound token validation, and reply in the
119
+ * HTTP response instead of through the Connector API.
120
+ *
121
+ * This exists so the messaging endpoint can be exercised locally - by the
122
+ * in-repo preview chat, a `curl`, or the Bot Framework Emulator - without an
123
+ * Azure Bot registration. It removes the endpoint's ONLY trust boundary: any
124
+ * caller that can reach the route can drive the agent and read its answers.
125
+ *
126
+ * Never enable it on a deployment reachable from the internet. It is ignored
127
+ * unless `NODE_ENV` is `development` (see {@link resolveTeamsConfig}), so a
128
+ * production build cannot be talked into it by an environment variable alone.
129
+ * Falls back to `TEAMS_ALLOW_UNAUTHENTICATED`.
130
+ */
131
+ allowUnauthenticated?: boolean;
132
+ }
133
+
134
+ /** The concrete config the runtime reads, after config + env resolution. */
135
+ export interface ResolvedTeamsConfig {
136
+ /** Adaptive Card schema version the builder stamps onto every document. */
137
+ cardVersion: string;
138
+ /** Teams incoming-webhook URL, or `undefined` when posting is disabled. */
139
+ webhookUrl?: string;
140
+ /** Registered name of the plugin the conversation endpoint resolves agents from. */
141
+ agentPlugin: string;
142
+ /** Bot app (client) id, or `undefined` when no bot registration is configured. */
143
+ appId?: string;
144
+ /** Bot client secret, or `undefined` when replies cannot be sent. */
145
+ appPassword?: string;
146
+ /** Tenant id for a single-tenant bot, or `undefined` for a multi-tenant one. */
147
+ appTenantId?: string;
148
+ /**
149
+ * Whether `POST /messages` serves turns with NO token validation. See
150
+ * {@link TeamsPluginConfig.allowUnauthenticated}.
151
+ */
152
+ allowUnauthenticated: boolean;
153
+ }
154
+
155
+ /** JSON Schema published in the plugin manifest for {@link TeamsPluginConfig}. */
156
+ export const TEAMS_CONFIG_SCHEMA: JSONSchema7 = {
157
+ type: "object",
158
+ additionalProperties: false,
159
+ properties: {
160
+ cardVersion: {
161
+ type: "string",
162
+ description: `Adaptive Card schema version the builder targets. ${CARD_VERSION_ENV} overrides it.`,
163
+ },
164
+ webhookUrl: {
165
+ type: "string",
166
+ description: `Optional Teams incoming-webhook URL used to post cards. ${WEBHOOK_URL_ENV} overrides it.`,
167
+ },
168
+ agentPlugin: {
169
+ type: "string",
170
+ description: `Registered name of the sibling plugin whose agents answer a conversation turn. ${AGENT_PLUGIN_ENV} overrides it.`,
171
+ },
172
+ appId: {
173
+ type: "string",
174
+ description: `Entra app (client) id of the Azure Bot registration. Required for the Teams messaging endpoint. ${APP_ID_ENVS[0]} overrides it.`,
175
+ },
176
+ appPassword: {
177
+ type: "string",
178
+ description: `Client secret for the bot app id, used to fetch an outbound Connector token. ${APP_PASSWORD_ENVS[0]} overrides it.`,
179
+ },
180
+ appTenantId: {
181
+ type: "string",
182
+ description: `Tenant id for a single-tenant bot registration; unset for multi-tenant. ${APP_TENANT_ENVS[0]} overrides it.`,
183
+ },
184
+ allowUnauthenticated: {
185
+ type: "boolean",
186
+ description:
187
+ "Serve the Teams messaging endpoint with NO token validation, replying in " +
188
+ "the HTTP response. Local development only; ignored unless NODE_ENV is " +
189
+ `development. ${ALLOW_UNAUTHENTICATED_ENV} overrides it.`,
190
+ },
191
+ },
192
+ };
193
+
194
+ /**
195
+ * Layer the plugin config over environment defaults into the concrete config
196
+ * the runtime uses. Fails loudly on a webhook URL that is present but not a
197
+ * valid absolute URL, since that is a deploy-time mistake.
198
+ */
199
+ export function resolveTeamsConfig(overrides?: TeamsPluginConfig): ResolvedTeamsConfig {
200
+ const cardVersion =
201
+ string.trimToNull(overrides?.cardVersion) ??
202
+ string.trimToNull(process.env[CARD_VERSION_ENV]) ??
203
+ card.ADAPTIVE_CARD_VERSION;
204
+ const webhookUrl =
205
+ string.trimToNull(overrides?.webhookUrl) ?? string.trimToNull(process.env[WEBHOOK_URL_ENV]);
206
+ if (webhookUrl !== null && !isAbsoluteUrl(webhookUrl)) {
207
+ throw ValidationError.invalidValue(
208
+ WEBHOOK_URL_ENV,
209
+ webhookUrl,
210
+ "an absolute https URL for the Teams webhook",
211
+ );
212
+ }
213
+ const agentPlugin =
214
+ string.trimToNull(overrides?.agentPlugin) ??
215
+ string.trimToNull(process.env[AGENT_PLUGIN_ENV]) ??
216
+ DEFAULT_AGENT_PLUGIN;
217
+ // Two independent conditions must BOTH hold: the operator asked for it, and
218
+ // this is a development build. Gating on `NODE_ENV` as well means a stray
219
+ // variable in a production environment cannot silently expose the endpoint.
220
+ const requested =
221
+ overrides?.allowUnauthenticated ?? object.toBoolean(process.env[ALLOW_UNAUTHENTICATED_ENV]);
222
+ const allowUnauthenticated = requested === true && process.env.NODE_ENV === "development";
223
+ return {
224
+ cardVersion,
225
+ agentPlugin,
226
+ allowUnauthenticated,
227
+ ...(webhookUrl !== null ? { webhookUrl } : {}),
228
+ ...spread("appId", string.trimToNull(overrides?.appId) ?? fromEnv(APP_ID_ENVS)),
229
+ ...spread(
230
+ "appPassword",
231
+ string.trimToNull(overrides?.appPassword) ?? fromEnv(APP_PASSWORD_ENVS),
232
+ ),
233
+ ...spread("appTenantId", string.trimToNull(overrides?.appTenantId) ?? fromEnv(APP_TENANT_ENVS)),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * First non-empty value among `names` in the environment, or `null`. Lets a
239
+ * `TEAMS_*` variable win over the `MICROSOFT_APP_*` alias the Bot Framework SDK
240
+ * uses without duplicating the lookup per field.
241
+ */
242
+ function fromEnv(names: readonly string[]): string | null {
243
+ for (const name of names) {
244
+ const value = string.trimToNull(process.env[name]);
245
+ if (value !== null) return value;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * `{ [key]: value }` when `value` is present, otherwise nothing - so an absent
252
+ * optional field stays ABSENT rather than becoming an explicit `undefined`,
253
+ * which `exactOptionalPropertyTypes` rejects.
254
+ */
255
+ function spread<K extends string>(key: K, value: string | null): Record<K, string> | undefined {
256
+ return value === null ? undefined : ({ [key]: value } as Record<K, string>);
257
+ }
258
+
259
+ /** Whether `value` parses as an absolute URL. */
260
+ function isAbsoluteUrl(value: string): boolean {
261
+ try {
262
+ // eslint-disable-next-line no-new
263
+ new URL(value);
264
+ return true;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Bot Framework Connector API client - how a reply actually reaches Teams.
3
+ *
4
+ * This is the part that makes the difference between a chat API and a bot. Azure
5
+ * Bot Service does NOT read replies from the body of the `/messages` response:
6
+ * it expects `200` (an acknowledgement that the activity was accepted) and then
7
+ * a separate, authenticated call back to the `serviceUrl` the activity arrived
8
+ * with. So a turn is inherently asynchronous - acknowledge, think, then deliver.
9
+ *
10
+ * Two calls are modelled, both `POST` to the conversation's activity collection:
11
+ *
12
+ * - {@link sendActivity} - `POST /v3/conversations/{id}/activities/{replyToId}`
13
+ * delivers a reply threaded under the user's message.
14
+ * - {@link sendTyping} - the same call with a `typing` activity, which is what
15
+ * puts the "…" indicator in the channel while the agent works.
16
+ *
17
+ * `serviceUrl` is always supplied by the CALLER from a validated source (see
18
+ * `isAllowedServiceUrl`), never read straight off an untrusted request body:
19
+ * these calls carry the bot's bearer token, so the destination host is a
20
+ * security-relevant input.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { log } from "@dbx-tools/shared-core";
26
+ import type { activity as activityContract } from "@dbx-tools/shared-teams";
27
+
28
+ const logger = log.logger("teams:connector");
29
+
30
+ /** Everything needed to address one Connector call. */
31
+ export interface ConnectorTarget {
32
+ /** Base URL the activity arrived from, e.g. `https://smba.trafficmanager.net/amer/`. */
33
+ serviceUrl: string;
34
+ /** Conversation the reply belongs to. */
35
+ conversationId: string;
36
+ /** Bearer token for the bot's app registration. */
37
+ token: string;
38
+ /** Activity the reply threads under, when replying to a specific message. */
39
+ replyToId?: string;
40
+ /** Cancels the request with the turn. */
41
+ signal?: AbortSignal;
42
+ }
43
+
44
+ /**
45
+ * Build the activities URL for a conversation.
46
+ *
47
+ * `replyToId` selects the threaded form, which is what makes a reply appear
48
+ * attached to the user's message rather than as a loose channel post.
49
+ */
50
+ const activitiesUrl = (target: ConnectorTarget): string => {
51
+ const base = target.serviceUrl.replace(/\/+$/, "");
52
+ const conversation = encodeURIComponent(target.conversationId);
53
+ const suffix = target.replyToId ? `/${encodeURIComponent(target.replyToId)}` : "";
54
+ return `${base}/v3/conversations/${conversation}/activities${suffix}`;
55
+ };
56
+
57
+ /**
58
+ * Deliver one activity to a conversation through the Connector API.
59
+ *
60
+ * Returns the id the channel assigned the posted activity, when it reports one -
61
+ * useful for a later update/delete, and for correlating logs with what a user
62
+ * sees in the channel.
63
+ */
64
+ export const sendActivity = async (
65
+ activity: activityContract.Activity,
66
+ target: ConnectorTarget,
67
+ ): Promise<string | undefined> => {
68
+ const response = await fetch(activitiesUrl(target), {
69
+ method: "POST",
70
+ headers: {
71
+ "content-type": "application/json",
72
+ authorization: `Bearer ${target.token}`,
73
+ },
74
+ body: JSON.stringify(activity),
75
+ ...(target.signal ? { signal: target.signal } : {}),
76
+ });
77
+ if (!response.ok) {
78
+ const detail = await response.text().catch(() => "");
79
+ throw new Error(`teams: connector rejected the activity (${response.status}) ${detail}`.trim());
80
+ }
81
+ const payload = (await response.json().catch(() => null)) as { id?: unknown } | null;
82
+ const id = typeof payload?.id === "string" ? payload.id : undefined;
83
+ logger.debug("activity delivered", { conversation: target.conversationId, id });
84
+ return id;
85
+ };
86
+
87
+ /**
88
+ * Show the typing indicator in the conversation.
89
+ *
90
+ * Best-effort by design: a failed indicator must never fail the turn, because
91
+ * the card that follows is the actual answer. A failure is logged at debug and
92
+ * swallowed.
93
+ */
94
+ export const sendTyping = async (target: ConnectorTarget): Promise<void> => {
95
+ try {
96
+ await sendActivity({ type: "typing" }, target);
97
+ } catch (err) {
98
+ logger.debug("typing indicator failed", {
99
+ conversation: target.conversationId,
100
+ error: (err as Error).message,
101
+ });
102
+ }
103
+ };