fix: msteams attachments + plugin prompt hints

Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-22 03:27:26 +00:00
parent 5fe8c4ab8c
commit 0f7f7bb95f
50 changed files with 2739 additions and 174 deletions

View File

@@ -32,9 +32,11 @@ const EXT_BY_MIME: Record<string, string> = {
"text/markdown": ".md",
};
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
);
const MIME_BY_EXT: Record<string, string> = {
...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
// Additional extension aliases
".jpeg": "image/jpeg",
};
const AUDIO_FILE_EXTENSIONS = new Set([
".aac",

View File

@@ -161,4 +161,114 @@ describe("media store", () => {
expect(path.extname(saved.path)).toBe(".xlsx");
});
});
describe("extractOriginalFilename", () => {
it("extracts original filename from embedded pattern", async () => {
await withTempStore(async (store) => {
// Pattern: {original}---{uuid}.{ext}
const filename = "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/path/to/${filename}`);
expect(result).toBe("report.pdf");
});
});
it("handles uppercase UUID pattern", async () => {
await withTempStore(async (store) => {
const filename = "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx";
const result = store.extractOriginalFilename(`/media/inbound/${filename}`);
expect(result).toBe("Document.docx");
});
});
it("falls back to basename for non-matching patterns", async () => {
await withTempStore(async (store) => {
// UUID-only filename (legacy format)
const uuidOnly = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
expect(store.extractOriginalFilename(`/path/${uuidOnly}`)).toBe(uuidOnly);
// Regular filename without embedded pattern
expect(store.extractOriginalFilename("/path/to/regular.txt")).toBe("regular.txt");
// Filename with --- but invalid UUID part
expect(store.extractOriginalFilename("/path/to/foo---bar.txt")).toBe("foo---bar.txt");
});
});
it("preserves original name with special characters", async () => {
await withTempStore(async (store) => {
const filename = "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/media/${filename}`);
expect(result).toBe("报告_2024.pdf");
});
});
});
describe("saveMediaBuffer with originalFilename", () => {
it("embeds original filename in stored path when provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test content");
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"report.txt",
);
// Should contain the original name and a UUID pattern
expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.txt$/);
expect(saved.path).toContain("report---");
// Should be able to extract original name
const extracted = store.extractOriginalFilename(saved.path);
expect(extracted).toBe("report.txt");
});
});
it("sanitizes unsafe characters in original filename", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
// Filename with unsafe chars: < > : " / \ | ? *
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"my<file>:test.txt",
);
// Unsafe chars should be replaced with underscores
expect(saved.id).toMatch(/^my_file_test---[a-f0-9-]{36}\.txt$/);
});
});
it("truncates long original filenames", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const longName = "a".repeat(100) + ".txt";
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
longName,
);
// Original name should be truncated to 60 chars
const baseName = path.parse(saved.id).name.split("---")[0];
expect(baseName.length).toBeLessThanOrEqual(60);
});
});
it("falls back to UUID-only when originalFilename not provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const saved = await store.saveMediaBuffer(buf, "text/plain", "inbound");
// Should be UUID-only pattern (legacy behavior)
expect(saved.id).toMatch(/^[a-f0-9-]{36}\.txt$/);
expect(saved.id).not.toContain("---");
});
});
});
});

View File

@@ -11,6 +11,43 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
/**
* Sanitize a filename for cross-platform safety.
* Removes chars unsafe on Windows/SharePoint/all platforms.
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/
function sanitizeFilename(name: string): string {
// Remove: < > : " / \ | ? * and control chars (U+0000-U+001F)
// oxlint-disable-next-line no-control-regex -- Intentionally matching control chars
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g;
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore
// Collapse multiple underscores, trim leading/trailing, limit length
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
}
/**
* Extract original filename from path if it matches the embedded format.
* Pattern: {original}---{uuid}.{ext} → returns "{original}.{ext}"
* Falls back to basename if no pattern match, or "file.bin" if empty.
*/
export function extractOriginalFilename(filePath: string): string {
const basename = path.basename(filePath);
if (!basename) return "file.bin"; // Fallback for empty input
const ext = path.extname(basename);
const nameWithoutExt = path.basename(basename, ext);
// Check for ---{uuid} pattern (36 chars: 8-4-4-4-12 with hyphens)
const match = nameWithoutExt.match(
/^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
);
if (match?.[1]) {
return `${match[1]}${ext}`;
}
return basename; // Fallback: use as-is
}
export function getMediaDir() {
return resolveMediaDir();
}
@@ -152,17 +189,29 @@ export async function saveMediaBuffer(
contentType?: string,
subdir = "inbound",
maxBytes = MAX_BYTES,
originalFilename?: string,
): Promise<SavedMedia> {
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID();
const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType });
const ext = headerExt ?? extensionForMime(mime);
const id = ext ? `${baseId}${ext}` : baseId;
const ext = headerExt ?? extensionForMime(mime) ?? "";
let id: string;
if (originalFilename) {
// Embed original name: {sanitized}---{uuid}.ext
const base = path.parse(originalFilename).name;
const sanitized = sanitizeFilename(base);
id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`;
} else {
// Legacy: just UUID
id = ext ? `${uuid}${ext}` : uuid;
}
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType: mime };