@copilotkit/channels-teams 0.1.0

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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/adapter.d.ts +133 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +539 -0
  6. package/dist/adapter.test.d.ts +2 -0
  7. package/dist/adapter.test.d.ts.map +1 -0
  8. package/dist/adapter.test.js +202 -0
  9. package/dist/conversation-store.d.ts +36 -0
  10. package/dist/conversation-store.d.ts.map +1 -0
  11. package/dist/conversation-store.js +78 -0
  12. package/dist/conversation-store.test.d.ts +2 -0
  13. package/dist/conversation-store.test.d.ts.map +1 -0
  14. package/dist/conversation-store.test.js +72 -0
  15. package/dist/download-files.d.ts +62 -0
  16. package/dist/download-files.d.ts.map +1 -0
  17. package/dist/download-files.js +190 -0
  18. package/dist/download-files.test.d.ts +2 -0
  19. package/dist/download-files.test.d.ts.map +1 -0
  20. package/dist/download-files.test.js +96 -0
  21. package/dist/event-renderer.d.ts +23 -0
  22. package/dist/event-renderer.d.ts.map +1 -0
  23. package/dist/event-renderer.js +135 -0
  24. package/dist/event-renderer.test.d.ts +2 -0
  25. package/dist/event-renderer.test.d.ts.map +1 -0
  26. package/dist/event-renderer.test.js +95 -0
  27. package/dist/graph-files.d.ts +50 -0
  28. package/dist/graph-files.d.ts.map +1 -0
  29. package/dist/graph-files.js +126 -0
  30. package/dist/graph-files.test.d.ts +2 -0
  31. package/dist/graph-files.test.d.ts.map +1 -0
  32. package/dist/graph-files.test.js +114 -0
  33. package/dist/index.d.ts +20 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +14 -0
  36. package/dist/interaction.d.ts +36 -0
  37. package/dist/interaction.d.ts.map +1 -0
  38. package/dist/interaction.js +35 -0
  39. package/dist/interaction.test.d.ts +2 -0
  40. package/dist/interaction.test.d.ts.map +1 -0
  41. package/dist/interaction.test.js +43 -0
  42. package/dist/listener.d.ts +26 -0
  43. package/dist/listener.d.ts.map +1 -0
  44. package/dist/listener.js +55 -0
  45. package/dist/listener.test.d.ts +2 -0
  46. package/dist/listener.test.d.ts.map +1 -0
  47. package/dist/listener.test.js +43 -0
  48. package/dist/message-stream.d.ts +52 -0
  49. package/dist/message-stream.d.ts.map +1 -0
  50. package/dist/message-stream.js +78 -0
  51. package/dist/message-stream.test.d.ts +2 -0
  52. package/dist/message-stream.test.d.ts.map +1 -0
  53. package/dist/message-stream.test.js +44 -0
  54. package/dist/render/adaptive-card.d.ts +43 -0
  55. package/dist/render/adaptive-card.d.ts.map +1 -0
  56. package/dist/render/adaptive-card.js +401 -0
  57. package/dist/render/adaptive-card.test.d.ts +2 -0
  58. package/dist/render/adaptive-card.test.d.ts.map +1 -0
  59. package/dist/render/adaptive-card.test.js +240 -0
  60. package/dist/render/auto-close.d.ts +27 -0
  61. package/dist/render/auto-close.d.ts.map +1 -0
  62. package/dist/render/auto-close.js +153 -0
  63. package/dist/render/auto-close.test.d.ts +2 -0
  64. package/dist/render/auto-close.test.d.ts.map +1 -0
  65. package/dist/render/auto-close.test.js +46 -0
  66. package/dist/render/budget.d.ts +38 -0
  67. package/dist/render/budget.d.ts.map +1 -0
  68. package/dist/render/budget.js +44 -0
  69. package/dist/render/budget.test.d.ts +2 -0
  70. package/dist/render/budget.test.d.ts.map +1 -0
  71. package/dist/render/budget.test.js +25 -0
  72. package/dist/render/markdown.d.ts +13 -0
  73. package/dist/render/markdown.d.ts.map +1 -0
  74. package/dist/render/markdown.js +121 -0
  75. package/dist/render/markdown.test.d.ts +2 -0
  76. package/dist/render/markdown.test.d.ts.map +1 -0
  77. package/dist/render/markdown.test.js +58 -0
  78. package/dist/sanitizing-http-agent.d.ts +35 -0
  79. package/dist/sanitizing-http-agent.d.ts.map +1 -0
  80. package/dist/sanitizing-http-agent.js +58 -0
  81. package/dist/types.d.ts +53 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +1 -0
  84. package/package.json +67 -0
@@ -0,0 +1,539 @@
1
+ import { CloudAdapter, CardFactory, MessageFactory, } from "@microsoft/agents-hosting";
2
+ import { ActivityTypes, Activity } from "@microsoft/agents-activity";
3
+ import { TeamsConversationStore } from "./conversation-store.js";
4
+ import { buildFileContentParts } from "./download-files.js";
5
+ import { buildChannelFileContentParts } from "./graph-files.js";
6
+ import { createTeamsServer } from "./listener.js";
7
+ import { conversationKeyOf, parseCardAction } from "./interaction.js";
8
+ import { createRunRenderer } from "./event-renderer.js";
9
+ import { renderTeamsMarkdown } from "./render/markdown.js";
10
+ import { renderAdaptiveCard, isPlainText } from "./render/adaptive-card.js";
11
+ import { TeamsMessageStream } from "./message-stream.js";
12
+ /**
13
+ * Microsoft Teams `PlatformAdapter`.
14
+ *
15
+ * Ingress: a `CloudAdapter` receives Teams activities at `POST /api/messages`
16
+ * (M365 Agents SDK). The inbound HTTP turn is **acked immediately**; the agent
17
+ * run is handed off to a detached `continueConversation` so it can outlive the
18
+ * turn. This is required for HITL, where `awaitChoice` suspends the run until a user
19
+ * clicks an Adaptive Card button (possibly minutes later). That detached
20
+ * `continueConversation` provides a stable `TurnContext` the whole run streams
21
+ * on, exactly as the Slack adapter runs in the background off a web client.
22
+ *
23
+ * Adaptive Card `Action.Submit` clicks arrive as Message activities carrying
24
+ * the action `data` in `activity.value`; those are decoded and routed to
25
+ * `sink.onInteraction` to resolve the waiter. This path needs no Microsoft
26
+ * credentials in the local M365 Agents Playground.
27
+ */
28
+ export class TeamsAdapter {
29
+ opts;
30
+ platform = "teams";
31
+ capabilities;
32
+ // Teams keeps the inbound HTTP turn open while the bot works; ~15s is the
33
+ // practical channel window. Declarative today (the engine doesn't enforce it).
34
+ ackDeadlineMs = 15000;
35
+ store = new TeamsConversationStore();
36
+ cloud;
37
+ server;
38
+ sink;
39
+ constructor(opts = {}) {
40
+ this.opts = opts;
41
+ this.capabilities = {
42
+ supportsModals: false,
43
+ supportsTyping: true,
44
+ supportsReactions: false,
45
+ // Streamed by message edit (post-then-updateActivity), not native
46
+ // token-by-token streaming, but the engine's streaming path is honored.
47
+ supportsStreaming: true,
48
+ };
49
+ }
50
+ async start(sink) {
51
+ this.sink = sink;
52
+ const authConfig = {
53
+ clientId: this.opts.clientId ?? process.env.clientId,
54
+ clientSecret: this.opts.clientSecret ?? process.env.clientSecret,
55
+ tenantId: this.opts.tenantId ?? process.env.tenantId,
56
+ };
57
+ this.cloud = new CloudAdapter(authConfig);
58
+ // Contain turn-handler failures at the SDK boundary. Without this the M365
59
+ // adapter rethrows (e.g. a Bot Connector 401 surfaces as "Unknown error
60
+ // type"), which becomes an unhandled rejection and crashes the process,
61
+ // turning one bad turn into a service-wide outage + restart loop.
62
+ this.cloud.onTurnError = async (_context, error) => {
63
+ console.error("[bot-teams] turn error:", error);
64
+ };
65
+ this.server = createTeamsServer({
66
+ adapter: this.cloud,
67
+ port: this.opts.port ?? 3978,
68
+ onTurnContext: (context) => this.handleActivity(context, sink),
69
+ });
70
+ await this.server.start();
71
+ }
72
+ async stop() {
73
+ await this.server?.stop();
74
+ }
75
+ /**
76
+ * Normalize an inbound activity, ack the HTTP turn immediately, and drive the
77
+ * work into the engine on a **detached** `continueConversation` so it can
78
+ * outlive this turn (HITL suspends the run until a later click).
79
+ */
80
+ async handleActivity(context, sink) {
81
+ const activity = context.activity;
82
+ if (activity.type !== ActivityTypes.Message)
83
+ return;
84
+ const conversationKey = conversationKeyOf(activity);
85
+ const reference = activity.getConversationReference();
86
+ const from = activity.from;
87
+ const user = from?.id
88
+ ? { id: from.id, name: from.name }
89
+ : undefined;
90
+ // An Adaptive Card `Action.Submit` arrives as a Message activity carrying
91
+ // our action `data` in `value` (and no user text). Route it as an
92
+ // interaction so the engine resolves the matching `awaitChoice` waiter and
93
+ // runs the button's `onClick` (which edits the picker card in place).
94
+ const action = parseCardAction(activity);
95
+ if (action) {
96
+ const onInteraction = (replyContext) => Promise.resolve(sink.onInteraction({
97
+ id: action.id,
98
+ conversationKey,
99
+ value: action.value,
100
+ user,
101
+ replyTarget: {
102
+ conversationKey,
103
+ reference,
104
+ context: replyContext,
105
+ },
106
+ messageRef: {
107
+ id: activity.replyToId ?? "",
108
+ conversationKey,
109
+ reference,
110
+ context: replyContext,
111
+ },
112
+ }));
113
+ if (this.canGoProactive()) {
114
+ // Credentialed (real Teams): the inbound card-click turn's connector
115
+ // client is created with an anonymous identity, so editing the card in
116
+ // place (`updateActivity`, a PUT to the Connector) is rejected 401.
117
+ // Run the interaction on a detached, app-id-authenticated proactive
118
+ // context (exactly like an ordinary turn) and ack the click now.
119
+ this.runDetached(reference, onInteraction);
120
+ }
121
+ else {
122
+ // Anonymous local Playground: the inbound turn context is the only one
123
+ // available (no app id for `continueConversation`) and works there.
124
+ try {
125
+ await onInteraction(context);
126
+ }
127
+ catch (err) {
128
+ console.error("[bot-teams] interaction failed:", err);
129
+ }
130
+ }
131
+ return;
132
+ }
133
+ // Ordinary chat message. Strip any `<at>bot</at>` mention (channel scope).
134
+ let text = "";
135
+ try {
136
+ text = (activity.removeRecipientMention() ?? activity.text ?? "").trim();
137
+ }
138
+ catch {
139
+ text = (activity.text ?? "").trim();
140
+ }
141
+ // Uploaded files (e.g. a CSV the user wants charted) ride along as
142
+ // attachments. Download them and hand the model multimodal content parts —
143
+ // the "upload a CSV → get a chart" payoff. Done inside `drive` (not before
144
+ // the ack) so a slow download never blocks the inbound HTTP turn, and so
145
+ // the transcript records the file's CONTENT (not just the text), letting a
146
+ // follow-up turn ("now make it a bar chart") still act on the data.
147
+ const drive = async (target) => {
148
+ // Keep "…is typing" visible for the whole turn. Teams' indicator expires
149
+ // after a few seconds, and slow work (downloading a file, rendering a
150
+ // chart) posts nothing in the meantime, so a one-shot ping leaves dead
151
+ // air. Heartbeat until the run resolves.
152
+ const stopTyping = this.startTypingHeartbeat(target);
153
+ try {
154
+ const { parts, notes } = await this.collectInboundFileParts(activity);
155
+ // Build contentParts only when we actually got file content (plus any
156
+ // partial-failure notes). A pure failure with no parts is logged but
157
+ // not surfaced, so routine no-file channel messages aren't polluted
158
+ // with "couldn't read file" noise.
159
+ let contentParts;
160
+ if (parts.length > 0) {
161
+ contentParts = [
162
+ ...(text ? [{ type: "text", text }] : []),
163
+ ...parts,
164
+ ...(notes.length
165
+ ? [
166
+ {
167
+ type: "text",
168
+ text: `[attachment notes: ${notes.join("; ")}]`,
169
+ },
170
+ ]
171
+ : []),
172
+ ];
173
+ }
174
+ // Record the incoming message into the transcript so it (a) reaches the
175
+ // model this turn via the seeded history and (b) persists for later
176
+ // turns. Store the multimodal content when present (so an uploaded CSV
177
+ // is remembered), else the plain text.
178
+ this.store.recordUser(conversationKey, contentParts ?? text);
179
+ await sink.onTurn({
180
+ conversationKey,
181
+ replyTarget: target,
182
+ userText: text,
183
+ user,
184
+ platform: this.platform,
185
+ contentParts,
186
+ });
187
+ }
188
+ finally {
189
+ stopTyping();
190
+ }
191
+ };
192
+ if (this.canGoProactive()) {
193
+ // Credentialed (real Teams): ack the turn now and run on a detached
194
+ // proactive context so HITL's `awaitChoice` can suspend the run for
195
+ // minutes without holding the HTTP turn open.
196
+ this.runDetached(reference, (proactive) => drive({ conversationKey, reference, context: proactive }));
197
+ }
198
+ else {
199
+ // Anonymous/local (M365 Playground): `continueConversation` needs an app
200
+ // id we don't have, so run on the inbound turn context. The localhost
201
+ // connection stays open across an `awaitChoice` suspend until the click.
202
+ try {
203
+ await drive({ conversationKey, reference, context });
204
+ }
205
+ catch (err) {
206
+ console.error("[bot-teams] in-turn run failed:", err);
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * Resolve a message's uploaded files into AG-UI content parts. Personal chats
212
+ * deliver the file inline (the Teams bot file API), which `buildFileContentParts`
213
+ * handles; a channel doesn't include the file at all, so we fetch it through
214
+ * Microsoft Graph when credentials are configured (group chats aren't wired
215
+ * up yet). Failures are logged, never thrown.
216
+ */
217
+ async collectInboundFileParts(activity) {
218
+ const attachments = (activity.attachments ?? []);
219
+ const convType = activity.conversation?.conversationType;
220
+ // Inline path (personal chat, or any direct file/media attachment).
221
+ if (attachments.length > 0) {
222
+ const result = await buildFileContentParts(attachments, this.opts.files);
223
+ this.logFileParts("attachment", result);
224
+ if (result.parts.length > 0)
225
+ return result;
226
+ }
227
+ // Channel path: the file lives in SharePoint, reachable only via Graph.
228
+ if (convType === "channel") {
229
+ const creds = this.graphCredentials();
230
+ const ref = this.channelMessageRef(activity);
231
+ if (creds && ref) {
232
+ const result = await buildChannelFileContentParts(ref, creds, this.opts.files);
233
+ this.logFileParts("graph", result);
234
+ return result;
235
+ }
236
+ }
237
+ return { parts: [], notes: [] };
238
+ }
239
+ /** App-only Graph credentials, when all three are configured. */
240
+ graphCredentials() {
241
+ const clientId = this.opts.clientId ?? process.env.clientId;
242
+ const clientSecret = this.opts.clientSecret ?? process.env.clientSecret;
243
+ const tenantId = this.opts.tenantId ?? process.env.tenantId;
244
+ if (clientId && clientSecret && tenantId) {
245
+ return { clientId, clientSecret, tenantId };
246
+ }
247
+ return undefined;
248
+ }
249
+ /** Pull the team/channel/message ids Graph needs out of a channel activity. */
250
+ channelMessageRef(activity) {
251
+ const cd = activity.channelData;
252
+ const teamId = cd?.team?.aadGroupId;
253
+ const channelId = cd?.teamsChannelId;
254
+ const messageId = activity.id;
255
+ if (!teamId || !channelId || !messageId)
256
+ return undefined;
257
+ // conversation.id is "<channel>;messageid=<rootId>"; the root differs from
258
+ // messageId only when the inbound message is a reply.
259
+ const convId = activity.conversation?.id ?? "";
260
+ const rootId = convId.match(/messageid=(\d+)/)?.[1] ?? messageId;
261
+ return { teamId, channelId, messageId, rootId };
262
+ }
263
+ /**
264
+ * Concise operational log for the inbound-file pipeline: how many parts we
265
+ * built and any skip reasons. Deliberately content-free — it never logs file
266
+ * bytes, decoded text, or SharePoint URLs.
267
+ */
268
+ logFileParts(source, result) {
269
+ if (result.parts.length === 0 && result.notes.length === 0)
270
+ return;
271
+ console.log(`[bot-teams] ${source}: ${result.parts.length} file part(s)` +
272
+ (result.notes.length ? `; notes: ${result.notes.join(" | ")}` : ""));
273
+ }
274
+ /** Whether we can send proactively (out-of-turn). Requires a Microsoft app id. */
275
+ canGoProactive() {
276
+ return Boolean(this.opts.clientId ?? process.env.clientId);
277
+ }
278
+ /**
279
+ * Run `fn` against a proactive `TurnContext` opened by `continueConversation`,
280
+ * detached from any inbound HTTP turn. Fire-and-forget: the caller acks the
281
+ * inbound turn immediately and this runs (and may suspend at `awaitChoice`)
282
+ * in the background. Errors are logged, never surfaced to the inbound turn.
283
+ */
284
+ runDetached(reference, fn) {
285
+ void this.withProactive(reference, fn).catch((err) => {
286
+ console.error("[bot-teams] detached turn failed:", err);
287
+ });
288
+ }
289
+ /** Open a proactive `TurnContext` for the conversation and await `fn`. */
290
+ async withProactive(reference, fn) {
291
+ if (!this.cloud)
292
+ return;
293
+ const appId = this.opts.clientId ?? process.env.clientId ?? "";
294
+ await this.cloud.continueConversation(appId, reference, (context) => fn(context));
295
+ }
296
+ /**
297
+ * Render IR to a native payload: plain text when it collapses to text,
298
+ * otherwise an Adaptive Card. (A bare `Echo: hi` is a text bubble; structured
299
+ * or interactive UI becomes a card.)
300
+ */
301
+ render(ir) {
302
+ return isPlainText(ir)
303
+ ? { text: renderTeamsMarkdown(ir) }
304
+ : { card: renderAdaptiveCard(ir) };
305
+ }
306
+ async post(target, ir) {
307
+ const t = target;
308
+ const payload = this.render(ir);
309
+ const id = "text" in payload
310
+ ? await this.sendText(t, payload.text)
311
+ : await this.sendCard(t, payload.card);
312
+ return { id, conversationKey: t.conversationKey, context: t.context };
313
+ }
314
+ async update(ref, ir) {
315
+ const r = ref;
316
+ if (!r.id)
317
+ return;
318
+ const payload = this.render(ir);
319
+ // `updateActivity` re-derives addressing from the turn, so we build a fresh
320
+ // activity carrying only the id + new content (cloning the inbound activity
321
+ // drags fields that fail the SDK's re-validation).
322
+ const edit = async (context) => {
323
+ const activity = "text" in payload
324
+ ? MessageFactory.text(payload.text)
325
+ : MessageFactory.attachment(CardFactory.adaptiveCard(payload.card));
326
+ activity.id = r.id;
327
+ await context.updateActivity(activity);
328
+ };
329
+ // Prefer a live context; otherwise re-enter the conversation out-of-turn so
330
+ // a picker card can be edited in place after its run has detached.
331
+ if (r.context) {
332
+ await edit(r.context);
333
+ }
334
+ else if (r.reference) {
335
+ await this.withProactive(r.reference, edit);
336
+ }
337
+ }
338
+ /**
339
+ * Stream a text reply by message edit: post the first content, then
340
+ * `updateActivity` the same message as the buffer grows (throttled). This is
341
+ * Teams' baseline streaming model: no native token streaming.
342
+ */
343
+ async stream(target, chunks) {
344
+ const t = target;
345
+ const stream = new TeamsMessageStream({
346
+ post: (text) => this.sendText(t, text),
347
+ update: (id, text) => this.updateText(t, id, text),
348
+ typing: () => this.sendTyping(t),
349
+ });
350
+ let acc = "";
351
+ for await (const chunk of chunks) {
352
+ acc += chunk;
353
+ stream.append(acc);
354
+ }
355
+ const id = (await stream.finish()) ?? "";
356
+ return { id, conversationKey: t.conversationKey, context: t.context };
357
+ }
358
+ async delete(ref) {
359
+ const r = ref;
360
+ if (!r.context || !r.id)
361
+ return;
362
+ await r.context.deleteActivity(r.id);
363
+ }
364
+ /**
365
+ * Post a file to the conversation. Teams renders an image inline when it's
366
+ * sent as an attachment whose `contentUrl` is a `data:` URI, so we base64 the
367
+ * bytes into one — exactly what `render_chart`/`render_diagram` need to drop a
368
+ * PNG into the thread (the bot-slack `postFile` parallel). Non-image bytes are
369
+ * still attached with their inferred MIME; whether Teams previews them is up
370
+ * to the client. Sends on the live turn context, or proactively by reference.
371
+ */
372
+ async postFile(target, { bytes, filename, altText, }) {
373
+ const t = target;
374
+ const mime = mimeFromFilename(filename);
375
+ const base64 = Buffer.from(bytes).toString("base64");
376
+ const activity = MessageFactory.attachment({
377
+ contentType: mime,
378
+ contentUrl: `data:${mime};base64,${base64}`,
379
+ name: altText ?? filename,
380
+ });
381
+ try {
382
+ if (t.context) {
383
+ const res = await t.context.sendActivity(activity);
384
+ return { ok: true, fileId: res?.id };
385
+ }
386
+ if (this.cloud && t.reference) {
387
+ let fileId;
388
+ const appId = this.opts.clientId ?? process.env.clientId ?? "";
389
+ await this.cloud.continueConversation(appId, t.reference, async (context) => {
390
+ const res = await context.sendActivity(activity);
391
+ fileId = res?.id;
392
+ });
393
+ return { ok: true, fileId };
394
+ }
395
+ return { ok: false, error: "no live or proactive context to post on" };
396
+ }
397
+ catch (e) {
398
+ return { ok: false, error: e.message };
399
+ }
400
+ }
401
+ createRunRenderer(target) {
402
+ const t = target;
403
+ return createRunRenderer({
404
+ interruptEventNames: this.opts.interruptEventNames,
405
+ post: (text) => this.sendText(t, text),
406
+ update: (id, text) => this.updateText(t, id, text),
407
+ typing: () => this.sendTyping(t),
408
+ recordAssistant: (text) => this.store.recordAssistant(t.conversationKey, text),
409
+ });
410
+ }
411
+ decodeInteraction(raw) {
412
+ const activity = raw;
413
+ const action = parseCardAction(activity);
414
+ if (!action)
415
+ return undefined;
416
+ const conversationKey = conversationKeyOf(activity);
417
+ const reference = activity.getConversationReference?.();
418
+ const from = activity.from;
419
+ return {
420
+ id: action.id,
421
+ conversationKey,
422
+ value: action.value,
423
+ user: from?.id ? { id: from.id, name: from.name } : undefined,
424
+ replyTarget: { conversationKey, reference },
425
+ messageRef: {
426
+ id: activity.replyToId ?? "",
427
+ conversationKey,
428
+ reference,
429
+ },
430
+ };
431
+ }
432
+ async lookupUser(_q) {
433
+ // Directory lookups require Microsoft Graph; not wired in milestone-1.
434
+ return undefined;
435
+ }
436
+ get conversationStore() {
437
+ return this.store;
438
+ }
439
+ /** Return the conversation transcript the adapter has accumulated. */
440
+ async getMessages(target) {
441
+ const t = target;
442
+ return this.store.getTranscript(t.conversationKey);
443
+ }
444
+ /** Send plain Markdown text, preferring the live turn context. */
445
+ async sendText(t, text) {
446
+ const trimmed = text.trim();
447
+ if (!trimmed)
448
+ return "";
449
+ if (t.context) {
450
+ const res = await t.context.sendActivity(trimmed);
451
+ return res?.id ?? "";
452
+ }
453
+ // Out-of-turn (proactive) send: re-enter the conversation by reference.
454
+ if (this.cloud && t.reference) {
455
+ let id = "";
456
+ const appId = this.opts.clientId ?? process.env.clientId ?? "";
457
+ await this.cloud.continueConversation(appId, t.reference, async (context) => {
458
+ const res = await context.sendActivity(trimmed);
459
+ id = res?.id ?? "";
460
+ });
461
+ return id;
462
+ }
463
+ return "";
464
+ }
465
+ /** Send an Adaptive Card as a message attachment on the live turn. */
466
+ async sendCard(t, card) {
467
+ const activity = MessageFactory.attachment(CardFactory.adaptiveCard(card));
468
+ if (t.context) {
469
+ const res = await t.context.sendActivity(activity);
470
+ return res?.id ?? "";
471
+ }
472
+ if (this.cloud && t.reference) {
473
+ let id = "";
474
+ const appId = this.opts.clientId ?? process.env.clientId ?? "";
475
+ await this.cloud.continueConversation(appId, t.reference, async (context) => {
476
+ const res = await context.sendActivity(activity);
477
+ id = res?.id ?? "";
478
+ });
479
+ return id;
480
+ }
481
+ return "";
482
+ }
483
+ /** Edit a previously-posted text activity in place (streamed-by-edit). */
484
+ async updateText(t, id, text) {
485
+ if (!t.context || !id)
486
+ return;
487
+ const activity = MessageFactory.text(text);
488
+ activity.id = id;
489
+ await t.context.updateActivity(activity);
490
+ }
491
+ /** Fire a typing indicator (shown while the agent works). */
492
+ async sendTyping(t) {
493
+ if (!t.context)
494
+ return;
495
+ try {
496
+ await t.context.sendActivity(new Activity(ActivityTypes.Typing));
497
+ }
498
+ catch {
499
+ // Typing is best-effort; never let it sink a reply.
500
+ }
501
+ }
502
+ /**
503
+ * Send a typing indicator now and keep re-sending it every few seconds until
504
+ * the returned stop fn is called. Teams' typing indicator lapses after a few
505
+ * seconds, so a single ping can't cover slow work (a Graph download, a chart
506
+ * render) where nothing posts in between. Returns a stop fn; call it in a
507
+ * `finally` so the timer is always cleared.
508
+ */
509
+ startTypingHeartbeat(t) {
510
+ void this.sendTyping(t); // immediate, so the indicator shows right away
511
+ const timer = setInterval(() => void this.sendTyping(t), 3500);
512
+ return () => clearInterval(timer);
513
+ }
514
+ }
515
+ /** Construct a Microsoft Teams `PlatformAdapter`. */
516
+ export function teams(opts = {}) {
517
+ return new TeamsAdapter(opts);
518
+ }
519
+ /** Best-effort MIME from a filename extension; defaults to PNG (the common case). */
520
+ function mimeFromFilename(filename) {
521
+ const ext = filename.split(".").pop()?.toLowerCase();
522
+ switch (ext) {
523
+ case "png":
524
+ return "image/png";
525
+ case "jpg":
526
+ case "jpeg":
527
+ return "image/jpeg";
528
+ case "gif":
529
+ return "image/gif";
530
+ case "webp":
531
+ return "image/webp";
532
+ case "svg":
533
+ return "image/svg+xml";
534
+ case "pdf":
535
+ return "application/pdf";
536
+ default:
537
+ return "image/png";
538
+ }
539
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=adapter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}