@openclaw/msteams 2026.3.12 → 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 +161 -9
  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 +174 -437
  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 +148 -14
  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 +258 -0
  78. package/src/graph-upload.ts +87 -8
  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 +522 -45
  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 +477 -174
  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 +301 -106
  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 +34 -40
  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 -101
  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 };
@@ -0,0 +1,258 @@
1
+ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { buildTeamsFileInfoCard } from "./graph-chat.js";
4
+ import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
5
+
6
+ describe("graph upload helpers", () => {
7
+ const tokenProvider = {
8
+ getAccessToken: vi.fn(async () => "graph-token"),
9
+ };
10
+
11
+ it("uploads to OneDrive with the personal drive path", async () => {
12
+ const fetchFn = vi.fn(
13
+ async () =>
14
+ new Response(
15
+ JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }),
16
+ {
17
+ status: 200,
18
+ headers: { "content-type": "application/json" },
19
+ },
20
+ ),
21
+ );
22
+
23
+ const result = await uploadToOneDrive({
24
+ buffer: Buffer.from("hello"),
25
+ filename: "a.txt",
26
+ tokenProvider,
27
+ fetchFn: withFetchPreconnect(fetchFn),
28
+ });
29
+
30
+ expect(fetchFn).toHaveBeenCalledWith(
31
+ "https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content",
32
+ expect.objectContaining({
33
+ method: "PUT",
34
+ headers: expect.objectContaining({
35
+ Authorization: "Bearer graph-token",
36
+ "Content-Type": "application/octet-stream",
37
+ "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
38
+ }),
39
+ }),
40
+ );
41
+ expect(result).toEqual({
42
+ id: "item-1",
43
+ webUrl: "https://example.com/1",
44
+ name: "a.txt",
45
+ });
46
+ });
47
+
48
+ it("uploads to SharePoint with the site drive path", async () => {
49
+ const fetchFn = vi.fn(
50
+ async () =>
51
+ new Response(
52
+ JSON.stringify({ id: "item-2", webUrl: "https://example.com/2", name: "b.txt" }),
53
+ {
54
+ status: 200,
55
+ headers: { "content-type": "application/json" },
56
+ },
57
+ ),
58
+ );
59
+
60
+ const result = await uploadToSharePoint({
61
+ buffer: Buffer.from("world"),
62
+ filename: "b.txt",
63
+ siteId: "site-123",
64
+ tokenProvider,
65
+ fetchFn: withFetchPreconnect(fetchFn),
66
+ });
67
+
68
+ expect(fetchFn).toHaveBeenCalledWith(
69
+ "https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/b.txt:/content",
70
+ expect.objectContaining({
71
+ method: "PUT",
72
+ headers: expect.objectContaining({
73
+ Authorization: "Bearer graph-token",
74
+ "Content-Type": "application/octet-stream",
75
+ "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
76
+ }),
77
+ }),
78
+ );
79
+ expect(result).toEqual({
80
+ id: "item-2",
81
+ webUrl: "https://example.com/2",
82
+ name: "b.txt",
83
+ });
84
+ });
85
+
86
+ it("rejects upload responses missing required fields", async () => {
87
+ const fetchFn = vi.fn(
88
+ async () =>
89
+ new Response(JSON.stringify({ id: "item-3" }), {
90
+ status: 200,
91
+ headers: { "content-type": "application/json" },
92
+ }),
93
+ );
94
+
95
+ await expect(
96
+ uploadToSharePoint({
97
+ buffer: Buffer.from("world"),
98
+ filename: "bad.txt",
99
+ siteId: "site-123",
100
+ tokenProvider,
101
+ fetchFn: withFetchPreconnect(fetchFn),
102
+ }),
103
+ ).rejects.toThrow("SharePoint upload response missing required fields");
104
+ });
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,12 +10,13 @@
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;
@@ -41,6 +42,7 @@ export async function uploadToOneDrive(params: {
41
42
  const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
42
43
  method: "PUT",
43
44
  headers: {
45
+ "User-Agent": buildUserAgent(),
44
46
  Authorization: `Bearer ${token}`,
45
47
  "Content-Type": params.contentType ?? "application/octet-stream",
46
48
  },
@@ -69,7 +71,7 @@ export async function uploadToOneDrive(params: {
69
71
  };
70
72
  }
71
73
 
72
- export interface OneDriveSharingLink {
74
+ interface OneDriveSharingLink {
73
75
  webUrl: string;
74
76
  }
75
77
 
@@ -77,7 +79,7 @@ export interface OneDriveSharingLink {
77
79
  * Create a sharing link for a OneDrive file.
78
80
  * The link allows organization members to view the file.
79
81
  */
80
- export async function createSharingLink(params: {
82
+ async function createSharingLink(params: {
81
83
  itemId: string;
82
84
  tokenProvider: MSTeamsAccessTokenProvider;
83
85
  /** Sharing scope: "organization" (default) or "anonymous" */
@@ -90,6 +92,7 @@ export async function createSharingLink(params: {
90
92
  const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
91
93
  method: "POST",
92
94
  headers: {
95
+ "User-Agent": buildUserAgent(),
93
96
  Authorization: `Bearer ${token}`,
94
97
  "Content-Type": "application/json",
95
98
  },
@@ -186,6 +189,7 @@ export async function uploadToSharePoint(params: {
186
189
  {
187
190
  method: "PUT",
188
191
  headers: {
192
+ "User-Agent": buildUserAgent(),
189
193
  Authorization: `Bearer ${token}`,
190
194
  "Content-Type": params.contentType ?? "application/octet-stream",
191
195
  },
@@ -215,7 +219,7 @@ export async function uploadToSharePoint(params: {
215
219
  };
216
220
  }
217
221
 
218
- export interface ChatMember {
222
+ interface ChatMember {
219
223
  aadObjectId: string;
220
224
  displayName?: string;
221
225
  }
@@ -251,7 +255,7 @@ export async function getDriveItemProperties(params: {
251
255
 
252
256
  const res = await fetchFn(
253
257
  `${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
254
- { headers: { Authorization: `Bearer ${token}` } },
258
+ { headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` } },
255
259
  );
256
260
 
257
261
  if (!res.ok) {
@@ -276,11 +280,85 @@ export async function getDriveItemProperties(params: {
276
280
  };
277
281
  }
278
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
+
279
357
  /**
280
358
  * Get members of a Teams chat for per-user sharing.
281
359
  * Used to create sharing links scoped to only the chat participants.
282
360
  */
283
- export async function getChatMembers(params: {
361
+ async function getChatMembers(params: {
284
362
  chatId: string;
285
363
  tokenProvider: MSTeamsAccessTokenProvider;
286
364
  fetchFn?: typeof fetch;
@@ -289,7 +367,7 @@ export async function getChatMembers(params: {
289
367
  const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
290
368
 
291
369
  const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
292
- headers: { Authorization: `Bearer ${token}` },
370
+ headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
293
371
  });
294
372
 
295
373
  if (!res.ok) {
@@ -317,7 +395,7 @@ export async function getChatMembers(params: {
317
395
  * For organization scope (default), uses v1.0 API.
318
396
  * For per-user scope, uses beta API with recipients.
319
397
  */
320
- export async function createSharePointSharingLink(params: {
398
+ async function createSharePointSharingLink(params: {
321
399
  siteId: string;
322
400
  itemId: string;
323
401
  tokenProvider: MSTeamsAccessTokenProvider;
@@ -349,6 +427,7 @@ export async function createSharePointSharingLink(params: {
349
427
  {
350
428
  method: "POST",
351
429
  headers: {
430
+ "User-Agent": buildUserAgent(),
352
431
  Authorization: `Bearer ${token}`,
353
432
  "Content-Type": "application/json",
354
433
  },