fix(msteams): display decoded attachment filenames (#115127)

This commit is contained in:
Peter Steinberger
2026-07-28 06:09:34 -04:00
committed by GitHub
parent 28630a9a65
commit ecd22f5e2d
3 changed files with 80 additions and 2 deletions

View File

@@ -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");

View File

@@ -45,8 +45,24 @@ export async function extractFilename(url: string): Promise<string> {
// 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;
}

View File

@@ -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 <at>@User</at>" }, baseRef);
const entities = activity.entities as Array<Record<string, unknown>>;