Skip to content

Commit 700269d

Browse files
committed
fix(msteams): bound remote media saves with header and idle timeouts
1 parent faf3dbd commit 700269d

7 files changed

Lines changed: 70 additions & 23 deletions

File tree

extensions/msteams/src/attachments/bot-framework.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type SavedCall = {
1414
direction: string;
1515
maxBytes: number;
1616
originalFilename?: string;
17+
readIdleTimeoutMs?: number;
1718
};
1819

1920
type MockRuntime = {
@@ -77,6 +78,7 @@ function installRuntime(): MockRuntime {
7778
subdir?: string;
7879
maxBytes?: number;
7980
originalFilename?: string;
81+
readIdleTimeoutMs?: number;
8082
},
8183
) => {
8284
const buffer = Buffer.from(await response.arrayBuffer());
@@ -86,6 +88,7 @@ function installRuntime(): MockRuntime {
8688
direction: options.subdir ?? "inbound",
8789
maxBytes: options.maxBytes ?? 0,
8890
originalFilename: options.originalFilename,
91+
readIdleTimeoutMs: options.readIdleTimeoutMs,
8992
});
9093
return { path: state.savePath, contentType: state.savedContentType };
9194
},
@@ -197,9 +200,12 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => {
197200
expect(media?.path).toBe(runtime.savePath);
198201
expect(media?.contentType).toBe(runtime.savedContentType);
199202
expect(runtime.saveCalls).toHaveLength(1);
200-
expect(expectDefined(runtime.saveCalls[0], "MSTeams save call").buffer.toString("utf-8")).toBe(
201-
"PDFBYTES",
202-
);
203+
const saveCall = expectDefined(runtime.saveCalls[0], "MSTeams save call");
204+
expect(saveCall.buffer.toString("utf-8")).toBe("PDFBYTES");
205+
// Regression guard: the attachmentView save call must forward the same
206+
// idle-read bound as the sibling Graph/remote-media save callers, or a
207+
// stalled Bot Framework attachment body can hang this path indefinitely.
208+
expect(saveCall.readIdleTimeoutMs).toBe(30_000);
203209
});
204210

205211
it("skips malformed attachment view content-length before saving media", async () => {

extensions/msteams/src/attachments/bot-framework.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type MSTeamsAttachmentDownloadLogger,
1616
type MSTeamsAttachmentFetchPolicy,
1717
type MSTeamsAttachmentResolveFn,
18+
MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
1819
resolveAttachmentFetchPolicy,
1920
safeFetchWithPolicy,
2021
} from "./shared.js";
@@ -201,6 +202,7 @@ async function saveBotFrameworkAttachmentView(params: {
201202
fallbackContentType: params.contentTypeHint,
202203
subdir: "inbound",
203204
originalFilename: params.preserveFilenames ? params.fileNameHint : undefined,
205+
readIdleTimeoutMs: MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
204206
});
205207
} catch (err) {
206208
params.logger?.warn?.("msteams botFramework attachmentView save failed", {

extensions/msteams/src/attachments/graph.test.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,32 +25,32 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
2525
fetchWithSsrFGuard: vi.fn(),
2626
}));
2727

28+
const saveResponseMediaMock = vi.hoisted(() =>
29+
vi.fn(
30+
async (
31+
response: Response,
32+
options?: { fallbackContentType?: string; maxBytes?: number; readIdleTimeoutMs?: number },
33+
) => {
34+
const length = Number(response.headers.get("content-length"));
35+
if (Number.isFinite(length) && options?.maxBytes !== undefined && length > options.maxBytes) {
36+
throw new Error("content length exceeds maxBytes");
37+
}
38+
return {
39+
path: "/tmp/saved.png",
40+
contentType: options?.fallbackContentType ?? "image/png",
41+
};
42+
},
43+
),
44+
);
45+
2846
vi.mock("../runtime.js", () => ({
2947
getMSTeamsRuntime: vi.fn(() => ({
3048
media: {
3149
detectMime: vi.fn(async () => "image/png"),
3250
},
3351
channel: {
3452
media: {
35-
saveResponseMedia: vi.fn(
36-
async (
37-
response: Response,
38-
options?: { fallbackContentType?: string; maxBytes?: number },
39-
) => {
40-
const length = Number(response.headers.get("content-length"));
41-
if (
42-
Number.isFinite(length) &&
43-
options?.maxBytes !== undefined &&
44-
length > options.maxBytes
45-
) {
46-
throw new Error("content length exceeds maxBytes");
47-
}
48-
return {
49-
path: "/tmp/saved.png",
50-
contentType: options?.fallbackContentType ?? "image/png",
51-
};
52-
},
53-
),
53+
saveResponseMedia: saveResponseMediaMock,
5454
saveMediaBuffer: vi.fn(async (_buf: Buffer, ct: string) => ({
5555
path: "/tmp/saved.png",
5656
contentType: ct ?? "image/png",
@@ -170,6 +170,14 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
170170
);
171171
expect(result.media.length).toBeGreaterThan(0);
172172
expect(result.hostedCount).toBe(1);
173+
// Regression guard: the hostedContents $value save must forward the same
174+
// idle-read bound as the sibling Bot Framework/remote-media save
175+
// callers, or a stalled Graph hosted-content body can hang this path
176+
// indefinitely.
177+
expect(saveResponseMediaMock).toHaveBeenCalledWith(
178+
expect.any(Response),
179+
expect.objectContaining({ readIdleTimeoutMs: 30_000 }),
180+
);
173181
});
174182

175183
it("skips hosted content when the list item has no id", async () => {

extensions/msteams/src/attachments/graph.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type MSTeamsAttachmentDownloadLogger,
2828
type MSTeamsAttachmentFetchPolicy,
2929
type MSTeamsAttachmentResolveFn,
30+
MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
3031
normalizeContentType,
3132
resolveMediaSsrfPolicy,
3233
resolveAttachmentFetchPolicy,
@@ -208,6 +209,7 @@ async function downloadGraphHostedContent(params: {
208209
maxBytes: params.maxBytes,
209210
fallbackContentType: item.contentType ?? undefined,
210211
subdir: "inbound",
212+
readIdleTimeoutMs: MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
211213
});
212214
out.push({
213215
path: saved.path,

extensions/msteams/src/attachments/remote-media.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
107107
const calledUrl = requireFirstFetchUrl(fetchImpl);
108108
expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
109109
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
110+
expect(saveResponseMediaMock).toHaveBeenCalledWith(
111+
expect.any(Response),
112+
expect.objectContaining({
113+
readIdleTimeoutMs: 30_000,
114+
maxBytes: 1024,
115+
}),
116+
);
110117
expect(result.path).toBe("/tmp/saved.png");
111118
});
112119

@@ -182,6 +189,13 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
182189
});
183190

184191
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
192+
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledWith(
193+
expect.objectContaining({
194+
responseHeaderTimeoutMs: 120_000,
195+
readIdleTimeoutMs: 30_000,
196+
maxBytes: 1024,
197+
}),
198+
);
185199
});
186200

187201
it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {

extensions/msteams/src/attachments/remote-media.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import { saveResponseMedia, type SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
33
import type { SsrFPolicy } from "../../runtime-api.js";
44
import { getMSTeamsRuntime } from "../runtime.js";
5-
import { inferPlaceholder } from "./shared.js";
5+
import {
6+
inferPlaceholder,
7+
MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
8+
MSTEAMS_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
9+
} from "./shared.js";
610
import type { MSTeamsInboundMedia } from "./types.js";
711

812
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -26,6 +30,7 @@ async function saveRemoteMediaDirect(params: {
2630
sourceUrl: params.url,
2731
filePathHint: params.filePathHint,
2832
maxBytes: params.maxBytes,
33+
readIdleTimeoutMs: MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
2934
fallbackContentType: params.contentTypeHint,
3035
originalFilename: params.originalFilename,
3136
});
@@ -69,6 +74,8 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
6974
filePathHint: params.filePathHint,
7075
maxBytes: params.maxBytes,
7176
ssrfPolicy: params.ssrfPolicy,
77+
responseHeaderTimeoutMs: MSTEAMS_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
78+
readIdleTimeoutMs: MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS,
7279
fallbackContentType: params.contentTypeHint,
7380
originalFilename,
7481
});

extensions/msteams/src/attachments/shared.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ import { MSTEAMS_REQUEST_TIMEOUT_MS } from "../request-timeout.js";
1818
import { responseWithRelease } from "../response-with-release.js";
1919
import type { MSTeamsAttachmentLike } from "./types.js";
2020

21+
// Align with Mattermost/Zalo/Tlon media downloads: header wait and body idle
22+
// are separate failure modes. Every production caller that hands a response
23+
// to `saveResponseMedia`/`saveRemoteMedia` must forward this idle bound, or a
24+
// stalled chunk on that specific caller can hang the inbound media save
25+
// forever even though the sibling callers are already bounded.
26+
export const MSTEAMS_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 120_000;
27+
export const MSTEAMS_MEDIA_READ_IDLE_TIMEOUT_MS = 30_000;
28+
2129
type InlineImageCandidate =
2230
| {
2331
kind: "data";

0 commit comments

Comments
 (0)