From ecd22f5e2d7014de911ca55f366212eb37a8a356 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 06:09:34 -0400 Subject: [PATCH] fix(msteams): display decoded attachment filenames (#115127) --- extensions/msteams/src/media-helpers.test.ts | 11 +++++ extensions/msteams/src/media-helpers.ts | 20 +++++++- extensions/msteams/src/messenger.test.ts | 51 ++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/extensions/msteams/src/media-helpers.test.ts b/extensions/msteams/src/media-helpers.test.ts index dffb932560f7..bb22215e0159 100644 --- a/extensions/msteams/src/media-helpers.test.ts +++ b/extensions/msteams/src/media-helpers.test.ts @@ -102,6 +102,17 @@ describe("msteams media-helpers", () => { expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png"); }); + it.each([ + ["https://example.com/files/My%20report.pdf", "My report.pdf"], + ["https://example.com/files/r%C3%A9sum%C3%A9.pdf", "résumé.pdf"], + ["https://example.com/files/100%25.png", "100%.png"], + ["https://example.com/files/bad%ZZ.pdf", "bad%ZZ.pdf"], + ["https://example.com/files/folder%2Fsecret.png", "folder%2Fsecret.png"], + ["https://example.com/files/folder%5Csecret.png", "folder%5Csecret.png"], + ])("preserves the safe display filename from %s", async (url, expected) => { + expect(await extractFilename(url)).toBe(expected); + }); + it("handles URLs without extension by deriving from MIME", async () => { // Now defaults to application/octet-stream → .bin fallback expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin"); diff --git a/extensions/msteams/src/media-helpers.ts b/extensions/msteams/src/media-helpers.ts index 4f9433c1e5c1..484cb6c4e9cb 100644 --- a/extensions/msteams/src/media-helpers.ts +++ b/extensions/msteams/src/media-helpers.ts @@ -45,8 +45,24 @@ export async function extractFilename(url: string): Promise { // Try to extract from URL pathname try { const pathname = new URL(url).pathname; - const basename = path.basename(pathname); - const existingExt = getFileExtension(pathname); + let basename = path.basename(pathname); + if (basename.includes("%")) { + try { + const decodedBasename = decodeURIComponent(basename); + // Attachment names are display values; never turn escaped delimiters + // into a different filesystem or URL path. + if ( + !decodedBasename.includes("/") && + !decodedBasename.includes("\\") && + !decodedBasename.includes("\0") + ) { + basename = decodedBasename; + } + } catch { + // Keep malformed percent escapes as the original literal filename. + } + } + const existingExt = getFileExtension(basename); if (basename && existingExt) { return basename; } diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 8702badcafc7..63ab4c2f1371 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { withServer } from "openclaw/plugin-sdk/test-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StoredConversationReference } from "./conversation-store.js"; const graphUploadMockState = vi.hoisted(() => ({ @@ -762,6 +763,56 @@ describe("msteams messenger", () => { ]); }); + it("sends decoded attachment filenames over the Bot Framework HTTP transport", async () => { + const receivedAttachments: Array<{ name: string; contentUrl: string }> = []; + + await withServer( + (request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + const activity = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + attachments?: Array<{ name: string; contentUrl: string }>; + }; + receivedAttachments.push( + ...(activity.attachments ?? []).map(({ name, contentUrl }) => ({ name, contentUrl })), + ); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ id: `message-${receivedAttachments.length}` })); + }); + }, + async (baseUrl) => { + const app = createMockApp({ + createFn: async (activity) => { + const response = await fetch(`${baseUrl}/v3/conversations/test/activities`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(activity), + }); + return await response.json(); + }, + }); + const encodedNames = ["My%20report.pdf", "r%C3%A9sum%C3%A9.pdf", "100%25.png"]; + + await sendMSTeamsMessages({ + replyStyle: "top-level", + app, + appId: "app123", + conversationRef: baseRef, + messages: encodedNames.map((name) => ({ + mediaUrl: `${baseUrl}/files/${name}`, + })), + }); + + expect(receivedAttachments).toEqual([ + { name: "My report.pdf", contentUrl: `${baseUrl}/files/My%20report.pdf` }, + { name: "résumé.pdf", contentUrl: `${baseUrl}/files/r%C3%A9sum%C3%A9.pdf` }, + { name: "100%.png", contentUrl: `${baseUrl}/files/100%25.png` }, + ]); + }, + ); + }); + it("preserves mention entities alongside AI entity", async () => { const activity = await buildActivity({ text: "hi @User" }, baseRef); const entities = activity.entities as Array>;