@openclaw/msteams 2026.3.13 → 2026.5.2-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
@@ -0,0 +1,49 @@
1
+ import {
2
+ collectSecretInputAssignment,
3
+ getChannelRecord,
4
+ type ResolverContext,
5
+ type SecretDefaults,
6
+ } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
7
+
8
+ export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] =
9
+ [
10
+ {
11
+ id: "channels.msteams.appPassword",
12
+ targetType: "channels.msteams.appPassword",
13
+ configFile: "openclaw.json",
14
+ pathPattern: "channels.msteams.appPassword",
15
+ secretShape: "secret_input",
16
+ expectedResolvedValue: "string",
17
+ includeInPlan: true,
18
+ includeInConfigure: true,
19
+ includeInAudit: true,
20
+ },
21
+ ];
22
+
23
+ export function collectRuntimeConfigAssignments(params: {
24
+ config: { channels?: Record<string, unknown> };
25
+ defaults?: SecretDefaults;
26
+ context: ResolverContext;
27
+ }): void {
28
+ const msteams = getChannelRecord(params.config, "msteams");
29
+ if (!msteams) {
30
+ return;
31
+ }
32
+ collectSecretInputAssignment({
33
+ value: msteams.appPassword,
34
+ path: "channels.msteams.appPassword",
35
+ expected: "string",
36
+ defaults: params.defaults,
37
+ context: params.context,
38
+ active: msteams.enabled !== false,
39
+ inactiveReason: "Microsoft Teams channel is disabled.",
40
+ apply: (value) => {
41
+ msteams.appPassword = value;
42
+ },
43
+ });
44
+ }
45
+
46
+ export const channelSecrets = {
47
+ secretTargetRegistryEntries,
48
+ collectRuntimeConfigAssignments,
49
+ };
@@ -2,6 +2,6 @@ import {
2
2
  hasConfiguredSecretInput,
3
3
  normalizeResolvedSecretInputString,
4
4
  normalizeSecretInputString,
5
- } from "openclaw/plugin-sdk/msteams";
5
+ } from "openclaw/plugin-sdk/secret-input";
6
6
 
7
7
  export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
@@ -1,20 +1,23 @@
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
1
2
  import {
2
3
  resolveChannelMediaMaxBytes,
3
4
  type OpenClawConfig,
4
5
  type PluginRuntime,
5
- } from "openclaw/plugin-sdk/msteams";
6
+ } from "../runtime-api.js";
6
7
  import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
7
8
  import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
8
9
  import type {
9
10
  MSTeamsConversationStore,
10
11
  StoredConversationReference,
11
12
  } from "./conversation-store.js";
13
+ import { formatUnknownError } from "./errors.js";
14
+ import { resolveGraphChatId } from "./graph-upload.js";
12
15
  import type { MSTeamsAdapter } from "./messenger.js";
13
16
  import { getMSTeamsRuntime } from "./runtime.js";
14
- import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
17
+ import { createMSTeamsAdapter, createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
15
18
  import { resolveMSTeamsCredentials } from "./token.js";
16
19
 
17
- export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
20
+ type MSTeamsConversationType = "personal" | "groupChat" | "channel";
18
21
 
19
22
  export type MSTeamsProactiveContext = {
20
23
  appId: string;
@@ -30,6 +33,13 @@ export type MSTeamsProactiveContext = {
30
33
  sharePointSiteId?: string;
31
34
  /** Resolved media max bytes from config (default: 100MB) */
32
35
  mediaMaxBytes?: number;
36
+ /**
37
+ * Graph API-native chat ID for this conversation.
38
+ * Bot Framework personal DM IDs (`a:1xxx` / `8:orgid:xxx`) cannot be used directly
39
+ * with Graph chat endpoints. This field holds the resolved `19:xxx` format ID.
40
+ * Null if resolution failed or not applicable.
41
+ */
42
+ graphChatId?: string | null;
33
43
  };
34
44
 
35
45
  /**
@@ -84,7 +94,7 @@ async function findConversationReference(recipient: {
84
94
  return null;
85
95
  }
86
96
 
87
- const found = await recipient.store.findByUserId(recipient.id);
97
+ const found = await recipient.store.findPreferredDmByUserId(recipient.id);
88
98
  if (!found) {
89
99
  return null;
90
100
  }
@@ -120,17 +130,34 @@ export async function resolveMSTeamsSendContext(params: {
120
130
  }
121
131
 
122
132
  const { conversationId, ref } = found;
133
+
134
+ // Safety check: when the caller targeted a specific user (DM), verify the
135
+ // resolved conversation is actually a personal DM. Without this guard a
136
+ // stale or mismatched conversation store could route a private DM reply
137
+ // into a shared channel or group chat -- see #54520.
138
+ if (recipient.type === "user") {
139
+ const resolvedType = normalizeLowercaseStringOrEmpty(ref.conversation?.conversationType ?? "");
140
+ if (resolvedType && resolvedType !== "personal") {
141
+ throw new Error(
142
+ `Conversation reference for user:${recipient.id} resolved to a ${resolvedType} ` +
143
+ `conversation (${conversationId}) instead of a personal DM. ` +
144
+ `The bot must receive a DM from this user before it can send proactively.`,
145
+ );
146
+ }
147
+ }
123
148
  const core = getMSTeamsRuntime();
124
149
  const log = core.logging.getChildLogger({ name: "msteams:send" });
125
150
 
126
- const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
127
- const adapter = createMSTeamsAdapter(authConfig, sdk);
151
+ const { sdk, app } = await loadMSTeamsSdkWithAuth(creds);
152
+ const adapter = createMSTeamsAdapter(app, sdk);
128
153
 
129
- // Create token provider for Graph API / OneDrive operations
130
- const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider;
154
+ // Create token provider adapter for Graph API / OneDrive operations
155
+ const tokenProvider: MSTeamsAccessTokenProvider = createMSTeamsTokenProvider(app);
131
156
 
132
157
  // Determine conversation type from stored reference
133
- const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? "";
158
+ const storedConversationType = normalizeLowercaseStringOrEmpty(
159
+ ref.conversation?.conversationType ?? "",
160
+ );
134
161
  let conversationType: MSTeamsConversationType;
135
162
  if (storedConversationType === "personal") {
136
163
  conversationType = "personal";
@@ -150,6 +177,45 @@ export async function resolveMSTeamsSendContext(params: {
150
177
  resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
151
178
  });
152
179
 
180
+ // Resolve Graph API-native chat ID if needed for SharePoint per-user sharing.
181
+ // Bot Framework personal DM conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`) cannot
182
+ // be used directly with Graph /chats/{chatId} endpoints — the Graph API requires the
183
+ // `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
184
+ // We check the cached value first, then resolve via Graph API and cache for future sends.
185
+ let graphChatId: string | null | undefined = ref.graphChatId ?? undefined;
186
+ if (graphChatId === undefined && sharePointSiteId) {
187
+ // Only resolve when SharePoint is configured (the only place chatId matters currently)
188
+ try {
189
+ const resolved = await resolveGraphChatId({
190
+ botFrameworkConversationId: conversationId,
191
+ userAadObjectId: ref.user?.aadObjectId,
192
+ tokenProvider,
193
+ });
194
+ graphChatId = resolved;
195
+
196
+ // Cache in the conversation store so subsequent sends skip the Graph lookup.
197
+ // NOTE: We intentionally do NOT cache null results. Transient Graph API failures
198
+ // (network, 401, rate limit) should be retried on subsequent sends rather than
199
+ // permanently blocking file uploads for this conversation.
200
+ if (resolved) {
201
+ await store.upsert(conversationId, { ...ref, graphChatId: resolved });
202
+ } else {
203
+ log.warn?.("could not resolve Graph chat ID; file uploads may fail for this conversation", {
204
+ conversationId,
205
+ });
206
+ }
207
+ } catch (err) {
208
+ log.warn?.(
209
+ "failed to resolve Graph chat ID; file uploads may fall back to Bot Framework ID",
210
+ {
211
+ conversationId,
212
+ error: formatUnknownError(err),
213
+ },
214
+ );
215
+ graphChatId = null;
216
+ }
217
+ }
218
+
153
219
  return {
154
220
  appId: creds.appId,
155
221
  conversationId,
@@ -160,5 +226,6 @@ export async function resolveMSTeamsSendContext(params: {
160
226
  tokenProvider,
161
227
  sharePointSiteId,
162
228
  mediaMaxBytes,
229
+ graphChatId,
163
230
  };
164
231
  }
package/src/send.test.ts CHANGED
@@ -1,20 +1,40 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk/msteams";
2
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { sendMessageMSTeams } from "./send.js";
2
+ import type { OpenClawConfig } from "../runtime-api.js";
3
+ import { deleteMessageMSTeams, editMessageMSTeams, sendMessageMSTeams } from "./send.js";
4
4
 
5
5
  const mockState = vi.hoisted(() => ({
6
6
  loadOutboundMediaFromUrl: vi.fn(),
7
7
  resolveMSTeamsSendContext: vi.fn(),
8
+ resolveMarkdownTableMode: vi.fn(() => "off"),
9
+ convertMarkdownTables: vi.fn((text: string) => text),
10
+ runtimeResolveMarkdownTableMode: vi.fn(() => "off"),
11
+ runtimeConvertMarkdownTables: vi.fn((text: string) => text),
8
12
  requiresFileConsent: vi.fn(),
9
13
  prepareFileConsentActivity: vi.fn(),
14
+ prepareFileConsentActivityFs: vi.fn(),
10
15
  extractFilename: vi.fn(async () => "fallback.bin"),
11
16
  sendMSTeamsMessages: vi.fn(),
17
+ uploadAndShareSharePoint: vi.fn(),
18
+ getDriveItemProperties: vi.fn(),
19
+ buildTeamsFileInfoCard: vi.fn(),
12
20
  }));
13
21
 
14
- vi.mock("openclaw/plugin-sdk/msteams", () => ({
22
+ vi.mock("openclaw/plugin-sdk/outbound-media", () => ({
15
23
  loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
16
24
  }));
17
25
 
26
+ vi.mock("openclaw/plugin-sdk/markdown-table-runtime", () => ({
27
+ resolveMarkdownTableMode: mockState.resolveMarkdownTableMode,
28
+ }));
29
+
30
+ vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
31
+ const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
32
+ return {
33
+ ...actual,
34
+ convertMarkdownTables: mockState.convertMarkdownTables,
35
+ };
36
+ });
37
+
18
38
  vi.mock("./send-context.js", () => ({
19
39
  resolveMSTeamsSendContext: mockState.resolveMSTeamsSendContext,
20
40
  }));
@@ -22,6 +42,7 @@ vi.mock("./send-context.js", () => ({
22
42
  vi.mock("./file-consent-helpers.js", () => ({
23
43
  requiresFileConsent: mockState.requiresFileConsent,
24
44
  prepareFileConsentActivity: mockState.prepareFileConsentActivity,
45
+ prepareFileConsentActivityFs: mockState.prepareFileConsentActivityFs,
25
46
  }));
26
47
 
27
48
  vi.mock("./media-helpers.js", () => ({
@@ -38,21 +59,121 @@ vi.mock("./runtime.js", () => ({
38
59
  getMSTeamsRuntime: () => ({
39
60
  channel: {
40
61
  text: {
41
- resolveMarkdownTableMode: () => "off",
42
- convertMarkdownTables: (text: string) => text,
62
+ resolveMarkdownTableMode: mockState.runtimeResolveMarkdownTableMode,
63
+ convertMarkdownTables: mockState.runtimeConvertMarkdownTables,
43
64
  },
44
65
  },
45
66
  }),
46
67
  }));
47
68
 
69
+ vi.mock("./graph-upload.js", () => ({
70
+ uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
71
+ getDriveItemProperties: mockState.getDriveItemProperties,
72
+ uploadAndShareOneDrive: vi.fn(),
73
+ }));
74
+
75
+ vi.mock("./graph-chat.js", () => ({
76
+ buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard,
77
+ }));
78
+
79
+ function mockContinueConversationFailure(error: string) {
80
+ const mockContinueConversation = vi.fn().mockRejectedValue(new Error(error));
81
+ mockState.resolveMSTeamsSendContext.mockResolvedValue({
82
+ adapter: { continueConversation: mockContinueConversation },
83
+ appId: "app-id",
84
+ conversationId: "19:conversation@thread.tacv2",
85
+ ref: {
86
+ user: { id: "user-1" },
87
+ agent: { id: "agent-1" },
88
+ conversation: { id: "19:conversation@thread.tacv2" },
89
+ channelId: "msteams",
90
+ },
91
+ log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
92
+ conversationType: "personal",
93
+ tokenProvider: {},
94
+ });
95
+ return mockContinueConversation;
96
+ }
97
+
98
+ function createSharePointSendContext(params: {
99
+ conversationId: string;
100
+ graphChatId: string | null;
101
+ siteId: string;
102
+ }) {
103
+ return {
104
+ adapter: {
105
+ continueConversation: vi.fn(
106
+ async (
107
+ _id: string,
108
+ _ref: unknown,
109
+ fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
110
+ ) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
111
+ ),
112
+ },
113
+ appId: "app-id",
114
+ conversationId: params.conversationId,
115
+ graphChatId: params.graphChatId,
116
+ ref: {},
117
+ log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
118
+ conversationType: "groupChat" as const,
119
+ tokenProvider: { getAccessToken: vi.fn(async () => "token") },
120
+ mediaMaxBytes: 8 * 1024 * 1024,
121
+ sharePointSiteId: params.siteId,
122
+ };
123
+ }
124
+
125
+ function mockSharePointPdfUpload(params: {
126
+ bufferSize: number;
127
+ fileName: string;
128
+ itemId: string;
129
+ uniqueId: string;
130
+ }) {
131
+ mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
132
+ buffer: Buffer.alloc(params.bufferSize, "pdf"),
133
+ contentType: "application/pdf",
134
+ fileName: params.fileName,
135
+ kind: "file",
136
+ });
137
+ mockState.requiresFileConsent.mockReturnValue(false);
138
+ mockState.uploadAndShareSharePoint.mockResolvedValue({
139
+ itemId: params.itemId,
140
+ webUrl: `https://sp.example.com/${params.fileName}`,
141
+ shareUrl: `https://sp.example.com/share/${params.fileName}`,
142
+ name: params.fileName,
143
+ });
144
+ mockState.getDriveItemProperties.mockResolvedValue({
145
+ eTag: `"${params.uniqueId},1"`,
146
+ webDavUrl: `https://sp.example.com/dav/${params.fileName}`,
147
+ name: params.fileName,
148
+ });
149
+ mockState.buildTeamsFileInfoCard.mockReturnValue({
150
+ contentType: "application/vnd.microsoft.teams.card.file.info",
151
+ contentUrl: `https://sp.example.com/dav/${params.fileName}`,
152
+ name: params.fileName,
153
+ content: { uniqueId: params.uniqueId, fileType: "pdf" },
154
+ });
155
+ }
156
+
48
157
  describe("sendMessageMSTeams", () => {
49
158
  beforeEach(() => {
50
159
  mockState.loadOutboundMediaFromUrl.mockReset();
51
160
  mockState.resolveMSTeamsSendContext.mockReset();
161
+ mockState.resolveMarkdownTableMode.mockReset();
162
+ mockState.resolveMarkdownTableMode.mockReturnValue("off");
163
+ mockState.convertMarkdownTables.mockReset();
164
+ mockState.convertMarkdownTables.mockImplementation((text: string) => text);
165
+ mockState.runtimeResolveMarkdownTableMode.mockReset();
166
+ mockState.runtimeResolveMarkdownTableMode.mockReturnValue("off");
167
+ mockState.runtimeConvertMarkdownTables.mockReset();
168
+ mockState.runtimeConvertMarkdownTables.mockImplementation((text: string) => text);
52
169
  mockState.requiresFileConsent.mockReset();
53
170
  mockState.prepareFileConsentActivity.mockReset();
171
+ mockState.prepareFileConsentActivityFs.mockReset();
54
172
  mockState.extractFilename.mockReset();
55
173
  mockState.sendMSTeamsMessages.mockReset();
174
+ mockState.uploadAndShareSharePoint.mockReset();
175
+ mockState.getDriveItemProperties.mockReset();
176
+ mockState.buildTeamsFileInfoCard.mockReset();
56
177
 
57
178
  mockState.extractFilename.mockResolvedValue("fallback.bin");
58
179
  mockState.requiresFileConsent.mockReturnValue(false);
@@ -106,4 +227,267 @@ describe("sendMessageMSTeams", () => {
106
227
  }),
107
228
  );
108
229
  });
230
+
231
+ it("sends with provided cfg even when Teams runtime text helpers are unavailable", async () => {
232
+ mockState.runtimeResolveMarkdownTableMode.mockImplementation(() => {
233
+ throw new Error("MSTeams runtime not initialized");
234
+ });
235
+ mockState.runtimeConvertMarkdownTables.mockImplementation(() => {
236
+ throw new Error("MSTeams runtime not initialized");
237
+ });
238
+ mockState.resolveMarkdownTableMode.mockReturnValue("off");
239
+ mockState.convertMarkdownTables.mockReturnValue("hello");
240
+
241
+ await expect(
242
+ sendMessageMSTeams({
243
+ cfg: {} as OpenClawConfig,
244
+ to: "conversation:19:conversation@thread.tacv2",
245
+ text: "hello",
246
+ }),
247
+ ).resolves.toEqual({
248
+ messageId: "message-1",
249
+ conversationId: "19:conversation@thread.tacv2",
250
+ });
251
+
252
+ expect(mockState.resolveMarkdownTableMode).toHaveBeenCalledWith({
253
+ cfg: {},
254
+ channel: "msteams",
255
+ });
256
+ expect(mockState.convertMarkdownTables).toHaveBeenCalledWith("hello", "off");
257
+ });
258
+
259
+ it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => {
260
+ // Simulates a group chat where Bot Framework conversationId is valid but we have
261
+ // a resolved Graph chat ID cached from a prior send.
262
+ const graphChatId = "19:graph-native-chat-id@thread.tacv2";
263
+ const botFrameworkConversationId = "19:bot-framework-id@thread.tacv2";
264
+
265
+ mockState.resolveMSTeamsSendContext.mockResolvedValue(
266
+ createSharePointSendContext({
267
+ conversationId: botFrameworkConversationId,
268
+ graphChatId,
269
+ siteId: "site-123",
270
+ }),
271
+ );
272
+ mockSharePointPdfUpload({
273
+ bufferSize: 100,
274
+ fileName: "doc.pdf",
275
+ itemId: "item-1",
276
+ uniqueId: "{GUID-123}",
277
+ });
278
+
279
+ await sendMessageMSTeams({
280
+ cfg: {} as OpenClawConfig,
281
+ to: "conversation:19:bot-framework-id@thread.tacv2",
282
+ text: "here is a file",
283
+ mediaUrl: "https://example.com/doc.pdf",
284
+ });
285
+
286
+ // The Graph-native chatId must be passed to SharePoint upload, not the Bot Framework ID
287
+ expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith(
288
+ expect.objectContaining({
289
+ chatId: graphChatId,
290
+ siteId: "site-123",
291
+ }),
292
+ );
293
+ });
294
+
295
+ it("falls back to conversationId when graphChatId is not available", async () => {
296
+ const botFrameworkConversationId = "19:fallback-id@thread.tacv2";
297
+
298
+ mockState.resolveMSTeamsSendContext.mockResolvedValue(
299
+ createSharePointSendContext({
300
+ conversationId: botFrameworkConversationId,
301
+ graphChatId: null,
302
+ siteId: "site-456",
303
+ }),
304
+ );
305
+ mockSharePointPdfUpload({
306
+ bufferSize: 50,
307
+ fileName: "report.pdf",
308
+ itemId: "item-2",
309
+ uniqueId: "{GUID-456}",
310
+ });
311
+
312
+ await sendMessageMSTeams({
313
+ cfg: {} as OpenClawConfig,
314
+ to: "conversation:19:fallback-id@thread.tacv2",
315
+ text: "report",
316
+ mediaUrl: "https://example.com/report.pdf",
317
+ });
318
+
319
+ // Falls back to conversationId when graphChatId is null
320
+ expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith(
321
+ expect.objectContaining({
322
+ chatId: botFrameworkConversationId,
323
+ siteId: "site-456",
324
+ }),
325
+ );
326
+ });
327
+ });
328
+
329
+ describe("editMessageMSTeams", () => {
330
+ beforeEach(() => {
331
+ mockState.resolveMSTeamsSendContext.mockReset();
332
+ });
333
+
334
+ it("calls continueConversation and updateActivity with correct params", async () => {
335
+ const mockUpdateActivity = vi.fn();
336
+ const mockContinueConversation = vi.fn(
337
+ async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
338
+ await logic({
339
+ sendActivity: vi.fn(),
340
+ updateActivity: mockUpdateActivity,
341
+ deleteActivity: vi.fn(),
342
+ });
343
+ },
344
+ );
345
+ mockState.resolveMSTeamsSendContext.mockResolvedValue({
346
+ adapter: { continueConversation: mockContinueConversation },
347
+ appId: "app-id",
348
+ conversationId: "19:conversation@thread.tacv2",
349
+ ref: {
350
+ user: { id: "user-1" },
351
+ agent: { id: "agent-1" },
352
+ conversation: { id: "19:conversation@thread.tacv2", conversationType: "personal" },
353
+ channelId: "msteams",
354
+ },
355
+ log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
356
+ conversationType: "personal",
357
+ tokenProvider: {},
358
+ });
359
+
360
+ const result = await editMessageMSTeams({
361
+ cfg: {} as OpenClawConfig,
362
+ to: "conversation:19:conversation@thread.tacv2",
363
+ activityId: "activity-123",
364
+ text: "Updated message text",
365
+ });
366
+
367
+ expect(result.conversationId).toBe("19:conversation@thread.tacv2");
368
+ expect(mockContinueConversation).toHaveBeenCalledTimes(1);
369
+ expect(mockContinueConversation).toHaveBeenCalledWith(
370
+ "app-id",
371
+ expect.objectContaining({ activityId: undefined }),
372
+ expect.any(Function),
373
+ );
374
+ expect(mockUpdateActivity).toHaveBeenCalledWith({
375
+ type: "message",
376
+ id: "activity-123",
377
+ text: "Updated message text",
378
+ });
379
+ });
380
+
381
+ it("throws a descriptive error when continueConversation fails", async () => {
382
+ mockContinueConversationFailure("Service unavailable");
383
+
384
+ await expect(
385
+ editMessageMSTeams({
386
+ cfg: {} as OpenClawConfig,
387
+ to: "conversation:19:conversation@thread.tacv2",
388
+ activityId: "activity-123",
389
+ text: "Updated text",
390
+ }),
391
+ ).rejects.toThrow("msteams edit failed");
392
+ });
393
+ });
394
+
395
+ describe("deleteMessageMSTeams", () => {
396
+ beforeEach(() => {
397
+ mockState.resolveMSTeamsSendContext.mockReset();
398
+ });
399
+
400
+ it("calls continueConversation and deleteActivity with correct activityId", async () => {
401
+ const mockDeleteActivity = vi.fn();
402
+ const mockContinueConversation = vi.fn(
403
+ async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
404
+ await logic({
405
+ sendActivity: vi.fn(),
406
+ updateActivity: vi.fn(),
407
+ deleteActivity: mockDeleteActivity,
408
+ });
409
+ },
410
+ );
411
+ mockState.resolveMSTeamsSendContext.mockResolvedValue({
412
+ adapter: { continueConversation: mockContinueConversation },
413
+ appId: "app-id",
414
+ conversationId: "19:conversation@thread.tacv2",
415
+ ref: {
416
+ user: { id: "user-1" },
417
+ agent: { id: "agent-1" },
418
+ conversation: { id: "19:conversation@thread.tacv2", conversationType: "groupChat" },
419
+ channelId: "msteams",
420
+ },
421
+ log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
422
+ conversationType: "groupChat",
423
+ tokenProvider: {},
424
+ });
425
+
426
+ const result = await deleteMessageMSTeams({
427
+ cfg: {} as OpenClawConfig,
428
+ to: "conversation:19:conversation@thread.tacv2",
429
+ activityId: "activity-456",
430
+ });
431
+
432
+ expect(result.conversationId).toBe("19:conversation@thread.tacv2");
433
+ expect(mockContinueConversation).toHaveBeenCalledTimes(1);
434
+ expect(mockContinueConversation).toHaveBeenCalledWith(
435
+ "app-id",
436
+ expect.objectContaining({ activityId: undefined }),
437
+ expect.any(Function),
438
+ );
439
+ expect(mockDeleteActivity).toHaveBeenCalledWith("activity-456");
440
+ });
441
+
442
+ it("throws a descriptive error when continueConversation fails", async () => {
443
+ mockContinueConversationFailure("Not found");
444
+
445
+ await expect(
446
+ deleteMessageMSTeams({
447
+ cfg: {} as OpenClawConfig,
448
+ to: "conversation:19:conversation@thread.tacv2",
449
+ activityId: "activity-456",
450
+ }),
451
+ ).rejects.toThrow("msteams delete failed");
452
+ });
453
+
454
+ it("passes the appId and proactive ref to continueConversation", async () => {
455
+ const mockContinueConversation = vi.fn(
456
+ async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
457
+ await logic({
458
+ sendActivity: vi.fn(),
459
+ updateActivity: vi.fn(),
460
+ deleteActivity: vi.fn(),
461
+ });
462
+ },
463
+ );
464
+ mockState.resolveMSTeamsSendContext.mockResolvedValue({
465
+ adapter: { continueConversation: mockContinueConversation },
466
+ appId: "my-app-id",
467
+ conversationId: "19:conv@thread.tacv2",
468
+ ref: {
469
+ activityId: "original-activity",
470
+ user: { id: "user-1" },
471
+ agent: { id: "agent-1" },
472
+ conversation: { id: "19:conv@thread.tacv2" },
473
+ channelId: "msteams",
474
+ },
475
+ log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
476
+ conversationType: "personal",
477
+ tokenProvider: {},
478
+ });
479
+
480
+ await deleteMessageMSTeams({
481
+ cfg: {} as OpenClawConfig,
482
+ to: "conversation:19:conv@thread.tacv2",
483
+ activityId: "activity-789",
484
+ });
485
+
486
+ // appId should be forwarded correctly
487
+ expect(mockContinueConversation.mock.calls[0]?.[0]).toBe("my-app-id");
488
+ // activityId on the proactive ref should be cleared (undefined) — proactive pattern
489
+ expect(mockContinueConversation.mock.calls[0]?.[1]).toMatchObject({
490
+ activityId: undefined,
491
+ });
492
+ });
109
493
  });