@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
package/src/messenger.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import {
2
- type ChunkMode,
3
2
  isSilentReplyText,
4
- loadWebMedia,
5
- type MarkdownTableMode,
6
- type MSTeamsReplyStyle,
7
- type ReplyPayload,
8
3
  SILENT_REPLY_TOKEN,
9
- sleep,
10
- } from "openclaw/plugin-sdk/msteams";
4
+ type ChunkMode,
5
+ } from "openclaw/plugin-sdk/reply-chunking";
6
+ import {
7
+ resolveSendableOutboundReplyParts,
8
+ type ReplyPayload,
9
+ } from "openclaw/plugin-sdk/reply-payload";
10
+ import { normalizeOptionalLowercaseString, sleep } from "openclaw/plugin-sdk/text-runtime";
11
+ import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
12
+ import type { MarkdownTableMode, MSTeamsReplyStyle, OpenClawConfig } from "../runtime-api.js";
11
13
  import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
12
14
  import type { StoredConversationReference } from "./conversation-store.js";
13
15
  import { classifyMSTeamsSendError } from "./errors.js";
@@ -20,6 +22,7 @@ import {
20
22
  } from "./graph-upload.js";
21
23
  import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
22
24
  import { parseMentions } from "./mentions.js";
25
+ import { setPendingUploadActivityId } from "./pending-uploads.js";
23
26
  import { withRevokedProxyFallback } from "./revoked-context.js";
24
27
  import { getMSTeamsRuntime } from "./runtime.js";
25
28
 
@@ -37,9 +40,11 @@ const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
37
40
 
38
41
  type SendContext = {
39
42
  sendActivity: (textOrActivity: string | object) => Promise<unknown>;
43
+ updateActivity: (activity: object) => Promise<{ id?: string } | void>;
44
+ deleteActivity: (activityId: string) => Promise<void>;
40
45
  };
41
46
 
42
- export type MSTeamsConversationReference = {
47
+ type MSTeamsConversationReference = {
43
48
  activityId?: string;
44
49
  user?: { id?: string; name?: string; aadObjectId?: string };
45
50
  agent?: { id?: string; name?: string; aadObjectId?: string } | null;
@@ -47,6 +52,18 @@ export type MSTeamsConversationReference = {
47
52
  channelId: string;
48
53
  serviceUrl?: string;
49
54
  locale?: string;
55
+ /**
56
+ * Top-level tenant ID echoed onto the Bot Framework connector request. Included
57
+ * alongside `conversation.tenantId` so the connector can route proactive sends
58
+ * to the correct Azure AD tenant. Missing it causes HTTP 403 on proactive
59
+ * (bot-initiated) messages.
60
+ */
61
+ tenantId?: string;
62
+ /**
63
+ * Azure AD object ID of the target user, forwarded on proactive sends so
64
+ * Bot Framework can resolve the personal DM recipient on the connector side.
65
+ */
66
+ aadObjectId?: string;
50
67
  };
51
68
 
52
69
  export type MSTeamsAdapter = {
@@ -60,9 +77,11 @@ export type MSTeamsAdapter = {
60
77
  res: unknown,
61
78
  logic: (context: unknown) => Promise<void>,
62
79
  ) => Promise<void>;
80
+ updateActivity: (context: unknown, activity: object) => Promise<void>;
81
+ deleteActivity: (context: unknown, reference: { activityId?: string }) => Promise<void>;
63
82
  };
64
83
 
65
- export type MSTeamsReplyRenderOptions = {
84
+ type MSTeamsReplyRenderOptions = {
66
85
  textChunkLimit: number;
67
86
  chunkText?: boolean;
68
87
  mediaMode?: "split" | "inline";
@@ -79,13 +98,13 @@ export type MSTeamsRenderedMessage = {
79
98
  mediaUrl?: string;
80
99
  };
81
100
 
82
- export type MSTeamsSendRetryOptions = {
101
+ type MSTeamsSendRetryOptions = {
83
102
  maxAttempts?: number;
84
103
  baseDelayMs?: number;
85
104
  maxDelayMs?: number;
86
105
  };
87
106
 
88
- export type MSTeamsSendRetryEvent = {
107
+ type MSTeamsSendRetryEvent = {
89
108
  messageIndex: number;
90
109
  messageCount: number;
91
110
  nextAttempt: number;
@@ -113,18 +132,26 @@ export function buildConversationReference(
113
132
  if (!user?.id) {
114
133
  throw new Error("Invalid stored reference: missing user.id");
115
134
  }
135
+ // Bot Framework proactive sends require `tenantId` on the outbound activity
136
+ // so the connector routes to the correct Azure AD tenant; otherwise it rejects
137
+ // with HTTP 403. Prefer the explicit top-level `ref.tenantId` (captured from
138
+ // `channelData.tenant.id` inbound) and fall back to `conversation.tenantId`.
139
+ const tenantId = ref.tenantId ?? ref.conversation?.tenantId;
140
+ const aadObjectId = ref.aadObjectId ?? user.aadObjectId;
116
141
  return {
117
142
  activityId: ref.activityId,
118
- user,
143
+ user: aadObjectId ? { ...user, aadObjectId } : user,
119
144
  agent,
120
145
  conversation: {
121
146
  id: normalizeConversationId(conversationId),
122
147
  conversationType: ref.conversation?.conversationType,
123
- tenantId: ref.conversation?.tenantId,
148
+ tenantId,
124
149
  },
125
150
  channelId: ref.channelId ?? "msteams",
126
151
  serviceUrl: ref.serviceUrl,
127
152
  locale: ref.locale,
153
+ ...(tenantId ? { tenantId } : {}),
154
+ ...(aadObjectId ? { aadObjectId } : {}),
128
155
  };
129
156
  }
130
157
 
@@ -211,46 +238,44 @@ export function renderReplyPayloadsToMessages(
211
238
  const tableMode =
212
239
  options.tableMode ??
213
240
  getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
214
- cfg: getMSTeamsRuntime().config.loadConfig(),
241
+ cfg: getMSTeamsRuntime().config.current() as OpenClawConfig,
215
242
  channel: "msteams",
216
243
  });
217
244
 
218
245
  for (const payload of replies) {
219
- const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
220
- const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
221
- payload.text ?? "",
222
- tableMode,
223
- );
246
+ const reply = resolveSendableOutboundReplyParts(payload, {
247
+ text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
248
+ });
224
249
 
225
- if (!text && mediaList.length === 0) {
250
+ if (!reply.hasContent) {
226
251
  continue;
227
252
  }
228
253
 
229
- if (mediaList.length === 0) {
230
- pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
254
+ if (!reply.hasMedia) {
255
+ pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
231
256
  continue;
232
257
  }
233
258
 
234
259
  if (mediaMode === "inline") {
235
260
  // For inline mode, combine text with first media as attachment
236
- const firstMedia = mediaList[0];
261
+ const firstMedia = reply.mediaUrls[0];
237
262
  if (firstMedia) {
238
- out.push({ text: text || undefined, mediaUrl: firstMedia });
263
+ out.push({ text: reply.text || undefined, mediaUrl: firstMedia });
239
264
  // Additional media URLs as separate messages
240
- for (let i = 1; i < mediaList.length; i++) {
241
- if (mediaList[i]) {
242
- out.push({ mediaUrl: mediaList[i] });
265
+ for (let i = 1; i < reply.mediaUrls.length; i++) {
266
+ if (reply.mediaUrls[i]) {
267
+ out.push({ mediaUrl: reply.mediaUrls[i] });
243
268
  }
244
269
  }
245
270
  } else {
246
- pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
271
+ pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
247
272
  }
248
273
  continue;
249
274
  }
250
275
 
251
276
  // mediaMode === "split"
252
- pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
253
- for (const mediaUrl of mediaList) {
277
+ pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
278
+ for (const mediaUrl of reply.mediaUrls) {
254
279
  if (!mediaUrl) {
255
280
  continue;
256
281
  }
@@ -261,24 +286,32 @@ export function renderReplyPayloadsToMessages(
261
286
  return out;
262
287
  }
263
288
 
264
- async function buildActivity(
289
+ import { AI_GENERATED_ENTITY } from "./ai-entity.js";
290
+
291
+ export async function buildActivity(
265
292
  msg: MSTeamsRenderedMessage,
266
293
  conversationRef: StoredConversationReference,
267
294
  tokenProvider?: MSTeamsAccessTokenProvider,
268
295
  sharePointSiteId?: string,
269
296
  mediaMaxBytes?: number,
297
+ options?: { feedbackLoopEnabled?: boolean },
270
298
  ): Promise<Record<string, unknown>> {
271
299
  const activity: Record<string, unknown> = { type: "message" };
272
300
 
301
+ // Mark as AI-generated so Teams renders the "AI generated" badge.
302
+ activity.channelData = {
303
+ feedbackLoopEnabled: options?.feedbackLoopEnabled ?? false,
304
+ };
305
+
273
306
  if (msg.text) {
274
307
  // Parse mentions from text (format: @[Name](id))
275
308
  const { text: formattedText, entities } = parseMentions(msg.text);
276
309
  activity.text = formattedText;
277
310
 
278
- // Add mention entities if any mentions were found
279
- if (entities.length > 0) {
280
- activity.entities = entities;
281
- }
311
+ // Start with mention entities (if any) + AI-generated entity
312
+ activity.entities = [...(entities.length > 0 ? entities : []), AI_GENERATED_ENTITY];
313
+ } else {
314
+ activity.entities = [AI_GENERATED_ENTITY];
282
315
  }
283
316
 
284
317
  if (msg.mediaUrl) {
@@ -294,7 +327,9 @@ async function buildActivity(
294
327
 
295
328
  // Determine conversation type and file type
296
329
  // Teams only accepts base64 data URLs for images
297
- const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
330
+ const conversationType = normalizeOptionalLowercaseString(
331
+ conversationRef.conversation?.conversationType,
332
+ );
298
333
  const isPersonal = conversationType === "personal";
299
334
  const isImage = media.kind === "image";
300
335
 
@@ -308,20 +343,25 @@ async function buildActivity(
308
343
  ) {
309
344
  // Large file or non-image in personal chat: use FileConsentCard flow
310
345
  const conversationId = conversationRef.conversation?.id ?? "unknown";
311
- const { activity: consentActivity } = prepareFileConsentActivity({
346
+ const { activity: consentActivity, uploadId } = prepareFileConsentActivity({
312
347
  media: { buffer: media.buffer, filename: fileName, contentType },
313
348
  conversationId,
314
349
  description: msg.text || undefined,
315
350
  });
316
351
 
352
+ // Tag the activity so the caller can store the activity ID after sending
353
+ consentActivity._pendingUploadId = uploadId;
354
+
317
355
  // Return the consent activity (caller sends it)
318
356
  return consentActivity;
319
357
  }
320
358
 
321
359
  if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
322
360
  // Non-image in group chat/channel with SharePoint site configured:
323
- // Upload to SharePoint and use native file card attachment
324
- const chatId = conversationRef.conversation?.id;
361
+ // Upload to SharePoint and use native file card attachment.
362
+ // Use the cached Graph-native chat ID when available — Bot Framework conversation IDs
363
+ // for personal DMs use a format (e.g. `a:1xxx`) that Graph API rejects.
364
+ const chatId = conversationRef.graphChatId ?? conversationRef.conversation?.id;
325
365
 
326
366
  // Upload to SharePoint
327
367
  const uploaded = await uploadAndShareSharePoint({
@@ -396,6 +436,8 @@ export async function sendMSTeamsMessages(params: {
396
436
  sharePointSiteId?: string;
397
437
  /** Max media size in bytes. Default: 100MB. */
398
438
  mediaMaxBytes?: number;
439
+ /** Enable the Teams feedback loop (thumbs up/down) on sent messages. */
440
+ feedbackLoopEnabled?: boolean;
399
441
  }): Promise<string[]> {
400
442
  const messages = params.messages.filter(
401
443
  (m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
@@ -447,20 +489,40 @@ export async function sendMSTeamsMessages(params: {
447
489
  message: MSTeamsRenderedMessage,
448
490
  messageIndex: number,
449
491
  ): Promise<string> => {
492
+ let pendingUploadId: string | undefined;
450
493
  const response = await sendWithRetry(
451
- async () =>
452
- await ctx.sendActivity(
453
- await buildActivity(
454
- message,
455
- params.conversationRef,
456
- params.tokenProvider,
457
- params.sharePointSiteId,
458
- params.mediaMaxBytes,
459
- ),
460
- ),
461
- { messageIndex, messageCount: messages.length },
494
+ async () => {
495
+ const activity = await buildActivity(
496
+ message,
497
+ params.conversationRef,
498
+ params.tokenProvider,
499
+ params.sharePointSiteId,
500
+ params.mediaMaxBytes,
501
+ { feedbackLoopEnabled: params.feedbackLoopEnabled },
502
+ );
503
+
504
+ // Extract and strip the internal-only pending upload tag before sending.
505
+ pendingUploadId =
506
+ typeof activity._pendingUploadId === "string" ? activity._pendingUploadId : undefined;
507
+ if (pendingUploadId) {
508
+ delete activity._pendingUploadId;
509
+ }
510
+
511
+ return await ctx.sendActivity(activity);
512
+ },
513
+ {
514
+ messageIndex,
515
+ messageCount: messages.length,
516
+ },
462
517
  );
463
- return extractMessageId(response) ?? "unknown";
518
+ const messageId = extractMessageId(response) ?? "unknown";
519
+
520
+ // Store the activity ID so the accept handler can replace the consent card in-place
521
+ if (pendingUploadId && messageId !== "unknown") {
522
+ setPendingUploadActivityId(pendingUploadId, messageId);
523
+ }
524
+
525
+ return messageId;
464
526
  };
465
527
 
466
528
  const sendMessageBatchInContext = async (
@@ -478,11 +540,21 @@ export async function sendMSTeamsMessages(params: {
478
540
  const sendProactively = async (
479
541
  batch: MSTeamsRenderedMessage[],
480
542
  startIndex: number,
543
+ threadActivityId?: string,
481
544
  ): Promise<string[]> => {
482
545
  const baseRef = buildConversationReference(params.conversationRef);
546
+ const isChannel = params.conversationRef.conversation?.conversationType === "channel";
547
+ // For Teams channels, reconstruct the threaded conversation ID so the
548
+ // proactive message lands in the correct thread instead of creating a
549
+ // new top-level post in the channel.
550
+ const conversationId =
551
+ isChannel && threadActivityId
552
+ ? `${baseRef.conversation.id};messageid=${threadActivityId}`
553
+ : baseRef.conversation.id;
483
554
  const proactiveRef: MSTeamsConversationReference = {
484
555
  ...baseRef,
485
556
  activityId: undefined,
557
+ conversation: { ...baseRef.conversation, id: conversationId },
486
558
  };
487
559
 
488
560
  const messageIds: string[] = [];
@@ -492,6 +564,11 @@ export async function sendMSTeamsMessages(params: {
492
564
  return messageIds;
493
565
  };
494
566
 
567
+ // Resolve the thread root message ID for channel thread routing.
568
+ // `threadId` is the canonical thread root (set on inbound for channel threads);
569
+ // fall back to `activityId` for backward compatibility with older stored refs.
570
+ const resolvedThreadId = params.conversationRef.threadId ?? params.conversationRef.activityId;
571
+
495
572
  if (params.replyStyle === "thread") {
496
573
  const ctx = params.context;
497
574
  if (!ctx) {
@@ -505,9 +582,13 @@ export async function sendMSTeamsMessages(params: {
505
582
  fellBack: false,
506
583
  }),
507
584
  onRevoked: async () => {
585
+ // When the live turn context is revoked (e.g. debounced messages),
586
+ // reconstruct the threaded conversation ID so the proactive
587
+ // fallback delivers the reply into the correct channel thread.
508
588
  const remaining = messages.slice(idx);
509
589
  return {
510
- ids: remaining.length > 0 ? await sendProactively(remaining, idx) : [],
590
+ ids:
591
+ remaining.length > 0 ? await sendProactively(remaining, idx, resolvedThreadId) : [],
511
592
  fellBack: true,
512
593
  };
513
594
  },
@@ -0,0 +1,125 @@
1
+ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
2
+ import {
3
+ DEFAULT_ACCOUNT_ID,
4
+ createChannelPairingController,
5
+ evaluateSenderGroupAccessForPolicy,
6
+ isDangerousNameMatchingEnabled,
7
+ readStoreAllowFromForDmPolicy,
8
+ resolveDefaultGroupPolicy,
9
+ resolveDmGroupAccessWithLists,
10
+ resolveEffectiveAllowFromLists,
11
+ resolveSenderScopedGroupPolicy,
12
+ type OpenClawConfig,
13
+ } from "../../runtime-api.js";
14
+ import { normalizeMSTeamsConversationId } from "../inbound.js";
15
+ import { resolveMSTeamsAllowlistMatch, resolveMSTeamsRouteConfig } from "../policy.js";
16
+ import { getMSTeamsRuntime } from "../runtime.js";
17
+ import type { MSTeamsTurnContext } from "../sdk-types.js";
18
+
19
+ export async function resolveMSTeamsSenderAccess(params: {
20
+ cfg: OpenClawConfig;
21
+ activity: MSTeamsTurnContext["activity"];
22
+ }) {
23
+ const activity = params.activity;
24
+ const msteamsCfg = params.cfg.channels?.msteams;
25
+ const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "unknown");
26
+ const convType = normalizeOptionalLowercaseString(activity.conversation?.conversationType);
27
+ const isDirectMessage = convType === "personal" || (!convType && !activity.conversation?.isGroup);
28
+ const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
29
+ const senderName = activity.from?.name ?? activity.from?.id ?? senderId;
30
+
31
+ const core = getMSTeamsRuntime();
32
+ const pairing = createChannelPairingController({
33
+ core,
34
+ channel: "msteams",
35
+ accountId: DEFAULT_ACCOUNT_ID,
36
+ });
37
+ const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
38
+ const storedAllowFrom = await readStoreAllowFromForDmPolicy({
39
+ provider: "msteams",
40
+ accountId: pairing.accountId,
41
+ dmPolicy,
42
+ readStore: pairing.readStoreForDmPolicy,
43
+ });
44
+ const configuredDmAllowFrom = msteamsCfg?.allowFrom ?? [];
45
+ const groupAllowFrom = msteamsCfg?.groupAllowFrom;
46
+ const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
47
+ allowFrom: configuredDmAllowFrom,
48
+ groupAllowFrom,
49
+ storeAllowFrom: storedAllowFrom,
50
+ dmPolicy,
51
+ });
52
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
53
+ const groupPolicy =
54
+ !isDirectMessage && msteamsCfg
55
+ ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
56
+ : "disabled";
57
+ const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
58
+ const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
59
+ const channelGate = resolveMSTeamsRouteConfig({
60
+ cfg: msteamsCfg,
61
+ teamId: activity.channelData?.team?.id,
62
+ teamName: activity.channelData?.team?.name,
63
+ conversationId,
64
+ channelName: activity.channelData?.channel?.name,
65
+ allowNameMatching,
66
+ });
67
+
68
+ // When a route-level (team/channel) allowlist is configured but the sender allowlist is
69
+ // empty, resolveSenderScopedGroupPolicy would otherwise downgrade the policy to "open",
70
+ // allowing any sender. To close this bypass (GHSA-g7cr-9h7q-4qxq), treat an empty sender
71
+ // allowlist as deny-all whenever the route allowlist is active.
72
+ const senderGroupPolicy =
73
+ channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0
74
+ ? groupPolicy
75
+ : resolveSenderScopedGroupPolicy({
76
+ groupPolicy,
77
+ groupAllowFrom: effectiveGroupAllowFrom,
78
+ });
79
+ const access = resolveDmGroupAccessWithLists({
80
+ isGroup: !isDirectMessage,
81
+ dmPolicy,
82
+ groupPolicy: senderGroupPolicy,
83
+ allowFrom: configuredDmAllowFrom,
84
+ groupAllowFrom,
85
+ storeAllowFrom: storedAllowFrom,
86
+ groupAllowFromFallbackToAllowFrom: false,
87
+ isSenderAllowed: (allowFrom) =>
88
+ resolveMSTeamsAllowlistMatch({
89
+ allowFrom,
90
+ senderId,
91
+ senderName,
92
+ allowNameMatching,
93
+ }).allowed,
94
+ });
95
+ const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
96
+ groupPolicy,
97
+ groupAllowFrom: effectiveGroupAllowFrom,
98
+ senderId,
99
+ isSenderAllowed: (_senderId, allowFrom) =>
100
+ resolveMSTeamsAllowlistMatch({
101
+ allowFrom,
102
+ senderId,
103
+ senderName,
104
+ allowNameMatching,
105
+ }).allowed,
106
+ });
107
+
108
+ return {
109
+ msteamsCfg,
110
+ pairing,
111
+ isDirectMessage,
112
+ conversationId,
113
+ senderId,
114
+ senderName,
115
+ dmPolicy,
116
+ channelGate,
117
+ access,
118
+ senderGroupAccess,
119
+ configuredDmAllowFrom,
120
+ effectiveDmAllowFrom: access.effectiveAllowFrom,
121
+ effectiveGroupAllowFrom,
122
+ allowNameMatching,
123
+ groupPolicy,
124
+ };
125
+ }