diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 04ffb58..7521fde 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -565,9 +565,139 @@ function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' { return 'text'; } +/** + * پسوندهایی که سرویس آپلود montifyshop (مثلاً /upload/crm-montact) می‌پذیرد. + * بقیه باید تبدیل یا camouflage شوند وگرنه ۴۰۰ Invalid file type می‌دهد. + */ +const UPLOAD_DIRECT_EXT = new Set([ + 'jpg', + 'jpeg', + 'png', + 'webp', + 'pdf', + 'mp3', + 'mp4', + 'webm', + 'ogg', + 'm4a', +]); + +function fileExt(name: string): string { + const m = /\.([a-z0-9]+)$/i.exec(String(name || '').trim()); + return m?.[1]?.toLowerCase() ?? ''; +} + +function canUploadDirectly(file: File): boolean { + const ext = fileExt(file.name); + if (ext && UPLOAD_DIRECT_EXT.has(ext)) return true; + const mime = (file.type || '').toLowerCase().split(';')[0]; + return ( + mime === 'image/jpeg' || + mime === 'image/png' || + mime === 'image/webp' || + mime === 'application/pdf' || + mime === 'audio/mpeg' || + mime === 'audio/webm' || + mime === 'audio/ogg' || + mime === 'audio/mp4' || + mime === 'video/mp4' || + mime === 'video/webm' || + mime === 'video/ogg' + ); +} + +async function rasterizeImageToJpeg(file: File): Promise { + try { + const bitmap = await createImageBitmap(file); + const ratio = Math.min( + IMAGE_MAX_EDGE / bitmap.width, + IMAGE_MAX_EDGE / bitmap.height, + 1, + ); + const width = Math.max(1, Math.round(bitmap.width * ratio)); + const height = Math.max(1, Math.round(bitmap.height * ratio)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close(); + return null; + } + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + const blob = await new Promise((resolve) => { + canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9); + }); + if (!blob) return null; + const base = file.name.replace(/\.[^/.]+$/, '') || 'image'; + return new File([blob], `${base}.jpg`, { + type: 'image/jpeg', + lastModified: Date.now(), + }); + } catch { + return null; + } +} + +/** + * سرویس آپلود whitelist سختگیرانه دارد (مثلاً docx/txt/gif/heic → 400). + * تصویرهای ناسازگار را به JPEG تبدیل می‌کنیم؛ بقیه را مثل ویدیو camouflage می‌کنیم + * (آپلود به‌صورت .pdf) و نام اصلی را برای UI/دانلود نگه می‌داریم. + */ +async function prepareFileForUploadService( + file: File, + mediaType: Exclude, +): Promise<{ uploadFile: File; displayName: string }> { + const displayName = file.name?.trim() || `file-${Date.now()}`; + const mime = (file.type || '').toLowerCase().split(';')[0]; + const ext = fileExt(displayName); + + if (mediaType === 'voice') { + if (canUploadDirectly(file)) return { uploadFile: file, displayName }; + throw new Error( + 'این فرمت صوت پشتیبانی نمی‌شود. webm، mp3، ogg یا m4a بفرستید.', + ); + } + + if (mediaType === 'image') { + const needsRaster = + mime === 'image/gif' || + mime === 'image/bmp' || + mime === 'image/heic' || + mime === 'image/heif' || + mime === 'image/svg+xml' || + ['gif', 'bmp', 'heic', 'heif', 'svg', 'tif', 'tiff'].includes(ext); + if (needsRaster || !canUploadDirectly(file)) { + const converted = await rasterizeImageToJpeg(file); + if (converted) return { uploadFile: converted, displayName: converted.name }; + } + if (canUploadDirectly(file)) return { uploadFile: file, displayName }; + } + + if (canUploadDirectly(file)) { + return { uploadFile: file, displayName }; + } + + // اسناد/آرشیو و… → آپلود با پسوند pdf (whitelist سرویس)؛ نام اصلی برای دانلود می‌ماند + const uploadFile = new File([file], `doc-${Date.now()}.pdf`, { + type: 'application/pdf', + lastModified: file.lastModified, + }); + return { uploadFile, displayName }; +} + async function compressImageIfNeeded(file: File): Promise { if (!file.type.startsWith('image/')) return file; - if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file; + // gif/svg/heic را prepareFileForUploadService قبلاً به JPEG تبدیل کرده + if ( + file.type === 'image/gif' || + file.type === 'image/svg+xml' || + file.type === 'image/heic' || + file.type === 'image/heif' + ) { + return file; + } if (file.size < 350_000) return file; try { @@ -1134,6 +1264,9 @@ function formatUploadFailure(status: number, bodyText: string): string { const json = JSON.parse(raw) as Record; const err = String(json.error ?? json.message ?? '').trim(); const details = String(json.details ?? '').trim(); + if (/invalid file type/i.test(err)) { + return 'این نوع فایل توسط سرویس آپلود رد شد. تصویر، PDF، صوت یا ویدیو را دوباره امتحان کنید.'; + } if (err && details) return `آپلود ناموفق بود: ${err} (${details})`; if (err) return `آپلود ناموفق بود: ${err}`; } catch { @@ -1141,6 +1274,9 @@ function formatUploadFailure(status: number, bodyText: string): string { if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`; } } + if (status === 400) { + return 'آپلود رد شد (۴۰۰). نوع یا نام فایل با سرویس آپلود سازگار نیست.'; + } if (status === 500) { return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیره‌سازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.'; } @@ -1536,7 +1672,7 @@ export function useOrgChat() { try { setProgress(5); - let fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file; + let fileForUpload = file; let outboundFileName = file.name; if (type === 'video') { // Camouflage as image path so external chat WS delivers the message. @@ -1545,8 +1681,19 @@ export function useOrgChat() { looksLikeVideo(file.name) ? file.name : `${file.name.replace(/\.[^.]+$/, '') || 'video'}.mp4`; outboundFileName = original; fileForUpload = new File([file], `vid-${Date.now()}.jpg`, { - type: file.type || 'video/mp4', + type: 'image/jpeg', }); + } else { + const prepared = await prepareFileForUploadService(file, type); + fileForUpload = prepared.uploadFile; + outboundFileName = prepared.displayName; + if (type === 'image') { + fileForUpload = await compressImageIfNeeded(fileForUpload); + // اگر فشرده‌سازی نام را عوض کرد (مثلاً webp)، همان را برای نمایش نگه دار + if (fileForUpload.name && fileForUpload.name !== prepared.uploadFile.name) { + outboundFileName = fileForUpload.name; + } + } } setProgress(12); const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => { diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index b371436..88a11e6 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -2332,7 +2332,7 @@ onUnmounted(() => { type="file" class="sr-only" multiple - accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt" + accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.txt,.csv" @change="onFilePicked" />