@openclaw/msteams 2026.1.29

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 (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/attachments/download.ts +206 -0
  6. package/src/attachments/graph.ts +319 -0
  7. package/src/attachments/html.ts +76 -0
  8. package/src/attachments/payload.ts +22 -0
  9. package/src/attachments/shared.ts +235 -0
  10. package/src/attachments/types.ts +37 -0
  11. package/src/attachments.test.ts +424 -0
  12. package/src/attachments.ts +18 -0
  13. package/src/channel.directory.test.ts +46 -0
  14. package/src/channel.ts +436 -0
  15. package/src/conversation-store-fs.test.ts +89 -0
  16. package/src/conversation-store-fs.ts +155 -0
  17. package/src/conversation-store-memory.ts +45 -0
  18. package/src/conversation-store.ts +41 -0
  19. package/src/directory-live.ts +179 -0
  20. package/src/errors.test.ts +46 -0
  21. package/src/errors.ts +158 -0
  22. package/src/file-consent-helpers.test.ts +234 -0
  23. package/src/file-consent-helpers.ts +73 -0
  24. package/src/file-consent.ts +122 -0
  25. package/src/graph-chat.ts +52 -0
  26. package/src/graph-upload.ts +445 -0
  27. package/src/inbound.test.ts +67 -0
  28. package/src/inbound.ts +38 -0
  29. package/src/index.ts +4 -0
  30. package/src/media-helpers.test.ts +186 -0
  31. package/src/media-helpers.ts +77 -0
  32. package/src/messenger.test.ts +245 -0
  33. package/src/messenger.ts +460 -0
  34. package/src/monitor-handler/inbound-media.ts +123 -0
  35. package/src/monitor-handler/message-handler.ts +629 -0
  36. package/src/monitor-handler.ts +166 -0
  37. package/src/monitor-types.ts +5 -0
  38. package/src/monitor.ts +290 -0
  39. package/src/onboarding.ts +432 -0
  40. package/src/outbound.ts +47 -0
  41. package/src/pending-uploads.ts +87 -0
  42. package/src/policy.test.ts +210 -0
  43. package/src/policy.ts +247 -0
  44. package/src/polls-store-memory.ts +30 -0
  45. package/src/polls-store.test.ts +40 -0
  46. package/src/polls.test.ts +73 -0
  47. package/src/polls.ts +300 -0
  48. package/src/probe.test.ts +57 -0
  49. package/src/probe.ts +99 -0
  50. package/src/reply-dispatcher.ts +128 -0
  51. package/src/resolve-allowlist.ts +277 -0
  52. package/src/runtime.ts +14 -0
  53. package/src/sdk-types.ts +19 -0
  54. package/src/sdk.ts +33 -0
  55. package/src/send-context.ts +156 -0
  56. package/src/send.ts +489 -0
  57. package/src/sent-message-cache.test.ts +16 -0
  58. package/src/sent-message-cache.ts +41 -0
  59. package/src/storage.ts +22 -0
  60. package/src/store-fs.ts +80 -0
  61. package/src/token.ts +19 -0
@@ -0,0 +1,629 @@
1
+ import {
2
+ buildPendingHistoryContextFromMap,
3
+ clearHistoryEntriesIfEnabled,
4
+ DEFAULT_GROUP_HISTORY_LIMIT,
5
+ logInboundDrop,
6
+ recordPendingHistoryEntryIfEnabled,
7
+ resolveControlCommandGate,
8
+ resolveMentionGating,
9
+ formatAllowlistMatchMeta,
10
+ type HistoryEntry,
11
+ } from "openclaw/plugin-sdk";
12
+
13
+ import {
14
+ buildMSTeamsAttachmentPlaceholder,
15
+ buildMSTeamsMediaPayload,
16
+ type MSTeamsAttachmentLike,
17
+ summarizeMSTeamsHtmlAttachments,
18
+ } from "../attachments.js";
19
+ import type { StoredConversationReference } from "../conversation-store.js";
20
+ import { formatUnknownError } from "../errors.js";
21
+ import {
22
+ extractMSTeamsConversationMessageId,
23
+ normalizeMSTeamsConversationId,
24
+ parseMSTeamsActivityTimestamp,
25
+ stripMSTeamsMentionTags,
26
+ wasMSTeamsBotMentioned,
27
+ } from "../inbound.js";
28
+ import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
29
+ import {
30
+ isMSTeamsGroupAllowed,
31
+ resolveMSTeamsAllowlistMatch,
32
+ resolveMSTeamsReplyPolicy,
33
+ resolveMSTeamsRouteConfig,
34
+ } from "../policy.js";
35
+ import { extractMSTeamsPollVote } from "../polls.js";
36
+ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
37
+ import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
38
+ import type { MSTeamsTurnContext } from "../sdk-types.js";
39
+ import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
40
+ import { getMSTeamsRuntime } from "../runtime.js";
41
+
42
+ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
43
+ const {
44
+ cfg,
45
+ runtime,
46
+ appId,
47
+ adapter,
48
+ tokenProvider,
49
+ textLimit,
50
+ mediaMaxBytes,
51
+ conversationStore,
52
+ pollStore,
53
+ log,
54
+ } = deps;
55
+ const core = getMSTeamsRuntime();
56
+ const logVerboseMessage = (message: string) => {
57
+ if (core.logging.shouldLogVerbose()) {
58
+ log.debug(message);
59
+ }
60
+ };
61
+ const msteamsCfg = cfg.channels?.msteams;
62
+ const historyLimit = Math.max(
63
+ 0,
64
+ msteamsCfg?.historyLimit ??
65
+ cfg.messages?.groupChat?.historyLimit ??
66
+ DEFAULT_GROUP_HISTORY_LIMIT,
67
+ );
68
+ const conversationHistories = new Map<string, HistoryEntry[]>();
69
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
70
+ cfg,
71
+ channel: "msteams",
72
+ });
73
+
74
+ type MSTeamsDebounceEntry = {
75
+ context: MSTeamsTurnContext;
76
+ rawText: string;
77
+ text: string;
78
+ attachments: MSTeamsAttachmentLike[];
79
+ wasMentioned: boolean;
80
+ implicitMention: boolean;
81
+ };
82
+
83
+ const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
84
+ const context = params.context;
85
+ const activity = context.activity;
86
+ const rawText = params.rawText;
87
+ const text = params.text;
88
+ const attachments = params.attachments;
89
+ const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments);
90
+ const rawBody = text || attachmentPlaceholder;
91
+ const from = activity.from;
92
+ const conversation = activity.conversation;
93
+
94
+ const attachmentTypes = attachments
95
+ .map((att) => (typeof att.contentType === "string" ? att.contentType : undefined))
96
+ .filter(Boolean)
97
+ .slice(0, 3);
98
+ const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
99
+
100
+ log.info("received message", {
101
+ rawText: rawText.slice(0, 50),
102
+ text: text.slice(0, 50),
103
+ attachments: attachments.length,
104
+ attachmentTypes,
105
+ from: from?.id,
106
+ conversation: conversation?.id,
107
+ });
108
+ if (htmlSummary) log.debug("html attachment summary", htmlSummary);
109
+
110
+ if (!from?.id) {
111
+ log.debug("skipping message without from.id");
112
+ return;
113
+ }
114
+
115
+ // Teams conversation.id may include ";messageid=..." suffix - strip it for session key.
116
+ const rawConversationId = conversation?.id ?? "";
117
+ const conversationId = normalizeMSTeamsConversationId(rawConversationId);
118
+ const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
119
+ const conversationType = conversation?.conversationType ?? "personal";
120
+ const isGroupChat = conversationType === "groupChat" || conversation?.isGroup === true;
121
+ const isChannel = conversationType === "channel";
122
+ const isDirectMessage = !isGroupChat && !isChannel;
123
+
124
+ const senderName = from.name ?? from.id;
125
+ const senderId = from.aadObjectId ?? from.id;
126
+ const storedAllowFrom = await core.channel.pairing
127
+ .readAllowFromStore("msteams")
128
+ .catch(() => []);
129
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
130
+
131
+ // Check DM policy for direct messages.
132
+ const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
133
+ const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom];
134
+ if (isDirectMessage && msteamsCfg) {
135
+ const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
136
+ const allowFrom = dmAllowFrom;
137
+
138
+ if (dmPolicy === "disabled") {
139
+ log.debug("dropping dm (dms disabled)");
140
+ return;
141
+ }
142
+
143
+ if (dmPolicy !== "open") {
144
+ const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
145
+ const allowMatch = resolveMSTeamsAllowlistMatch({
146
+ allowFrom: effectiveAllowFrom,
147
+ senderId,
148
+ senderName,
149
+ });
150
+
151
+ if (!allowMatch.allowed) {
152
+ if (dmPolicy === "pairing") {
153
+ const request = await core.channel.pairing.upsertPairingRequest({
154
+ channel: "msteams",
155
+ id: senderId,
156
+ meta: { name: senderName },
157
+ });
158
+ if (request) {
159
+ log.info("msteams pairing request created", {
160
+ sender: senderId,
161
+ label: senderName,
162
+ });
163
+ }
164
+ }
165
+ log.debug("dropping dm (not allowlisted)", {
166
+ sender: senderId,
167
+ label: senderName,
168
+ allowlistMatch: formatAllowlistMatchMeta(allowMatch),
169
+ });
170
+ return;
171
+ }
172
+ }
173
+ }
174
+
175
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
176
+ const groupPolicy =
177
+ !isDirectMessage && msteamsCfg
178
+ ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
179
+ : "disabled";
180
+ const groupAllowFrom =
181
+ !isDirectMessage && msteamsCfg
182
+ ? (msteamsCfg.groupAllowFrom ??
183
+ (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []))
184
+ : [];
185
+ const effectiveGroupAllowFrom =
186
+ !isDirectMessage && msteamsCfg
187
+ ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom]
188
+ : [];
189
+ const teamId = activity.channelData?.team?.id;
190
+ const teamName = activity.channelData?.team?.name;
191
+ const channelName = activity.channelData?.channel?.name;
192
+ const channelGate = resolveMSTeamsRouteConfig({
193
+ cfg: msteamsCfg,
194
+ teamId,
195
+ teamName,
196
+ conversationId,
197
+ channelName,
198
+ });
199
+
200
+ if (!isDirectMessage && msteamsCfg) {
201
+ if (groupPolicy === "disabled") {
202
+ log.debug("dropping group message (groupPolicy: disabled)", {
203
+ conversationId,
204
+ });
205
+ return;
206
+ }
207
+
208
+ if (groupPolicy === "allowlist") {
209
+ if (channelGate.allowlistConfigured && !channelGate.allowed) {
210
+ log.debug("dropping group message (not in team/channel allowlist)", {
211
+ conversationId,
212
+ teamKey: channelGate.teamKey ?? "none",
213
+ channelKey: channelGate.channelKey ?? "none",
214
+ channelMatchKey: channelGate.channelMatchKey ?? "none",
215
+ channelMatchSource: channelGate.channelMatchSource ?? "none",
216
+ });
217
+ return;
218
+ }
219
+ if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
220
+ log.debug("dropping group message (groupPolicy: allowlist, no allowlist)", {
221
+ conversationId,
222
+ });
223
+ return;
224
+ }
225
+ if (effectiveGroupAllowFrom.length > 0) {
226
+ const allowMatch = resolveMSTeamsAllowlistMatch({
227
+ groupPolicy,
228
+ allowFrom: effectiveGroupAllowFrom,
229
+ senderId,
230
+ senderName,
231
+ });
232
+ if (!allowMatch.allowed) {
233
+ log.debug("dropping group message (not in groupAllowFrom)", {
234
+ sender: senderId,
235
+ label: senderName,
236
+ allowlistMatch: formatAllowlistMatchMeta(allowMatch),
237
+ });
238
+ return;
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ const ownerAllowedForCommands = isMSTeamsGroupAllowed({
245
+ groupPolicy: "allowlist",
246
+ allowFrom: effectiveDmAllowFrom,
247
+ senderId,
248
+ senderName,
249
+ });
250
+ const groupAllowedForCommands = isMSTeamsGroupAllowed({
251
+ groupPolicy: "allowlist",
252
+ allowFrom: effectiveGroupAllowFrom,
253
+ senderId,
254
+ senderName,
255
+ });
256
+ const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
257
+ const commandGate = resolveControlCommandGate({
258
+ useAccessGroups,
259
+ authorizers: [
260
+ { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
261
+ { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
262
+ ],
263
+ allowTextCommands: true,
264
+ hasControlCommand: hasControlCommandInMessage,
265
+ });
266
+ const commandAuthorized = commandGate.commandAuthorized;
267
+ if (commandGate.shouldBlock) {
268
+ logInboundDrop({
269
+ log: logVerboseMessage,
270
+ channel: "msteams",
271
+ reason: "control command (unauthorized)",
272
+ target: senderId,
273
+ });
274
+ return;
275
+ }
276
+
277
+ // Build conversation reference for proactive replies.
278
+ const agent = activity.recipient;
279
+ const conversationRef: StoredConversationReference = {
280
+ activityId: activity.id,
281
+ user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
282
+ agent,
283
+ bot: agent ? { id: agent.id, name: agent.name } : undefined,
284
+ conversation: {
285
+ id: conversationId,
286
+ conversationType,
287
+ tenantId: conversation?.tenantId,
288
+ },
289
+ teamId,
290
+ channelId: activity.channelId,
291
+ serviceUrl: activity.serviceUrl,
292
+ locale: activity.locale,
293
+ };
294
+ conversationStore.upsert(conversationId, conversationRef).catch((err) => {
295
+ log.debug("failed to save conversation reference", {
296
+ error: formatUnknownError(err),
297
+ });
298
+ });
299
+
300
+ const pollVote = extractMSTeamsPollVote(activity);
301
+ if (pollVote) {
302
+ try {
303
+ const poll = await pollStore.recordVote({
304
+ pollId: pollVote.pollId,
305
+ voterId: senderId,
306
+ selections: pollVote.selections,
307
+ });
308
+ if (!poll) {
309
+ log.debug("poll vote ignored (poll not found)", {
310
+ pollId: pollVote.pollId,
311
+ });
312
+ } else {
313
+ log.info("recorded poll vote", {
314
+ pollId: pollVote.pollId,
315
+ voter: senderId,
316
+ selections: pollVote.selections,
317
+ });
318
+ }
319
+ } catch (err) {
320
+ log.error("failed to record poll vote", {
321
+ pollId: pollVote.pollId,
322
+ error: formatUnknownError(err),
323
+ });
324
+ }
325
+ return;
326
+ }
327
+
328
+ if (!rawBody) {
329
+ log.debug("skipping empty message after stripping mentions");
330
+ return;
331
+ }
332
+
333
+ const teamsFrom = isDirectMessage
334
+ ? `msteams:${senderId}`
335
+ : isChannel
336
+ ? `msteams:channel:${conversationId}`
337
+ : `msteams:group:${conversationId}`;
338
+ const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
339
+
340
+ const route = core.channel.routing.resolveAgentRoute({
341
+ cfg,
342
+ channel: "msteams",
343
+ peer: {
344
+ kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group",
345
+ id: isDirectMessage ? senderId : conversationId,
346
+ },
347
+ });
348
+
349
+ const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
350
+ const inboundLabel = isDirectMessage
351
+ ? `Teams DM from ${senderName}`
352
+ : `Teams message in ${conversationType} from ${senderName}`;
353
+
354
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
355
+ sessionKey: route.sessionKey,
356
+ contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
357
+ });
358
+
359
+ const channelId = conversationId;
360
+ const { teamConfig, channelConfig } = channelGate;
361
+ const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
362
+ isDirectMessage,
363
+ globalConfig: msteamsCfg,
364
+ teamConfig,
365
+ channelConfig,
366
+ });
367
+ const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
368
+
369
+ if (!isDirectMessage) {
370
+ const mentionGate = resolveMentionGating({
371
+ requireMention: Boolean(requireMention),
372
+ canDetectMention: true,
373
+ wasMentioned: params.wasMentioned,
374
+ implicitMention: params.implicitMention,
375
+ shouldBypassMention: false,
376
+ });
377
+ const mentioned = mentionGate.effectiveWasMentioned;
378
+ if (requireMention && mentionGate.shouldSkip) {
379
+ log.debug("skipping message (mention required)", {
380
+ teamId,
381
+ channelId,
382
+ requireMention,
383
+ mentioned,
384
+ });
385
+ recordPendingHistoryEntryIfEnabled({
386
+ historyMap: conversationHistories,
387
+ historyKey: conversationId,
388
+ limit: historyLimit,
389
+ entry: {
390
+ sender: senderName,
391
+ body: rawBody,
392
+ timestamp: timestamp?.getTime(),
393
+ messageId: activity.id ?? undefined,
394
+ },
395
+ });
396
+ return;
397
+ }
398
+ }
399
+ const mediaList = await resolveMSTeamsInboundMedia({
400
+ attachments,
401
+ htmlSummary: htmlSummary ?? undefined,
402
+ maxBytes: mediaMaxBytes,
403
+ tokenProvider,
404
+ allowHosts: msteamsCfg?.mediaAllowHosts,
405
+ conversationType,
406
+ conversationId,
407
+ conversationMessageId: conversationMessageId ?? undefined,
408
+ activity: {
409
+ id: activity.id,
410
+ replyToId: activity.replyToId,
411
+ channelData: activity.channelData,
412
+ },
413
+ log,
414
+ preserveFilenames: cfg.media?.preserveFilenames,
415
+ });
416
+
417
+ const mediaPayload = buildMSTeamsMediaPayload(mediaList);
418
+ const envelopeFrom = isDirectMessage ? senderName : conversationType;
419
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
420
+ agentId: route.agentId,
421
+ });
422
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
423
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
424
+ storePath,
425
+ sessionKey: route.sessionKey,
426
+ });
427
+ const body = core.channel.reply.formatAgentEnvelope({
428
+ channel: "Teams",
429
+ from: envelopeFrom,
430
+ timestamp,
431
+ previousTimestamp,
432
+ envelope: envelopeOptions,
433
+ body: rawBody,
434
+ });
435
+ let combinedBody = body;
436
+ const isRoomish = !isDirectMessage;
437
+ const historyKey = isRoomish ? conversationId : undefined;
438
+ if (isRoomish && historyKey) {
439
+ combinedBody = buildPendingHistoryContextFromMap({
440
+ historyMap: conversationHistories,
441
+ historyKey,
442
+ limit: historyLimit,
443
+ currentMessage: combinedBody,
444
+ formatEntry: (entry) =>
445
+ core.channel.reply.formatAgentEnvelope({
446
+ channel: "Teams",
447
+ from: conversationType,
448
+ timestamp: entry.timestamp,
449
+ body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
450
+ envelope: envelopeOptions,
451
+ }),
452
+ });
453
+ }
454
+
455
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
456
+ Body: combinedBody,
457
+ RawBody: rawBody,
458
+ CommandBody: rawBody,
459
+ From: teamsFrom,
460
+ To: teamsTo,
461
+ SessionKey: route.sessionKey,
462
+ AccountId: route.accountId,
463
+ ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
464
+ ConversationLabel: envelopeFrom,
465
+ GroupSubject: !isDirectMessage ? conversationType : undefined,
466
+ SenderName: senderName,
467
+ SenderId: senderId,
468
+ Provider: "msteams" as const,
469
+ Surface: "msteams" as const,
470
+ MessageSid: activity.id,
471
+ Timestamp: timestamp?.getTime() ?? Date.now(),
472
+ WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
473
+ CommandAuthorized: commandAuthorized,
474
+ OriginatingChannel: "msteams" as const,
475
+ OriginatingTo: teamsTo,
476
+ ...mediaPayload,
477
+ });
478
+
479
+ await core.channel.session.recordInboundSession({
480
+ storePath,
481
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
482
+ ctx: ctxPayload,
483
+ onRecordError: (err) => {
484
+ logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
485
+ },
486
+ });
487
+
488
+ logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
489
+
490
+ const sharePointSiteId = msteamsCfg?.sharePointSiteId;
491
+ const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
492
+ cfg,
493
+ agentId: route.agentId,
494
+ runtime,
495
+ log,
496
+ adapter,
497
+ appId,
498
+ conversationRef,
499
+ context,
500
+ replyStyle,
501
+ textLimit,
502
+ onSentMessageIds: (ids) => {
503
+ for (const id of ids) {
504
+ recordMSTeamsSentMessage(conversationId, id);
505
+ }
506
+ },
507
+ tokenProvider,
508
+ sharePointSiteId,
509
+ });
510
+
511
+ log.info("dispatching to agent", { sessionKey: route.sessionKey });
512
+ try {
513
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
514
+ ctx: ctxPayload,
515
+ cfg,
516
+ dispatcher,
517
+ replyOptions,
518
+ });
519
+
520
+ markDispatchIdle();
521
+ log.info("dispatch complete", { queuedFinal, counts });
522
+
523
+ const didSendReply = counts.final + counts.tool + counts.block > 0;
524
+ if (!queuedFinal) {
525
+ if (isRoomish && historyKey) {
526
+ clearHistoryEntriesIfEnabled({
527
+ historyMap: conversationHistories,
528
+ historyKey,
529
+ limit: historyLimit,
530
+ });
531
+ }
532
+ return;
533
+ }
534
+ const finalCount = counts.final;
535
+ logVerboseMessage(
536
+ `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
537
+ );
538
+ if (isRoomish && historyKey) {
539
+ clearHistoryEntriesIfEnabled({
540
+ historyMap: conversationHistories,
541
+ historyKey,
542
+ limit: historyLimit,
543
+ });
544
+ }
545
+ } catch (err) {
546
+ log.error("dispatch failed", { error: String(err) });
547
+ runtime.error?.(`msteams dispatch failed: ${String(err)}`);
548
+ try {
549
+ await context.sendActivity(
550
+ `⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
551
+ );
552
+ } catch {
553
+ // Best effort.
554
+ }
555
+ }
556
+ };
557
+
558
+ const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
559
+ debounceMs: inboundDebounceMs,
560
+ buildKey: (entry) => {
561
+ const conversationId = normalizeMSTeamsConversationId(
562
+ entry.context.activity.conversation?.id ?? "",
563
+ );
564
+ const senderId =
565
+ entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? "";
566
+ if (!senderId || !conversationId) return null;
567
+ return `msteams:${appId}:${conversationId}:${senderId}`;
568
+ },
569
+ shouldDebounce: (entry) => {
570
+ if (!entry.text.trim()) return false;
571
+ if (entry.attachments.length > 0) return false;
572
+ return !core.channel.text.hasControlCommand(entry.text, cfg);
573
+ },
574
+ onFlush: async (entries) => {
575
+ const last = entries.at(-1);
576
+ if (!last) return;
577
+ if (entries.length === 1) {
578
+ await handleTeamsMessageNow(last);
579
+ return;
580
+ }
581
+ const combinedText = entries
582
+ .map((entry) => entry.text)
583
+ .filter(Boolean)
584
+ .join("\n");
585
+ if (!combinedText.trim()) return;
586
+ const combinedRawText = entries
587
+ .map((entry) => entry.rawText)
588
+ .filter(Boolean)
589
+ .join("\n");
590
+ const wasMentioned = entries.some((entry) => entry.wasMentioned);
591
+ const implicitMention = entries.some((entry) => entry.implicitMention);
592
+ await handleTeamsMessageNow({
593
+ context: last.context,
594
+ rawText: combinedRawText,
595
+ text: combinedText,
596
+ attachments: [],
597
+ wasMentioned,
598
+ implicitMention,
599
+ });
600
+ },
601
+ onError: (err) => {
602
+ runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
603
+ },
604
+ });
605
+
606
+ return async function handleTeamsMessage(context: MSTeamsTurnContext) {
607
+ const activity = context.activity;
608
+ const rawText = activity.text?.trim() ?? "";
609
+ const text = stripMSTeamsMentionTags(rawText);
610
+ const attachments = Array.isArray(activity.attachments)
611
+ ? (activity.attachments as unknown as MSTeamsAttachmentLike[])
612
+ : [];
613
+ const wasMentioned = wasMSTeamsBotMentioned(activity);
614
+ const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
615
+ const replyToId = activity.replyToId ?? undefined;
616
+ const implicitMention = Boolean(
617
+ conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
618
+ );
619
+
620
+ await inboundDebouncer.enqueue({
621
+ context,
622
+ rawText,
623
+ text,
624
+ attachments,
625
+ wasMentioned,
626
+ implicitMention,
627
+ });
628
+ };
629
+ }