@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
@@ -0,0 +1,146 @@
1
+ import { fetchGraphJson, type GraphResponse } from "./graph.js";
2
+
3
+ export type GraphThreadMessage = {
4
+ id?: string;
5
+ from?: {
6
+ user?: { displayName?: string; id?: string };
7
+ application?: { displayName?: string; id?: string };
8
+ };
9
+ body?: { content?: string; contentType?: string };
10
+ createdDateTime?: string;
11
+ };
12
+
13
+ // TTL cache for team ID -> group GUID mapping.
14
+ const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
15
+ const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
16
+
17
+ /**
18
+ * Strip HTML tags from Teams message content, preserving @mention display names.
19
+ * Teams wraps mentions in <at>Name</at> tags.
20
+ */
21
+ export function stripHtmlFromTeamsMessage(html: string): string {
22
+ // Preserve mention display names by replacing <at>Name</at> with @Name.
23
+ let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
24
+ // Strip remaining HTML tags.
25
+ text = text.replace(/<[^>]*>/g, " ");
26
+ // Decode common HTML entities.
27
+ text = text
28
+ .replace(/&amp;/g, "&")
29
+ .replace(/&lt;/g, "<")
30
+ .replace(/&gt;/g, ">")
31
+ .replace(/&quot;/g, '"')
32
+ .replace(/&#39;/g, "'")
33
+ .replace(/&nbsp;/g, " ");
34
+ // Normalize whitespace.
35
+ return text.replace(/\s+/g, " ").trim();
36
+ }
37
+
38
+ /**
39
+ * Resolve the Azure AD group GUID for a Teams conversation team ID.
40
+ * Results are cached with a TTL to avoid repeated Graph API calls.
41
+ */
42
+ export async function resolveTeamGroupId(
43
+ token: string,
44
+ conversationTeamId: string,
45
+ ): Promise<string> {
46
+ const cached = teamGroupIdCache.get(conversationTeamId);
47
+ if (cached && cached.expiresAt > Date.now()) {
48
+ return cached.groupId;
49
+ }
50
+
51
+ // The team ID in channelData is typically the group ID itself for standard teams.
52
+ // Validate by fetching /teams/{id} and returning the confirmed id.
53
+ // Requires Team.ReadBasic.All permission; fall back to raw ID if missing.
54
+ try {
55
+ const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`;
56
+ const team = await fetchGraphJson<{ id?: string }>({ token, path });
57
+ const groupId = team.id ?? conversationTeamId;
58
+
59
+ // Only cache when the Graph lookup succeeds — caching a fallback raw ID
60
+ // can cause silent failures for the entire TTL if the ID is not a valid
61
+ // Graph team GUID (e.g. Bot Framework conversation key).
62
+ teamGroupIdCache.set(conversationTeamId, {
63
+ groupId,
64
+ expiresAt: Date.now() + CACHE_TTL_MS,
65
+ });
66
+
67
+ return groupId;
68
+ } catch {
69
+ // Fallback to raw team ID without caching so subsequent calls retry the
70
+ // Graph lookup instead of using a potentially invalid cached value.
71
+ return conversationTeamId;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Fetch a single channel message (the parent/root of a thread).
77
+ * Returns undefined on error so callers can degrade gracefully.
78
+ */
79
+ export async function fetchChannelMessage(
80
+ token: string,
81
+ groupId: string,
82
+ channelId: string,
83
+ messageId: string,
84
+ ): Promise<GraphThreadMessage | undefined> {
85
+ const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;
86
+ try {
87
+ return await fetchGraphJson<GraphThreadMessage>({ token, path });
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Fetch thread replies for a channel message, ordered chronologically.
95
+ *
96
+ * **Limitation:** The Graph API replies endpoint (`/messages/{id}/replies`) does not
97
+ * support `$orderby`, so results are always returned in ascending (oldest-first) order.
98
+ * Combined with the `$top` cap of 50, this means only the **oldest 50 replies** are
99
+ * returned for long threads — newer replies are silently omitted. There is currently no
100
+ * Graph API workaround for this; pagination via `@odata.nextLink` can retrieve more
101
+ * replies but still in ascending order only.
102
+ */
103
+ export async function fetchThreadReplies(
104
+ token: string,
105
+ groupId: string,
106
+ channelId: string,
107
+ messageId: string,
108
+ limit = 50,
109
+ ): Promise<GraphThreadMessage[]> {
110
+ const top = Math.min(Math.max(limit, 1), 50);
111
+ // NOTE: Graph replies endpoint returns oldest-first and does not support $orderby.
112
+ // For threads with >50 replies, only the oldest 50 are returned. The most recent
113
+ // replies (often the most relevant context) may be truncated.
114
+ const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`;
115
+ const res = await fetchGraphJson<GraphResponse<GraphThreadMessage>>({ token, path });
116
+ return res.value ?? [];
117
+ }
118
+
119
+ /**
120
+ * Format thread messages into a context string for the agent.
121
+ * Skips the current message (by id) and blank messages.
122
+ */
123
+ export function formatThreadContext(
124
+ messages: GraphThreadMessage[],
125
+ currentMessageId?: string,
126
+ ): string {
127
+ const lines: string[] = [];
128
+ for (const msg of messages) {
129
+ if (msg.id && msg.id === currentMessageId) {
130
+ continue;
131
+ } // Skip the triggering message.
132
+ const sender = msg.from?.user?.displayName ?? msg.from?.application?.displayName ?? "unknown";
133
+ const contentType = msg.body?.contentType ?? "text";
134
+ const rawContent = msg.body?.content ?? "";
135
+ const content =
136
+ contentType === "html" ? stripHtmlFromTeamsMessage(rawContent) : rawContent.trim();
137
+ if (!content) {
138
+ continue;
139
+ }
140
+ lines.push(`${sender}: ${content}`);
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ // Exported for testing only.
146
+ export { teamGroupIdCache as _teamGroupIdCacheForTest };
@@ -1,5 +1,7 @@
1
+ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
1
2
  import { describe, expect, it, vi } from "vitest";
2
- import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
3
+ import { buildTeamsFileInfoCard } from "./graph-chat.js";
4
+ import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
3
5
 
4
6
  describe("graph upload helpers", () => {
5
7
  const tokenProvider = {
@@ -22,7 +24,7 @@ describe("graph upload helpers", () => {
22
24
  buffer: Buffer.from("hello"),
23
25
  filename: "a.txt",
24
26
  tokenProvider,
25
- fetchFn: fetchFn as typeof fetch,
27
+ fetchFn: withFetchPreconnect(fetchFn),
26
28
  });
27
29
 
28
30
  expect(fetchFn).toHaveBeenCalledWith(
@@ -32,6 +34,7 @@ describe("graph upload helpers", () => {
32
34
  headers: expect.objectContaining({
33
35
  Authorization: "Bearer graph-token",
34
36
  "Content-Type": "application/octet-stream",
37
+ "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
35
38
  }),
36
39
  }),
37
40
  );
@@ -59,7 +62,7 @@ describe("graph upload helpers", () => {
59
62
  filename: "b.txt",
60
63
  siteId: "site-123",
61
64
  tokenProvider,
62
- fetchFn: fetchFn as typeof fetch,
65
+ fetchFn: withFetchPreconnect(fetchFn),
63
66
  });
64
67
 
65
68
  expect(fetchFn).toHaveBeenCalledWith(
@@ -69,6 +72,7 @@ describe("graph upload helpers", () => {
69
72
  headers: expect.objectContaining({
70
73
  Authorization: "Bearer graph-token",
71
74
  "Content-Type": "application/octet-stream",
75
+ "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
72
76
  }),
73
77
  }),
74
78
  );
@@ -94,8 +98,161 @@ describe("graph upload helpers", () => {
94
98
  filename: "bad.txt",
95
99
  siteId: "site-123",
96
100
  tokenProvider,
97
- fetchFn: fetchFn as typeof fetch,
101
+ fetchFn: withFetchPreconnect(fetchFn),
98
102
  }),
99
103
  ).rejects.toThrow("SharePoint upload response missing required fields");
100
104
  });
101
105
  });
106
+
107
+ describe("resolveGraphChatId", () => {
108
+ const tokenProvider = {
109
+ getAccessToken: vi.fn(async () => "graph-token"),
110
+ };
111
+
112
+ it("returns the ID directly when it already starts with 19:", async () => {
113
+ const fetchFn = vi.fn();
114
+ const result = await resolveGraphChatId({
115
+ botFrameworkConversationId: "19:abc123@thread.tacv2",
116
+ tokenProvider,
117
+ fetchFn: withFetchPreconnect(fetchFn),
118
+ });
119
+ // Should short-circuit without making any API call
120
+ expect(fetchFn).not.toHaveBeenCalled();
121
+ expect(result).toBe("19:abc123@thread.tacv2");
122
+ });
123
+
124
+ it("resolves personal DM chat ID via Graph API using user AAD object ID", async () => {
125
+ const fetchFn = vi.fn(
126
+ async () =>
127
+ new Response(JSON.stringify({ value: [{ id: "19:dm-chat-id@unq.gbl.spaces" }] }), {
128
+ status: 200,
129
+ headers: { "content-type": "application/json" },
130
+ }),
131
+ );
132
+
133
+ const result = await resolveGraphChatId({
134
+ botFrameworkConversationId: "a:1abc_bot_framework_dm_id",
135
+ userAadObjectId: "user-aad-object-id-123",
136
+ tokenProvider,
137
+ fetchFn: withFetchPreconnect(fetchFn),
138
+ });
139
+
140
+ expect(fetchFn).toHaveBeenCalledWith(
141
+ expect.stringContaining("/me/chats"),
142
+ expect.objectContaining({
143
+ headers: expect.objectContaining({
144
+ Authorization: "Bearer graph-token",
145
+ "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
146
+ }),
147
+ }),
148
+ );
149
+ const firstCall = fetchFn.mock.calls[0];
150
+ if (!firstCall) {
151
+ throw new Error("expected Graph chat lookup request");
152
+ }
153
+ const [callUrlRaw] = firstCall as unknown as [string, RequestInit?];
154
+ const callUrl = new URL(callUrlRaw);
155
+ expect(callUrl.origin).toBe("https://graph.microsoft.com");
156
+ expect(callUrl.pathname).toBe("/v1.0/me/chats");
157
+ expect(callUrl.searchParams.get("$filter")).toBe(
158
+ "chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq 'user-aad-object-id-123')",
159
+ );
160
+ expect(callUrl.searchParams.get("$select")).toBe("id");
161
+ expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
162
+ });
163
+
164
+ it("resolves personal DM chat ID without user AAD object ID (lists all 1:1 chats)", async () => {
165
+ const fetchFn = vi.fn(
166
+ async () =>
167
+ new Response(JSON.stringify({ value: [{ id: "19:fallback-chat@unq.gbl.spaces" }] }), {
168
+ status: 200,
169
+ headers: { "content-type": "application/json" },
170
+ }),
171
+ );
172
+
173
+ const result = await resolveGraphChatId({
174
+ botFrameworkConversationId: "8:orgid:user-object-id",
175
+ tokenProvider,
176
+ fetchFn: withFetchPreconnect(fetchFn),
177
+ });
178
+
179
+ expect(fetchFn).toHaveBeenCalledOnce();
180
+ expect(result).toBe("19:fallback-chat@unq.gbl.spaces");
181
+ });
182
+
183
+ it("returns null when Graph API returns no chats", async () => {
184
+ const fetchFn = vi.fn(
185
+ async () =>
186
+ new Response(JSON.stringify({ value: [] }), {
187
+ status: 200,
188
+ headers: { "content-type": "application/json" },
189
+ }),
190
+ );
191
+
192
+ const result = await resolveGraphChatId({
193
+ botFrameworkConversationId: "a:1unknown_dm",
194
+ userAadObjectId: "some-user",
195
+ tokenProvider,
196
+ fetchFn: withFetchPreconnect(fetchFn),
197
+ });
198
+
199
+ expect(result).toBeNull();
200
+ });
201
+
202
+ it("returns null when Graph API call fails", async () => {
203
+ const fetchFn = vi.fn(
204
+ async () =>
205
+ new Response("Unauthorized", {
206
+ status: 401,
207
+ headers: { "content-type": "text/plain" },
208
+ }),
209
+ );
210
+
211
+ const result = await resolveGraphChatId({
212
+ botFrameworkConversationId: "a:1some_dm_id",
213
+ userAadObjectId: "some-user",
214
+ tokenProvider,
215
+ fetchFn: withFetchPreconnect(fetchFn),
216
+ });
217
+
218
+ expect(result).toBeNull();
219
+ });
220
+ });
221
+
222
+ describe("buildTeamsFileInfoCard", () => {
223
+ it("extracts a unique id from quoted etags and lowercases file extensions", () => {
224
+ expect(
225
+ buildTeamsFileInfoCard({
226
+ eTag: '"{ABC-123},42"',
227
+ name: "Quarterly.Report.PDF",
228
+ webDavUrl: "https://sharepoint.example.com/file.pdf",
229
+ }),
230
+ ).toEqual({
231
+ contentType: "application/vnd.microsoft.teams.card.file.info",
232
+ contentUrl: "https://sharepoint.example.com/file.pdf",
233
+ name: "Quarterly.Report.PDF",
234
+ content: {
235
+ uniqueId: "ABC-123",
236
+ fileType: "pdf",
237
+ },
238
+ });
239
+ });
240
+
241
+ it("keeps the raw etag when no version suffix exists and handles extensionless files", () => {
242
+ expect(
243
+ buildTeamsFileInfoCard({
244
+ eTag: "plain-etag",
245
+ name: "README",
246
+ webDavUrl: "https://sharepoint.example.com/readme",
247
+ }),
248
+ ).toEqual({
249
+ contentType: "application/vnd.microsoft.teams.card.file.info",
250
+ contentUrl: "https://sharepoint.example.com/readme",
251
+ name: "README",
252
+ content: {
253
+ uniqueId: "plain-etag",
254
+ fileType: "",
255
+ },
256
+ });
257
+ });
258
+ });
@@ -10,47 +10,39 @@
10
10
  */
11
11
 
12
12
  import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
13
+ import { buildUserAgent } from "./user-agent.js";
13
14
 
14
15
  const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
15
16
  const GRAPH_BETA = "https://graph.microsoft.com/beta";
16
17
  const GRAPH_SCOPE = "https://graph.microsoft.com";
17
18
 
18
- export interface OneDriveUploadResult {
19
+ interface OneDriveUploadResult {
19
20
  id: string;
20
21
  webUrl: string;
21
22
  name: string;
22
23
  }
23
24
 
24
- function parseUploadedDriveItem(
25
- data: { id?: string; webUrl?: string; name?: string },
26
- label: "OneDrive" | "SharePoint",
27
- ): OneDriveUploadResult {
28
- if (!data.id || !data.webUrl || !data.name) {
29
- throw new Error(`${label} upload response missing required fields`);
30
- }
31
-
32
- return {
33
- id: data.id,
34
- webUrl: data.webUrl,
35
- name: data.name,
36
- };
37
- }
38
-
39
- async function uploadDriveItem(params: {
25
+ /**
26
+ * Upload a file to the user's OneDrive root folder.
27
+ * For larger files, this uses the simple upload endpoint (up to 4MB).
28
+ */
29
+ export async function uploadToOneDrive(params: {
40
30
  buffer: Buffer;
41
31
  filename: string;
42
32
  contentType?: string;
43
33
  tokenProvider: MSTeamsAccessTokenProvider;
44
34
  fetchFn?: typeof fetch;
45
- url: string;
46
- label: "OneDrive" | "SharePoint";
47
35
  }): Promise<OneDriveUploadResult> {
48
36
  const fetchFn = params.fetchFn ?? fetch;
49
37
  const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
50
38
 
51
- const res = await fetchFn(params.url, {
39
+ // Use "OpenClawShared" folder to organize bot-uploaded files
40
+ const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
41
+
42
+ const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
52
43
  method: "PUT",
53
44
  headers: {
45
+ "User-Agent": buildUserAgent(),
54
46
  Authorization: `Bearer ${token}`,
55
47
  "Content-Type": params.contentType ?? "application/octet-stream",
56
48
  },
@@ -59,36 +51,27 @@ async function uploadDriveItem(params: {
59
51
 
60
52
  if (!res.ok) {
61
53
  const body = await res.text().catch(() => "");
62
- throw new Error(`${params.label} upload failed: ${res.status} ${res.statusText} - ${body}`);
54
+ throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
63
55
  }
64
56
 
65
- return parseUploadedDriveItem(
66
- (await res.json()) as { id?: string; webUrl?: string; name?: string },
67
- params.label,
68
- );
69
- }
57
+ const data = (await res.json()) as {
58
+ id?: string;
59
+ webUrl?: string;
60
+ name?: string;
61
+ };
70
62
 
71
- /**
72
- * Upload a file to the user's OneDrive root folder.
73
- * For larger files, this uses the simple upload endpoint (up to 4MB).
74
- */
75
- export async function uploadToOneDrive(params: {
76
- buffer: Buffer;
77
- filename: string;
78
- contentType?: string;
79
- tokenProvider: MSTeamsAccessTokenProvider;
80
- fetchFn?: typeof fetch;
81
- }): Promise<OneDriveUploadResult> {
82
- // Use "OpenClawShared" folder to organize bot-uploaded files
83
- const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
84
- return await uploadDriveItem({
85
- ...params,
86
- url: `${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`,
87
- label: "OneDrive",
88
- });
63
+ if (!data.id || !data.webUrl || !data.name) {
64
+ throw new Error("OneDrive upload response missing required fields");
65
+ }
66
+
67
+ return {
68
+ id: data.id,
69
+ webUrl: data.webUrl,
70
+ name: data.name,
71
+ };
89
72
  }
90
73
 
91
- export interface OneDriveSharingLink {
74
+ interface OneDriveSharingLink {
92
75
  webUrl: string;
93
76
  }
94
77
 
@@ -96,7 +79,7 @@ export interface OneDriveSharingLink {
96
79
  * Create a sharing link for a OneDrive file.
97
80
  * The link allows organization members to view the file.
98
81
  */
99
- export async function createSharingLink(params: {
82
+ async function createSharingLink(params: {
100
83
  itemId: string;
101
84
  tokenProvider: MSTeamsAccessTokenProvider;
102
85
  /** Sharing scope: "organization" (default) or "anonymous" */
@@ -109,6 +92,7 @@ export async function createSharingLink(params: {
109
92
  const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
110
93
  method: "POST",
111
94
  headers: {
95
+ "User-Agent": buildUserAgent(),
112
96
  Authorization: `Bearer ${token}`,
113
97
  "Content-Type": "application/json",
114
98
  },
@@ -194,16 +178,48 @@ export async function uploadToSharePoint(params: {
194
178
  siteId: string;
195
179
  fetchFn?: typeof fetch;
196
180
  }): Promise<OneDriveUploadResult> {
181
+ const fetchFn = params.fetchFn ?? fetch;
182
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
183
+
197
184
  // Use "OpenClawShared" folder to organize bot-uploaded files
198
185
  const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
199
- return await uploadDriveItem({
200
- ...params,
201
- url: `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
202
- label: "SharePoint",
203
- });
186
+
187
+ const res = await fetchFn(
188
+ `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
189
+ {
190
+ method: "PUT",
191
+ headers: {
192
+ "User-Agent": buildUserAgent(),
193
+ Authorization: `Bearer ${token}`,
194
+ "Content-Type": params.contentType ?? "application/octet-stream",
195
+ },
196
+ body: new Uint8Array(params.buffer),
197
+ },
198
+ );
199
+
200
+ if (!res.ok) {
201
+ const body = await res.text().catch(() => "");
202
+ throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
203
+ }
204
+
205
+ const data = (await res.json()) as {
206
+ id?: string;
207
+ webUrl?: string;
208
+ name?: string;
209
+ };
210
+
211
+ if (!data.id || !data.webUrl || !data.name) {
212
+ throw new Error("SharePoint upload response missing required fields");
213
+ }
214
+
215
+ return {
216
+ id: data.id,
217
+ webUrl: data.webUrl,
218
+ name: data.name,
219
+ };
204
220
  }
205
221
 
206
- export interface ChatMember {
222
+ interface ChatMember {
207
223
  aadObjectId: string;
208
224
  displayName?: string;
209
225
  }
@@ -239,7 +255,7 @@ export async function getDriveItemProperties(params: {
239
255
 
240
256
  const res = await fetchFn(
241
257
  `${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
242
- { headers: { Authorization: `Bearer ${token}` } },
258
+ { headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` } },
243
259
  );
244
260
 
245
261
  if (!res.ok) {
@@ -264,11 +280,85 @@ export async function getDriveItemProperties(params: {
264
280
  };
265
281
  }
266
282
 
283
+ /**
284
+ * Resolve the Graph API-native chat ID from a Bot Framework conversation ID.
285
+ *
286
+ * Bot Framework personal DM conversation IDs use formats like `a:1xxx@unq.gbl.spaces`
287
+ * or `8:orgid:xxx` that the Graph API does not accept. Graph API requires the
288
+ * `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
289
+ *
290
+ * This function looks up the matching Graph chat by querying the bot's chats filtered
291
+ * by the target user's AAD object ID.
292
+ */
293
+ export async function resolveGraphChatId(params: {
294
+ /** Bot Framework conversation ID (may be in non-Graph format for personal DMs) */
295
+ botFrameworkConversationId: string;
296
+ /** AAD object ID of the user in the conversation (used for filtering chats) */
297
+ userAadObjectId?: string;
298
+ tokenProvider: MSTeamsAccessTokenProvider;
299
+ fetchFn?: typeof fetch;
300
+ }): Promise<string | null> {
301
+ const { botFrameworkConversationId, userAadObjectId, tokenProvider } = params;
302
+ const fetchFn = params.fetchFn ?? fetch;
303
+
304
+ // If the conversation ID already looks like a valid Graph chat ID, return it directly.
305
+ // Graph chat IDs start with "19:" — Bot Framework group chat IDs already use this format.
306
+ if (botFrameworkConversationId.startsWith("19:")) {
307
+ return botFrameworkConversationId;
308
+ }
309
+
310
+ // For personal DMs with non-Graph conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`),
311
+ // query the bot's chats to find the matching one.
312
+ const token = await tokenProvider.getAccessToken(GRAPH_SCOPE);
313
+
314
+ // Build filter: if we have the user's AAD object ID, narrow the search to 1:1 chats
315
+ // with that member. Otherwise, fall back to listing all 1:1 chats.
316
+ let path: string;
317
+ if (userAadObjectId) {
318
+ const encoded = encodeURIComponent(
319
+ `chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq '${userAadObjectId}')`,
320
+ );
321
+ path = `/me/chats?$filter=${encoded}&$select=id`;
322
+ } else {
323
+ // Fallback: list all 1:1 chats when no user ID is available.
324
+ // Only safe when the bot has exactly one 1:1 chat; returns null otherwise to
325
+ // avoid sending to the wrong person's chat.
326
+ path = `/me/chats?$filter=${encodeURIComponent("chatType eq 'oneOnOne'")}&$select=id`;
327
+ }
328
+
329
+ const res = await fetchFn(`${GRAPH_ROOT}${path}`, {
330
+ headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
331
+ });
332
+
333
+ if (!res.ok) {
334
+ return null;
335
+ }
336
+
337
+ const data = (await res.json()) as {
338
+ value?: Array<{ id?: string }>;
339
+ };
340
+
341
+ const chats = data.value ?? [];
342
+
343
+ // When filtered by userAadObjectId, any non-empty result is the right 1:1 chat.
344
+ if (userAadObjectId && chats.length > 0 && chats[0]?.id) {
345
+ return chats[0].id;
346
+ }
347
+
348
+ // Without a user ID we can only be certain when exactly one chat is returned;
349
+ // multiple results would be ambiguous and could route to the wrong person.
350
+ if (!userAadObjectId && chats.length === 1 && chats[0]?.id) {
351
+ return chats[0].id;
352
+ }
353
+
354
+ return null;
355
+ }
356
+
267
357
  /**
268
358
  * Get members of a Teams chat for per-user sharing.
269
359
  * Used to create sharing links scoped to only the chat participants.
270
360
  */
271
- export async function getChatMembers(params: {
361
+ async function getChatMembers(params: {
272
362
  chatId: string;
273
363
  tokenProvider: MSTeamsAccessTokenProvider;
274
364
  fetchFn?: typeof fetch;
@@ -277,7 +367,7 @@ export async function getChatMembers(params: {
277
367
  const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
278
368
 
279
369
  const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
280
- headers: { Authorization: `Bearer ${token}` },
370
+ headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
281
371
  });
282
372
 
283
373
  if (!res.ok) {
@@ -305,7 +395,7 @@ export async function getChatMembers(params: {
305
395
  * For organization scope (default), uses v1.0 API.
306
396
  * For per-user scope, uses beta API with recipients.
307
397
  */
308
- export async function createSharePointSharingLink(params: {
398
+ async function createSharePointSharingLink(params: {
309
399
  siteId: string;
310
400
  itemId: string;
311
401
  tokenProvider: MSTeamsAccessTokenProvider;
@@ -337,6 +427,7 @@ export async function createSharePointSharingLink(params: {
337
427
  {
338
428
  method: "POST",
339
429
  headers: {
430
+ "User-Agent": buildUserAgent(),
340
431
  Authorization: `Bearer ${token}`,
341
432
  "Content-Type": "application/json",
342
433
  },