@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/README.md +259 -0
- package/index.ts +22 -0
- package/package.json +56 -0
- package/src/auth.ts +294 -0
- package/src/builder.ts +102 -0
- package/src/config.ts +268 -0
- package/src/connector.ts +103 -0
- package/src/conversation.ts +640 -0
- package/src/defaults.ts +89 -0
- package/src/messaging.ts +155 -0
- package/src/plugin.ts +499 -0
- package/src/runtime.ts +190 -0
- package/src/tool.ts +84 -0
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
|
+
}
|
package/src/connector.ts
ADDED
|
@@ -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
|
+
};
|