Skip to content

Commit ab9be8d

Browse files
fix(msteams): fetch DM media via Bot Framework path for a: conversation IDs (openclaw#62219) (openclaw#63951)
* fix(msteams): fetch DM media via Bot Framework path for a: conversation IDs (openclaw#62219) * fix(msteams): log skipped BF DM media fetches --------- Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
1 parent 11f924b commit ab9be8d

7 files changed

Lines changed: 861 additions & 2 deletions

File tree

extensions/msteams/src/attachments.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
export {
2+
downloadMSTeamsBotFrameworkAttachment,
3+
downloadMSTeamsBotFrameworkAttachments,
4+
isBotFrameworkPersonalChatId,
5+
} from "./attachments/bot-framework.js";
16
export {
27
downloadMSTeamsAttachments,
38
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
@@ -6,6 +11,7 @@ export {
611
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
712
export {
813
buildMSTeamsAttachmentPlaceholder,
14+
extractMSTeamsHtmlAttachmentIds,
915
summarizeMSTeamsHtmlAttachments,
1016
} from "./attachments/html.js";
1117
export { buildMSTeamsMediaPayload } from "./attachments/payload.js";
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { setMSTeamsRuntime } from "../runtime.js";
3+
import {
4+
downloadMSTeamsBotFrameworkAttachment,
5+
downloadMSTeamsBotFrameworkAttachments,
6+
isBotFrameworkPersonalChatId,
7+
} from "./bot-framework.js";
8+
import type { MSTeamsAccessTokenProvider } from "./types.js";
9+
10+
type SavedCall = {
11+
buffer: Buffer;
12+
contentType?: string;
13+
direction: string;
14+
maxBytes: number;
15+
originalFilename?: string;
16+
};
17+
18+
type MockRuntime = {
19+
saveCalls: SavedCall[];
20+
savePath: string;
21+
savedContentType: string;
22+
};
23+
24+
function installRuntime(): MockRuntime {
25+
const state: MockRuntime = {
26+
saveCalls: [],
27+
savePath: "/tmp/bf-attachment.bin",
28+
savedContentType: "application/pdf",
29+
};
30+
setMSTeamsRuntime({
31+
media: {
32+
detectMime: async ({ headerMime }: { headerMime?: string }) =>
33+
headerMime ?? "application/pdf",
34+
},
35+
channel: {
36+
media: {
37+
saveMediaBuffer: async (
38+
buffer: Buffer,
39+
contentType: string | undefined,
40+
direction: string,
41+
maxBytes: number,
42+
originalFilename?: string,
43+
) => {
44+
state.saveCalls.push({
45+
buffer,
46+
contentType,
47+
direction,
48+
maxBytes,
49+
originalFilename,
50+
});
51+
return { path: state.savePath, contentType: state.savedContentType };
52+
},
53+
fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
54+
},
55+
},
56+
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
57+
return state;
58+
}
59+
60+
function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
61+
return (async (input: RequestInfo | URL) => {
62+
const url =
63+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
64+
const entry = entries.find((e) => e.match.test(url));
65+
if (!entry) {
66+
return new Response("not found", { status: 404 });
67+
}
68+
return entry.response.clone();
69+
}) as typeof fetch;
70+
}
71+
72+
function buildTokenProvider(): MSTeamsAccessTokenProvider {
73+
return {
74+
getAccessToken: vi.fn(async (scope: string) => {
75+
if (scope.includes("botframework.com")) {
76+
return "bf-token";
77+
}
78+
return "graph-token";
79+
}),
80+
};
81+
}
82+
83+
describe("isBotFrameworkPersonalChatId", () => {
84+
it("detects a: prefix personal chat IDs", () => {
85+
expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true);
86+
});
87+
88+
it("detects 8:orgid: prefix chat IDs", () => {
89+
expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true);
90+
});
91+
92+
it("returns false for Graph-compatible 19: thread IDs", () => {
93+
expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false);
94+
});
95+
96+
it("returns false for synthetic DM Graph IDs", () => {
97+
expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false);
98+
});
99+
100+
it("returns false for null/undefined/empty", () => {
101+
expect(isBotFrameworkPersonalChatId(null)).toBe(false);
102+
expect(isBotFrameworkPersonalChatId(undefined)).toBe(false);
103+
expect(isBotFrameworkPersonalChatId("")).toBe(false);
104+
});
105+
});
106+
107+
describe("downloadMSTeamsBotFrameworkAttachment", () => {
108+
let runtime: MockRuntime;
109+
beforeEach(() => {
110+
runtime = installRuntime();
111+
});
112+
113+
it("fetches attachment info then view and saves media", async () => {
114+
const info = {
115+
name: "report.pdf",
116+
type: "application/pdf",
117+
views: [{ viewId: "original", size: 1024 }],
118+
};
119+
const fileBytes = Buffer.from("PDFBYTES", "utf-8");
120+
const fetchFn = createMockFetch([
121+
{
122+
match: /\/v3\/attachments\/att-1$/,
123+
response: new Response(JSON.stringify(info), {
124+
status: 200,
125+
headers: { "content-type": "application/json" },
126+
}),
127+
},
128+
{
129+
match: /\/v3\/attachments\/att-1\/views\/original$/,
130+
response: new Response(fileBytes, {
131+
status: 200,
132+
headers: { "content-length": String(fileBytes.byteLength) },
133+
}),
134+
},
135+
]);
136+
137+
const media = await downloadMSTeamsBotFrameworkAttachment({
138+
serviceUrl: "https://smba.trafficmanager.net/amer/",
139+
attachmentId: "att-1",
140+
tokenProvider: buildTokenProvider(),
141+
maxBytes: 10_000_000,
142+
fetchFn,
143+
});
144+
145+
expect(media).toBeDefined();
146+
expect(media?.path).toBe(runtime.savePath);
147+
expect(runtime.saveCalls).toHaveLength(1);
148+
expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
149+
});
150+
151+
it("returns undefined when attachment info fetch fails", async () => {
152+
const fetchFn = createMockFetch([
153+
{
154+
match: /\/v3\/attachments\//,
155+
response: new Response("unauthorized", { status: 401 }),
156+
},
157+
]);
158+
159+
const media = await downloadMSTeamsBotFrameworkAttachment({
160+
serviceUrl: "https://smba.trafficmanager.net/amer",
161+
attachmentId: "att-1",
162+
tokenProvider: buildTokenProvider(),
163+
maxBytes: 10_000_000,
164+
fetchFn,
165+
});
166+
167+
expect(media).toBeUndefined();
168+
expect(runtime.saveCalls).toHaveLength(0);
169+
});
170+
171+
it("skips when attachment view size exceeds maxBytes", async () => {
172+
const info = {
173+
name: "huge.bin",
174+
type: "application/octet-stream",
175+
views: [{ viewId: "original", size: 50_000_000 }],
176+
};
177+
const fetchFn = createMockFetch([
178+
{
179+
match: /\/v3\/attachments\/big-1$/,
180+
response: new Response(JSON.stringify(info), { status: 200 }),
181+
},
182+
]);
183+
184+
const media = await downloadMSTeamsBotFrameworkAttachment({
185+
serviceUrl: "https://smba.trafficmanager.net/amer",
186+
attachmentId: "big-1",
187+
tokenProvider: buildTokenProvider(),
188+
maxBytes: 10_000_000,
189+
fetchFn,
190+
});
191+
192+
expect(media).toBeUndefined();
193+
expect(runtime.saveCalls).toHaveLength(0);
194+
});
195+
196+
it("returns undefined when no views are returned", async () => {
197+
const info = { name: "nothing", type: "application/pdf", views: [] };
198+
const fetchFn = createMockFetch([
199+
{
200+
match: /\/v3\/attachments\/empty-1$/,
201+
response: new Response(JSON.stringify(info), { status: 200 }),
202+
},
203+
]);
204+
205+
const media = await downloadMSTeamsBotFrameworkAttachment({
206+
serviceUrl: "https://smba.trafficmanager.net/amer",
207+
attachmentId: "empty-1",
208+
tokenProvider: buildTokenProvider(),
209+
maxBytes: 10_000_000,
210+
fetchFn,
211+
});
212+
213+
expect(media).toBeUndefined();
214+
});
215+
216+
it("returns undefined without a tokenProvider", async () => {
217+
const fetchFn = vi.fn();
218+
const media = await downloadMSTeamsBotFrameworkAttachment({
219+
serviceUrl: "https://smba.trafficmanager.net/amer",
220+
attachmentId: "att-1",
221+
tokenProvider: undefined,
222+
maxBytes: 10_000_000,
223+
fetchFn: fetchFn as unknown as typeof fetch,
224+
});
225+
expect(media).toBeUndefined();
226+
expect(fetchFn).not.toHaveBeenCalled();
227+
});
228+
});
229+
230+
describe("downloadMSTeamsBotFrameworkAttachments", () => {
231+
beforeEach(() => {
232+
installRuntime();
233+
});
234+
235+
it("fetches every unique attachment id and returns combined media", async () => {
236+
const mkInfo = (viewId: string) => ({
237+
name: `file-${viewId}.pdf`,
238+
type: "application/pdf",
239+
views: [{ viewId, size: 10 }],
240+
});
241+
const fetchFn = createMockFetch([
242+
{
243+
match: /\/v3\/attachments\/att-1$/,
244+
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
245+
},
246+
{
247+
match: /\/v3\/attachments\/att-1\/views\/original$/,
248+
response: new Response(Buffer.from("A"), { status: 200 }),
249+
},
250+
{
251+
match: /\/v3\/attachments\/att-2$/,
252+
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
253+
},
254+
{
255+
match: /\/v3\/attachments\/att-2\/views\/original$/,
256+
response: new Response(Buffer.from("B"), { status: 200 }),
257+
},
258+
]);
259+
260+
const result = await downloadMSTeamsBotFrameworkAttachments({
261+
serviceUrl: "https://smba.trafficmanager.net/amer",
262+
attachmentIds: ["att-1", "att-2", "att-1"],
263+
tokenProvider: buildTokenProvider(),
264+
maxBytes: 10_000,
265+
fetchFn,
266+
});
267+
268+
expect(result.media).toHaveLength(2);
269+
expect(result.attachmentCount).toBe(2);
270+
});
271+
272+
it("returns empty when no valid attachment ids", async () => {
273+
const result = await downloadMSTeamsBotFrameworkAttachments({
274+
serviceUrl: "https://smba.trafficmanager.net/amer",
275+
attachmentIds: [],
276+
tokenProvider: buildTokenProvider(),
277+
maxBytes: 10_000,
278+
fetchFn: vi.fn() as unknown as typeof fetch,
279+
});
280+
expect(result.media).toEqual([]);
281+
});
282+
283+
it("continues past a per-attachment failure", async () => {
284+
const fetchFn = createMockFetch([
285+
{
286+
match: /\/v3\/attachments\/ok$/,
287+
response: new Response(
288+
JSON.stringify({
289+
name: "ok.pdf",
290+
type: "application/pdf",
291+
views: [{ viewId: "original", size: 1 }],
292+
}),
293+
{ status: 200 },
294+
),
295+
},
296+
{
297+
match: /\/v3\/attachments\/ok\/views\/original$/,
298+
response: new Response(Buffer.from("OK"), { status: 200 }),
299+
},
300+
{
301+
match: /\/v3\/attachments\/bad$/,
302+
response: new Response("nope", { status: 500 }),
303+
},
304+
]);
305+
306+
const result = await downloadMSTeamsBotFrameworkAttachments({
307+
serviceUrl: "https://smba.trafficmanager.net/amer",
308+
attachmentIds: ["bad", "ok"],
309+
tokenProvider: buildTokenProvider(),
310+
maxBytes: 10_000,
311+
fetchFn,
312+
});
313+
314+
expect(result.media).toHaveLength(1);
315+
expect(result.attachmentCount).toBe(2);
316+
});
317+
});

0 commit comments

Comments
 (0)