@openclaw/msteams 2026.3.13 → 2026.5.1-beta.1

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 (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +138 -1
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +163 -418
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +145 -4
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +161 -4
  78. package/src/graph-upload.ts +147 -56
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +504 -23
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +470 -164
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +281 -79
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +11 -5
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -107
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -1,53 +1,150 @@
1
+ import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
2
+ import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
3
+ import {
4
+ logInboundDrop,
5
+ resolveInboundSessionEnvelopeContext,
6
+ } from "openclaw/plugin-sdk/channel-inbound";
7
+ import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-gating";
8
+ import {
9
+ filterSupplementalContextItems,
10
+ resolveChannelContextVisibilityMode,
11
+ shouldIncludeSupplementalContext,
12
+ } from "openclaw/plugin-sdk/context-visibility-runtime";
13
+ import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
1
14
  import {
2
- DEFAULT_ACCOUNT_ID,
3
- buildPendingHistoryContextFromMap,
4
- clearHistoryEntriesIfEnabled,
5
15
  dispatchReplyFromConfigWithSettledDispatcher,
16
+ hasFinalInboundReplyDispatch,
17
+ resolveInboundReplyDispatchCounts,
18
+ } from "openclaw/plugin-sdk/inbound-reply-dispatch";
19
+ import {
20
+ buildPendingHistoryContextFromMap,
6
21
  DEFAULT_GROUP_HISTORY_LIMIT,
7
- createScopedPairingAccess,
8
- logInboundDrop,
9
- evaluateSenderGroupAccessForPolicy,
10
- resolveSenderScopedGroupPolicy,
11
22
  recordPendingHistoryEntryIfEnabled,
12
- resolveDualTextControlCommandGate,
13
- resolveDefaultGroupPolicy,
14
- isDangerousNameMatchingEnabled,
15
- readStoreAllowFromForDmPolicy,
16
- resolveMentionGating,
17
- resolveInboundSessionEnvelopeContext,
18
- formatAllowlistMatchMeta,
19
- resolveEffectiveAllowFromLists,
20
- resolveDmGroupAccessWithLists,
21
23
  type HistoryEntry,
22
- } from "openclaw/plugin-sdk/msteams";
24
+ } from "openclaw/plugin-sdk/reply-history";
23
25
  import {
24
26
  buildMSTeamsAttachmentPlaceholder,
25
27
  buildMSTeamsMediaPayload,
26
28
  type MSTeamsAttachmentLike,
27
29
  summarizeMSTeamsHtmlAttachments,
28
30
  } from "../attachments.js";
31
+ import { isRecord } from "../attachments/shared.js";
29
32
  import type { StoredConversationReference } from "../conversation-store.js";
30
33
  import { formatUnknownError } from "../errors.js";
34
+ import {
35
+ fetchThreadReplies,
36
+ formatThreadContext,
37
+ resolveTeamGroupId,
38
+ type GraphThreadMessage,
39
+ } from "../graph-thread.js";
40
+ import { resolveGraphChatId } from "../graph-upload.js";
31
41
  import {
32
42
  extractMSTeamsConversationMessageId,
43
+ extractMSTeamsQuoteInfo,
33
44
  normalizeMSTeamsConversationId,
34
45
  parseMSTeamsActivityTimestamp,
35
46
  stripMSTeamsMentionTags,
47
+ translateMSTeamsDmConversationIdForGraph,
36
48
  wasMSTeamsBotMentioned,
37
49
  } from "../inbound.js";
38
- import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
50
+ import {
51
+ fetchParentMessageCached,
52
+ formatParentContextEvent,
53
+ markParentContextInjected,
54
+ shouldInjectParentContext,
55
+ summarizeParentMessage,
56
+ } from "../thread-parent-context.js";
57
+
58
+ function extractTextFromHtmlAttachments(attachments: MSTeamsAttachmentLike[]): string {
59
+ for (const attachment of attachments) {
60
+ if (attachment.contentType !== "text/html") {
61
+ continue;
62
+ }
63
+ const content = attachment.content;
64
+ const raw =
65
+ typeof content === "string"
66
+ ? content
67
+ : isRecord(content) && typeof content.text === "string"
68
+ ? content.text
69
+ : isRecord(content) && typeof content.body === "string"
70
+ ? content.body
71
+ : "";
72
+ if (!raw) {
73
+ continue;
74
+ }
75
+ const text = raw
76
+ .replace(/<at[^>]*>.*?<\/at>/gis, " ")
77
+ .replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gis, "$2 $1")
78
+ .replace(/<br\s*\/?>/gi, "\n")
79
+ .replace(/<\/p>/gi, "\n")
80
+ .replace(/<[^>]+>/g, " ")
81
+ .replace(/&nbsp;/gi, " ")
82
+ .replace(/&amp;/gi, "&")
83
+ .replace(/\s+/g, " ")
84
+ .trim();
85
+ if (text) {
86
+ return text;
87
+ }
88
+ }
89
+ return "";
90
+ }
91
+ import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
39
92
  import {
40
93
  isMSTeamsGroupAllowed,
41
94
  resolveMSTeamsAllowlistMatch,
42
95
  resolveMSTeamsReplyPolicy,
43
- resolveMSTeamsRouteConfig,
44
96
  } from "../policy.js";
45
97
  import { extractMSTeamsPollVote } from "../polls.js";
46
98
  import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
47
99
  import { getMSTeamsRuntime } from "../runtime.js";
48
100
  import type { MSTeamsTurnContext } from "../sdk-types.js";
49
101
  import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
102
+ import { resolveMSTeamsSenderAccess } from "./access.js";
50
103
  import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
104
+ import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
105
+
106
+ function buildStoredConversationReference(params: {
107
+ activity: MSTeamsTurnContext["activity"];
108
+ conversationId: string;
109
+ conversationType: string;
110
+ teamId?: string;
111
+ /** Thread root message ID for channel thread messages. */
112
+ threadId?: string;
113
+ }): StoredConversationReference {
114
+ const { activity, conversationId, conversationType, teamId, threadId } = params;
115
+ const from = activity.from;
116
+ const conversation = activity.conversation;
117
+ const agent = activity.recipient;
118
+ const clientInfo = activity.entities?.find((e) => e.type === "clientInfo") as
119
+ | { timezone?: string }
120
+ | undefined;
121
+ // Bot Framework requires `tenantId` on outbound proactive activities so the
122
+ // connector can route them to the correct Azure AD tenant; missing it causes
123
+ // HTTP 403. Channel activities often leave `conversation.tenantId` unset, so
124
+ // prefer the canonical `channelData.tenant.id` source when available.
125
+ const channelDataTenantId = activity.channelData?.tenant?.id;
126
+ const tenantId = channelDataTenantId ?? conversation?.tenantId;
127
+ const aadObjectId = from?.aadObjectId;
128
+ return {
129
+ activityId: activity.id,
130
+ user: from ? { id: from.id, name: from.name, aadObjectId: from.aadObjectId } : undefined,
131
+ agent,
132
+ bot: agent ? { id: agent.id, name: agent.name } : undefined,
133
+ conversation: {
134
+ id: conversationId,
135
+ conversationType,
136
+ tenantId,
137
+ },
138
+ ...(tenantId ? { tenantId } : {}),
139
+ ...(aadObjectId ? { aadObjectId } : {}),
140
+ teamId,
141
+ channelId: activity.channelId,
142
+ serviceUrl: activity.serviceUrl,
143
+ locale: activity.locale,
144
+ ...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}),
145
+ ...(threadId ? { threadId } : {}),
146
+ };
147
+ }
51
148
 
52
149
  export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
53
150
  const {
@@ -63,17 +160,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
63
160
  log,
64
161
  } = deps;
65
162
  const core = getMSTeamsRuntime();
66
- const pairing = createScopedPairingAccess({
67
- core,
68
- channel: "msteams",
69
- accountId: DEFAULT_ACCOUNT_ID,
70
- });
71
163
  const logVerboseMessage = (message: string) => {
72
164
  if (core.logging.shouldLogVerbose()) {
73
165
  log.debug?.(message);
74
166
  }
75
167
  };
76
168
  const msteamsCfg = cfg.channels?.msteams;
169
+ const contextVisibilityMode = resolveChannelContextVisibilityMode({
170
+ cfg,
171
+ channel: "msteams",
172
+ });
77
173
  const historyLimit = Math.max(
78
174
  0,
79
175
  msteamsCfg?.historyLimit ??
@@ -92,7 +188,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
92
188
  text: string;
93
189
  attachments: MSTeamsAttachmentLike[];
94
190
  wasMentioned: boolean;
95
- implicitMention: boolean;
191
+ implicitMentionKinds: Array<"reply_to_bot">;
96
192
  };
97
193
 
98
194
  const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
@@ -101,8 +197,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
101
197
  const rawText = params.rawText;
102
198
  const text = params.text;
103
199
  const attachments = params.attachments;
104
- const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments);
200
+ const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments, {
201
+ maxInlineBytes: mediaMaxBytes,
202
+ maxInlineTotalBytes: mediaMaxBytes,
203
+ });
105
204
  const rawBody = text || attachmentPlaceholder;
205
+ const quoteInfo = extractMSTeamsQuoteInfo(attachments);
206
+ let quoteSenderId: string | undefined;
207
+ let quoteSenderName: string | undefined;
106
208
  const from = activity.from;
107
209
  const conversation = activity.conversation;
108
210
 
@@ -134,73 +236,48 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
134
236
  const conversationId = normalizeMSTeamsConversationId(rawConversationId);
135
237
  const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
136
238
  const conversationType = conversation?.conversationType ?? "personal";
137
- const isGroupChat = conversationType === "groupChat" || conversation?.isGroup === true;
138
- const isChannel = conversationType === "channel";
139
- const isDirectMessage = !isGroupChat && !isChannel;
140
-
141
- const senderName = from.name ?? from.id;
142
- const senderId = from.aadObjectId ?? from.id;
143
- const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
144
- const storedAllowFrom = await readStoreAllowFromForDmPolicy({
145
- provider: "msteams",
146
- accountId: pairing.accountId,
147
- dmPolicy,
148
- readStore: pairing.readStoreForDmPolicy,
149
- });
150
- const useAccessGroups = cfg.commands?.useAccessGroups !== false;
151
-
152
- // Check DM policy for direct messages.
153
- const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
154
- const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v));
155
- const groupAllowFrom = msteamsCfg?.groupAllowFrom;
156
- const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
157
- allowFrom: configuredDmAllowFrom,
158
- groupAllowFrom,
159
- storeAllowFrom: storedAllowFrom,
160
- dmPolicy,
161
- });
162
-
163
- const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
164
- const groupPolicy =
165
- !isDirectMessage && msteamsCfg
166
- ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
167
- : "disabled";
168
- const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
169
239
  const teamId = activity.channelData?.team?.id;
170
- const teamName = activity.channelData?.team?.name;
171
- const channelName = activity.channelData?.channel?.name;
172
- const channelGate = resolveMSTeamsRouteConfig({
173
- cfg: msteamsCfg,
174
- teamId,
175
- teamName,
240
+ // For channel thread messages, resolve the thread root message ID so outbound
241
+ // replies land in the correct thread. The root ID comes from the `messageid=`
242
+ // portion of conversation.id (preferred) or from activity.replyToId.
243
+ const threadId =
244
+ conversationType === "channel"
245
+ ? (conversationMessageId ?? activity.replyToId ?? undefined)
246
+ : undefined;
247
+ const conversationRef = buildStoredConversationReference({
248
+ activity,
176
249
  conversationId,
177
- channelName,
178
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
179
- });
180
- const senderGroupPolicy = resolveSenderScopedGroupPolicy({
181
- groupPolicy,
182
- groupAllowFrom: effectiveGroupAllowFrom,
250
+ conversationType,
251
+ teamId,
252
+ threadId,
183
253
  });
184
- const access = resolveDmGroupAccessWithLists({
185
- isGroup: !isDirectMessage,
254
+
255
+ const {
186
256
  dmPolicy,
187
- groupPolicy: senderGroupPolicy,
188
- allowFrom: configuredDmAllowFrom,
189
- groupAllowFrom,
190
- storeAllowFrom: storedAllowFrom,
191
- groupAllowFromFallbackToAllowFrom: false,
192
- isSenderAllowed: (allowFrom) =>
193
- resolveMSTeamsAllowlistMatch({
194
- allowFrom,
195
- senderId,
196
- senderName,
197
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
198
- }).allowed,
257
+ senderId,
258
+ senderName,
259
+ pairing,
260
+ isDirectMessage,
261
+ channelGate,
262
+ access,
263
+ configuredDmAllowFrom,
264
+ effectiveDmAllowFrom,
265
+ effectiveGroupAllowFrom,
266
+ allowNameMatching,
267
+ groupPolicy,
268
+ } = await resolveMSTeamsSenderAccess({
269
+ cfg,
270
+ activity,
199
271
  });
200
- const effectiveDmAllowFrom = access.effectiveAllowFrom;
272
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
273
+ const isChannel = conversationType === "channel";
201
274
 
202
275
  if (isDirectMessage && msteamsCfg && access.decision !== "allow") {
203
276
  if (access.reason === "dmPolicy=disabled") {
277
+ log.info("dropping dm (dms disabled)", {
278
+ sender: senderId,
279
+ label: senderName,
280
+ });
204
281
  log.debug?.("dropping dm (dms disabled)");
205
282
  return;
206
283
  }
@@ -208,9 +285,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
208
285
  allowFrom: effectiveDmAllowFrom,
209
286
  senderId,
210
287
  senderName,
211
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
288
+ allowNameMatching,
212
289
  });
213
290
  if (access.decision === "pairing") {
291
+ conversationStore.upsert(conversationId, conversationRef).catch((err) => {
292
+ log.debug?.("failed to save conversation reference", {
293
+ error: formatUnknownError(err),
294
+ });
295
+ });
214
296
  const request = await pairing.upsertPairingRequest({
215
297
  id: senderId,
216
298
  meta: { name: senderName },
@@ -227,11 +309,25 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
227
309
  label: senderName,
228
310
  allowlistMatch: formatAllowlistMatchMeta(allowMatch),
229
311
  });
312
+ log.info("dropping dm (not allowlisted)", {
313
+ sender: senderId,
314
+ label: senderName,
315
+ dmPolicy,
316
+ reason: access.reason,
317
+ allowlistMatch: formatAllowlistMatchMeta(allowMatch),
318
+ });
230
319
  return;
231
320
  }
232
321
 
233
322
  if (!isDirectMessage && msteamsCfg) {
234
323
  if (channelGate.allowlistConfigured && !channelGate.allowed) {
324
+ log.info("dropping group message (not in team/channel allowlist)", {
325
+ conversationId,
326
+ teamKey: channelGate.teamKey ?? "none",
327
+ channelKey: channelGate.channelKey ?? "none",
328
+ channelMatchKey: channelGate.channelMatchKey ?? "none",
329
+ channelMatchSource: channelGate.channelMatchSource ?? "none",
330
+ });
235
331
  log.debug?.("dropping group message (not in team/channel allowlist)", {
236
332
  conversationId,
237
333
  teamKey: channelGate.teamKey ?? "none",
@@ -250,17 +346,23 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
250
346
  allowFrom,
251
347
  senderId,
252
348
  senderName,
253
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
349
+ allowNameMatching,
254
350
  }).allowed,
255
351
  });
256
352
 
257
353
  if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
354
+ log.info("dropping group message (groupPolicy: disabled)", {
355
+ conversationId,
356
+ });
258
357
  log.debug?.("dropping group message (groupPolicy: disabled)", {
259
358
  conversationId,
260
359
  });
261
360
  return;
262
361
  }
263
362
  if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
363
+ log.info("dropping group message (groupPolicy: allowlist, no allowlist)", {
364
+ conversationId,
365
+ });
264
366
  log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
265
367
  conversationId,
266
368
  });
@@ -271,13 +373,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
271
373
  allowFrom: effectiveGroupAllowFrom,
272
374
  senderId,
273
375
  senderName,
274
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
376
+ allowNameMatching,
275
377
  });
276
378
  log.debug?.("dropping group message (not in groupAllowFrom)", {
277
379
  sender: senderId,
278
380
  label: senderName,
279
381
  allowlistMatch: formatAllowlistMatchMeta(allowMatch),
280
382
  });
383
+ log.info("dropping group message (not in groupAllowFrom)", {
384
+ sender: senderId,
385
+ label: senderName,
386
+ allowlistMatch: formatAllowlistMatchMeta(allowMatch),
387
+ });
281
388
  return;
282
389
  }
283
390
  }
@@ -288,14 +395,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
288
395
  allowFrom: commandDmAllowFrom,
289
396
  senderId,
290
397
  senderName,
291
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
398
+ allowNameMatching,
292
399
  });
293
400
  const groupAllowedForCommands = isMSTeamsGroupAllowed({
294
401
  groupPolicy: "allowlist",
295
402
  allowFrom: effectiveGroupAllowFrom,
296
403
  senderId,
297
404
  senderName,
298
- allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
405
+ allowNameMatching,
299
406
  });
300
407
  const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
301
408
  useAccessGroups,
@@ -315,23 +422,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
315
422
  return;
316
423
  }
317
424
 
318
- // Build conversation reference for proactive replies.
319
- const agent = activity.recipient;
320
- const conversationRef: StoredConversationReference = {
321
- activityId: activity.id,
322
- user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
323
- agent,
324
- bot: agent ? { id: agent.id, name: agent.name } : undefined,
325
- conversation: {
326
- id: conversationId,
327
- conversationType,
328
- tenantId: conversation?.tenantId,
329
- },
330
- teamId,
331
- channelId: activity.channelId,
332
- serviceUrl: activity.serviceUrl,
333
- locale: activity.locale,
334
- };
335
425
  conversationStore.upsert(conversationId, conversationRef).catch((err) => {
336
426
  log.debug?.("failed to save conversation reference", {
337
427
  error: formatUnknownError(err),
@@ -381,12 +471,25 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
381
471
  const route = core.channel.routing.resolveAgentRoute({
382
472
  cfg,
383
473
  channel: "msteams",
474
+ teamId,
384
475
  peer: {
385
476
  kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
386
477
  id: isDirectMessage ? senderId : conversationId,
387
478
  },
388
479
  });
389
480
 
481
+ // Isolate channel thread sessions: each thread gets its own session key so
482
+ // context does not bleed across threads. Prefer conversationMessageId (the
483
+ // ;messageid= portion of conversation.id, i.e. the thread root) over
484
+ // activity.replyToId (which may point to a non-root parent in deep threads).
485
+ // DMs and group chats are unaffected — only channel thread replies fork.
486
+ route.sessionKey = resolveMSTeamsRouteSessionKey({
487
+ baseSessionKey: route.sessionKey,
488
+ isChannel,
489
+ conversationMessageId,
490
+ replyToId: activity.replyToId,
491
+ });
492
+
390
493
  const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
391
494
  const inboundLabel = isDirectMessage
392
495
  ? `Teams DM from ${senderName}`
@@ -406,17 +509,24 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
406
509
  channelConfig,
407
510
  });
408
511
  const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
409
-
410
- if (!isDirectMessage) {
411
- const mentionGate = resolveMentionGating({
412
- requireMention: Boolean(requireMention),
512
+ const mentionDecision = resolveInboundMentionDecision({
513
+ facts: {
413
514
  canDetectMention: true,
414
515
  wasMentioned: params.wasMentioned,
415
- implicitMention: params.implicitMention,
416
- shouldBypassMention: false,
417
- });
418
- const mentioned = mentionGate.effectiveWasMentioned;
419
- if (requireMention && mentionGate.shouldSkip) {
516
+ implicitMentionKinds: params.implicitMentionKinds,
517
+ },
518
+ policy: {
519
+ isGroup: !isDirectMessage,
520
+ requireMention,
521
+ allowTextCommands: false,
522
+ hasControlCommand: false,
523
+ commandAuthorized: false,
524
+ },
525
+ });
526
+
527
+ if (!isDirectMessage) {
528
+ const mentioned = mentionDecision.effectiveWasMentioned;
529
+ if (requireMention && mentionDecision.shouldSkip) {
420
530
  log.debug?.("skipping message (mention required)", {
421
531
  teamId,
422
532
  channelId,
@@ -437,6 +547,42 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
437
547
  return;
438
548
  }
439
549
  }
550
+ let graphConversationId = translateMSTeamsDmConversationIdForGraph({
551
+ isDirectMessage,
552
+ conversationId,
553
+ aadObjectId: from.aadObjectId,
554
+ appId,
555
+ });
556
+
557
+ // For personal DMs the Bot Framework conversation ID (`a:...`) and the
558
+ // synthetic `19:{userId}_{appId}@unq.gbl.spaces` format produced by
559
+ // translateMSTeamsDmConversationIdForGraph are not always accepted by the
560
+ // Graph `/chats/{chatId}/messages` endpoint. Resolve the real Graph chat
561
+ // ID via the API (with conversation store caching) so the Graph media
562
+ // download fallback works when the direct Bot Framework download fails.
563
+ if (isDirectMessage && conversationId.startsWith("a:")) {
564
+ const cached = await conversationStore.get(conversationId);
565
+ if (cached?.graphChatId) {
566
+ graphConversationId = cached.graphChatId;
567
+ } else {
568
+ try {
569
+ const resolved = await resolveGraphChatId({
570
+ botFrameworkConversationId: conversationId,
571
+ userAadObjectId: from.aadObjectId ?? undefined,
572
+ tokenProvider,
573
+ });
574
+ if (resolved) {
575
+ graphConversationId = resolved;
576
+ conversationStore
577
+ .upsert(conversationId, { ...conversationRef, graphChatId: resolved })
578
+ .catch(() => {});
579
+ }
580
+ } catch {
581
+ log.debug?.("failed to resolve Graph chat ID for inbound media", { conversationId });
582
+ }
583
+ }
584
+ }
585
+
440
586
  const mediaList = await resolveMSTeamsInboundMedia({
441
587
  attachments,
442
588
  htmlSummary: htmlSummary ?? undefined,
@@ -445,8 +591,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
445
591
  allowHosts: msteamsCfg?.mediaAllowHosts,
446
592
  authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
447
593
  conversationType,
448
- conversationId,
594
+ conversationId: graphConversationId,
449
595
  conversationMessageId: conversationMessageId ?? undefined,
596
+ serviceUrl: activity.serviceUrl,
450
597
  activity: {
451
598
  id: activity.id,
452
599
  replyToId: activity.replyToId,
@@ -458,6 +605,90 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
458
605
  });
459
606
 
460
607
  const mediaPayload = buildMSTeamsMediaPayload(mediaList);
608
+
609
+ // Fetch thread history when the message is a reply inside a Teams channel thread.
610
+ // This is a best-effort enhancement; errors are logged and do not block the reply.
611
+ //
612
+ // We also enqueue a compact `Replying to @sender: …` system event when the parent
613
+ // is resolvable. On brand-new thread sessions (see PR #62713), this gives the agent
614
+ // immediate parent context even before the fuller `[Thread history]` block is assembled.
615
+ // Parent fetches are cached (5 min LRU, 100 entries) and per-session deduped so
616
+ // consecutive replies in the same thread do not re-inject identical context.
617
+ let threadContext: string | undefined;
618
+ if (activity.replyToId && isChannel && teamId) {
619
+ try {
620
+ const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");
621
+ const groupId = await resolveTeamGroupId(graphToken, teamId);
622
+ // Use allSettled so a failure in one fetch does not discard the other.
623
+ // For example, reply-fetch 403 should not throw away a successful parent fetch.
624
+ const [parentResult, repliesResult] = await Promise.allSettled([
625
+ fetchParentMessageCached(graphToken, groupId, conversationId, activity.replyToId),
626
+ fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId),
627
+ ]);
628
+ const parentMsg = parentResult.status === "fulfilled" ? parentResult.value : undefined;
629
+ const replies = repliesResult.status === "fulfilled" ? repliesResult.value : [];
630
+ if (parentResult.status === "rejected") {
631
+ log.debug?.("failed to fetch parent message", {
632
+ error: formatUnknownError(parentResult.reason),
633
+ });
634
+ }
635
+ if (repliesResult.status === "rejected") {
636
+ log.debug?.("failed to fetch thread replies", {
637
+ error: formatUnknownError(repliesResult.reason),
638
+ });
639
+ }
640
+ const isThreadSenderAllowed = (msg: GraphThreadMessage) =>
641
+ groupPolicy === "allowlist"
642
+ ? resolveMSTeamsAllowlistMatch({
643
+ allowFrom: effectiveGroupAllowFrom,
644
+ senderId: msg.from?.user?.id ?? "",
645
+ senderName: msg.from?.user?.displayName,
646
+ allowNameMatching,
647
+ }).allowed
648
+ : true;
649
+ const parentSummary = summarizeParentMessage(parentMsg);
650
+ const visibleParentMessages = parentMsg
651
+ ? filterSupplementalContextItems({
652
+ items: [parentMsg],
653
+ mode: contextVisibilityMode,
654
+ kind: "thread",
655
+ isSenderAllowed: isThreadSenderAllowed,
656
+ }).items
657
+ : [];
658
+ if (
659
+ parentSummary &&
660
+ visibleParentMessages.length > 0 &&
661
+ shouldInjectParentContext(route.sessionKey, activity.replyToId)
662
+ ) {
663
+ core.system.enqueueSystemEvent(formatParentContextEvent(parentSummary), {
664
+ sessionKey: route.sessionKey,
665
+ contextKey: `msteams:thread-parent:${conversationId}:${activity.replyToId}`,
666
+ });
667
+ markParentContextInjected(route.sessionKey, activity.replyToId);
668
+ }
669
+ const allMessages = parentMsg ? [parentMsg, ...replies] : replies;
670
+ quoteSenderId = parentMsg?.from?.user?.id ?? parentMsg?.from?.application?.id ?? undefined;
671
+ quoteSenderName =
672
+ parentMsg?.from?.user?.displayName ??
673
+ parentMsg?.from?.application?.displayName ??
674
+ quoteInfo?.sender;
675
+ const { items: threadMessages } = filterSupplementalContextItems({
676
+ items: allMessages,
677
+ mode: contextVisibilityMode,
678
+ kind: "thread",
679
+ isSenderAllowed: isThreadSenderAllowed,
680
+ });
681
+ const formatted = formatThreadContext(threadMessages, activity.id);
682
+ if (formatted) {
683
+ threadContext = formatted;
684
+ }
685
+ } catch (err) {
686
+ log.debug?.("failed to fetch thread history", { error: formatUnknownError(err) });
687
+ // Graceful degradation: thread history is an optional enhancement.
688
+ }
689
+ }
690
+ quoteSenderName ??= quoteInfo?.sender;
691
+
461
692
  const envelopeFrom = isDirectMessage ? senderName : conversationType;
462
693
  const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
463
694
  cfg,
@@ -501,10 +732,40 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
501
732
  }))
502
733
  : undefined;
503
734
  const commandBody = text.trim();
735
+ const quoteSenderAllowed =
736
+ quoteInfo && quoteInfo.sender
737
+ ? !isChannel || groupPolicy !== "allowlist"
738
+ ? true
739
+ : resolveMSTeamsAllowlistMatch({
740
+ allowFrom: effectiveGroupAllowFrom,
741
+ senderId: quoteSenderId ?? "",
742
+ senderName: quoteSenderName,
743
+ allowNameMatching,
744
+ }).allowed
745
+ : true;
746
+ const includeQuoteContext =
747
+ quoteInfo &&
748
+ shouldIncludeSupplementalContext({
749
+ mode: contextVisibilityMode,
750
+ kind: "quote",
751
+ senderAllowed: quoteSenderAllowed,
752
+ });
753
+
754
+ // Prepend thread history to the agent body so the agent has full thread context.
755
+ const bodyForAgent = threadContext
756
+ ? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${rawBody}`
757
+ : rawBody;
758
+
759
+ // For Teams *channel* messages (not group chats / DMs), preserve the
760
+ // `teamId/channelId` pair on NativeChannelId so downstream action handlers
761
+ // can route through `/teams/{teamId}/channels/{channelId}` via Graph API.
762
+ // The bare conversation id (`19:...@thread.tacv2`) is insufficient on its
763
+ // own because channel Graph endpoints require the owning team id too.
764
+ const nativeChannelId = isChannel && teamId ? `${teamId}/${conversationId}` : undefined;
504
765
 
505
766
  const ctxPayload = core.channel.reply.finalizeInboundContext({
506
767
  Body: combinedBody,
507
- BodyForAgent: rawBody,
768
+ BodyForAgent: bodyForAgent,
508
769
  InboundHistory: inboundHistory,
509
770
  RawBody: rawBody,
510
771
  CommandBody: commandBody,
@@ -516,34 +777,32 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
516
777
  ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
517
778
  ConversationLabel: envelopeFrom,
518
779
  GroupSubject: !isDirectMessage ? conversationType : undefined,
780
+ GroupSpace: teamId,
519
781
  SenderName: senderName,
520
782
  SenderId: senderId,
521
783
  Provider: "msteams" as const,
522
784
  Surface: "msteams" as const,
523
785
  MessageSid: activity.id,
524
786
  Timestamp: timestamp?.getTime() ?? Date.now(),
525
- WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
787
+ WasMentioned: isDirectMessage || mentionDecision.effectiveWasMentioned,
526
788
  CommandAuthorized: commandAuthorized,
527
789
  OriginatingChannel: "msteams" as const,
528
790
  OriginatingTo: teamsTo,
791
+ NativeChannelId: nativeChannelId,
792
+ ReplyToId: activity.replyToId ?? undefined,
793
+ ReplyToBody: includeQuoteContext ? quoteInfo?.body : undefined,
794
+ ReplyToSender: includeQuoteContext ? quoteInfo?.sender : undefined,
795
+ ReplyToIsQuote: quoteInfo ? true : undefined,
529
796
  ...mediaPayload,
530
797
  });
531
798
 
532
- await core.channel.session.recordInboundSession({
533
- storePath,
534
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
535
- ctx: ctxPayload,
536
- onRecordError: (err) => {
537
- logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
538
- },
539
- });
540
-
541
799
  logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
542
800
 
543
801
  const sharePointSiteId = msteamsCfg?.sharePointSiteId;
544
802
  const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
545
803
  cfg,
546
804
  agentId: route.agentId,
805
+ sessionKey: route.sessionKey,
547
806
  accountId: route.accountId,
548
807
  runtime,
549
808
  log,
@@ -562,48 +821,93 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
562
821
  sharePointSiteId,
563
822
  });
564
823
 
824
+ // Use Teams clientInfo timezone if no explicit userTimezone is configured.
825
+ // This ensures the agent knows the sender's timezone for time-aware responses
826
+ // and proactive sends within the same session.
827
+ const activityClientInfo = activity.entities?.find((e) => e.type === "clientInfo") as
828
+ | { timezone?: string }
829
+ | undefined;
830
+ const senderTimezone = activityClientInfo?.timezone || conversationRef.timezone;
831
+ const configOverride =
832
+ senderTimezone && !cfg.agents?.defaults?.userTimezone
833
+ ? {
834
+ agents: {
835
+ defaults: { ...cfg.agents?.defaults, userTimezone: senderTimezone },
836
+ },
837
+ }
838
+ : undefined;
839
+
565
840
  log.info("dispatching to agent", { sessionKey: route.sessionKey });
566
841
  try {
567
- const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
568
- cfg,
569
- ctxPayload,
570
- dispatcher,
571
- onSettled: () => {
572
- markDispatchIdle();
842
+ const turnResult = await core.channel.turn.run({
843
+ channel: "msteams",
844
+ accountId: route.accountId,
845
+ raw: context,
846
+ adapter: {
847
+ ingest: () => ({
848
+ id: activity.id ?? `${teamsFrom}:${Date.now()}`,
849
+ timestamp: timestamp?.getTime(),
850
+ rawText: rawBody,
851
+ textForAgent: bodyForAgent,
852
+ textForCommands: commandBody,
853
+ raw: activity,
854
+ }),
855
+ resolveTurn: () => ({
856
+ channel: "msteams",
857
+ accountId: route.accountId,
858
+ routeSessionKey: route.sessionKey,
859
+ storePath,
860
+ ctxPayload,
861
+ recordInboundSession: core.channel.session.recordInboundSession,
862
+ record: {
863
+ onRecordError: (err) => {
864
+ logVerboseMessage(
865
+ `msteams: failed updating session meta: ${formatUnknownError(err)}`,
866
+ );
867
+ },
868
+ },
869
+ history: {
870
+ isGroup: isRoomish,
871
+ historyKey,
872
+ historyMap: conversationHistories,
873
+ limit: historyLimit,
874
+ },
875
+ onPreDispatchFailure: () =>
876
+ core.channel.reply.settleReplyDispatcher({
877
+ dispatcher,
878
+ onSettled: () => markDispatchIdle(),
879
+ }),
880
+ runDispatch: () =>
881
+ dispatchReplyFromConfigWithSettledDispatcher({
882
+ cfg,
883
+ ctxPayload,
884
+ dispatcher,
885
+ onSettled: () => markDispatchIdle(),
886
+ replyOptions,
887
+ configOverride,
888
+ }),
889
+ }),
573
890
  },
574
- replyOptions,
575
891
  });
892
+ const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
893
+ const queuedFinal = dispatchResult?.queuedFinal ?? false;
894
+ const counts = resolveInboundReplyDispatchCounts(dispatchResult);
895
+ const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
576
896
 
577
897
  log.info("dispatch complete", { queuedFinal, counts });
578
898
 
579
- if (!queuedFinal) {
580
- if (isRoomish && historyKey) {
581
- clearHistoryEntriesIfEnabled({
582
- historyMap: conversationHistories,
583
- historyKey,
584
- limit: historyLimit,
585
- });
586
- }
899
+ if (!hasFinalResponse) {
587
900
  return;
588
901
  }
589
902
  const finalCount = counts.final;
590
903
  logVerboseMessage(
591
904
  `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
592
905
  );
593
- if (isRoomish && historyKey) {
594
- clearHistoryEntriesIfEnabled({
595
- historyMap: conversationHistories,
596
- historyKey,
597
- limit: historyLimit,
598
- });
599
- }
600
906
  } catch (err) {
601
- log.error("dispatch failed", { error: String(err) });
602
- runtime.error?.(`msteams dispatch failed: ${String(err)}`);
907
+ log.error("dispatch failed", { error: formatUnknownError(err) });
908
+ runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`);
603
909
  try {
604
- await context.sendActivity(
605
- `⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
606
- );
910
+ await context.sendActivity("⚠️ Something went wrong. Please try again.");
607
911
  } catch {
608
912
  // Best effort.
609
913
  }
@@ -653,34 +957,36 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
653
957
  .filter(Boolean)
654
958
  .join("\n");
655
959
  const wasMentioned = entries.some((entry) => entry.wasMentioned);
656
- const implicitMention = entries.some((entry) => entry.implicitMention);
960
+ const implicitMentionKinds = entries.flatMap((entry) => entry.implicitMentionKinds);
657
961
  await handleTeamsMessageNow({
658
962
  context: last.context,
659
963
  rawText: combinedRawText,
660
964
  text: combinedText,
661
965
  attachments: [],
662
966
  wasMentioned,
663
- implicitMention,
967
+ implicitMentionKinds,
664
968
  });
665
969
  },
666
970
  onError: (err) => {
667
- runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
971
+ runtime.error?.(`msteams debounce flush failed: ${formatUnknownError(err)}`);
668
972
  },
669
973
  });
670
974
 
671
975
  return async function handleTeamsMessage(context: MSTeamsTurnContext) {
672
976
  const activity = context.activity;
673
- const rawText = activity.text?.trim() ?? "";
674
- const text = stripMSTeamsMentionTags(rawText);
675
977
  const attachments = Array.isArray(activity.attachments)
676
978
  ? (activity.attachments as unknown as MSTeamsAttachmentLike[])
677
979
  : [];
980
+ const rawText = activity.text?.trim() ?? "";
981
+ const htmlText = extractTextFromHtmlAttachments(attachments);
982
+ const text = stripMSTeamsMentionTags(rawText || htmlText);
678
983
  const wasMentioned = wasMSTeamsBotMentioned(activity);
679
984
  const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
680
985
  const replyToId = activity.replyToId ?? undefined;
681
- const implicitMention = Boolean(
682
- conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
683
- );
986
+ const implicitMentionKinds: Array<"reply_to_bot"> =
987
+ conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId)
988
+ ? ["reply_to_bot"]
989
+ : [];
684
990
 
685
991
  await inboundDebouncer.enqueue({
686
992
  context,
@@ -688,7 +994,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
688
994
  text,
689
995
  attachments,
690
996
  wasMentioned,
691
- implicitMention,
997
+ implicitMentionKinds,
692
998
  });
693
999
  };
694
1000
  }