fix: msteams attachments + plugin prompt hints
Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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("---");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user