import { createCustomOSSUploader } from '@bty/uploader' import type { STSTokenResponse } from '@/package/uploader/src/index' import { AESUtils } from '@/package/utils/crypto' import { tryParseToObject } from '@/package/utils/parse' import { isSupportedFileType, getMimeByExtension } from './file-types' import { HTTPClient } from '@/http' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB export interface BotAttachment { url: string fileName: string mimeType?: string size?: number } interface UploadResult { fileId: string fileName: string } // Reuse a single HTTPClient for Nova API calls const novaClient = new HTTPClient({ baseURL: process.env.NOVA_BASE_URL, headers: { 'Content-Type': 'application/json', 'Tenant-Id': process.env.NOVA_TENANT_ID!, 'Authorization': process.env.NOVA_ACCESS_KEY!, }, }) async function getServerSTSToken(): Promise { const env = process.env.NODE_ENV const res = await novaClient.get('/v1/oss/upload_sts') const dataToDecrypt = typeof res === 'string' ? res : res?.data if (typeof dataToDecrypt !== 'string') { throw new Error('Invalid encrypted STS payload from Nova API') } const decryptedData = new AESUtils(env).decryptAES_CBC(dataToDecrypt) return tryParseToObject(decryptedData) as STSTokenResponse } async function downloadFile( url: string, headers?: Record, ): Promise<{ buffer: Buffer; size: number }> { const response = await fetch(url, { headers }) if (!response.ok) { throw new Error(`Download failed: ${response.status} ${response.statusText}`) } const arrayBuffer = await response.arrayBuffer() const buffer = Buffer.from(arrayBuffer) return { buffer, size: buffer.length } } async function uploadSingleAttachment( attachment: BotAttachment, headers?: Record, ): Promise { // 1. Validate file type if (!isSupportedFileType(attachment.fileName)) { console.warn(`[File Uploader] Unsupported file type: ${attachment.fileName}`) return null } // 2. Pre-check size if available if (attachment.size && attachment.size > MAX_FILE_SIZE) { console.warn(`[File Uploader] File too large (${attachment.size} bytes): ${attachment.fileName}`) return null } // 3. Download file const { buffer, size } = await downloadFile(attachment.url, headers) if (size > MAX_FILE_SIZE) { console.warn(`[File Uploader] Downloaded file too large (${size} bytes): ${attachment.fileName}`) return null } // 4. Determine MIME type const mimeType = attachment.mimeType || getMimeByExtension(attachment.fileName) || 'application/octet-stream' // 5. Construct OSS path (same pattern as frontend useFileUploader) const uuid = crypto.randomUUID() const timestamp = Date.now() const filePath = `super_agent/user_upload_file/${uuid}/_${timestamp}_${attachment.fileName}` // 6. Upload to OSS const ossUploader = createCustomOSSUploader(getServerSTSToken) // Note: We use `upload` (which maps to `put` in ali-oss) instead of `multipartUpload` // and pass the raw `Buffer` directly. In Node.js, ali-oss natively supports Buffer // for `put`, whereas `multipartUpload` expects a file path or a browser File object // (which causes "FileReader is not defined" when polyfilled). await ossUploader.upload({ filePath, file: buffer as unknown as File, options: { headers: { 'Content-Type': mimeType, 'Content-Disposition': 'inline', }, }, }) // 7. Create file record via Nova API const lastDotIndex = attachment.fileName.lastIndexOf('.') const splitName = lastDotIndex !== -1 ? [attachment.fileName.substring(0, lastDotIndex), attachment.fileName.substring(lastDotIndex + 1)] : [attachment.fileName] const safeName = `${splitName[0]}-${Math.random().toString(36).substring(2, 5)}${splitName.length > 1 ? `.${splitName[1]}` : ''}` const res = await novaClient.post<{ file_upload_record_id: string }>( '/v1/super_agent/file_upload_record/create', { file_url: filePath, file_type: mimeType, file_name: safeName, file_byte_size: size, conversation_id: uuid, }, ) console.log(`[File Uploader] Uploaded: ${safeName} → ${res.file_upload_record_id}`) return { fileId: res.file_upload_record_id, fileName: safeName, } } /** * Upload multiple bot attachments to Nova OSS and create file records. * Returns array of upload_file_ids (only successful uploads). * Never throws — individual failures are logged and skipped. */ export async function uploadBotAttachments( attachments: BotAttachment[], headers?: Record, ): Promise { if (attachments.length === 0) return [] const results = await Promise.allSettled( attachments.map(att => uploadSingleAttachment(att, headers)), ) const fileIds: string[] = [] for (const result of results) { if (result.status === 'fulfilled' && result.value) { fileIds.push(result.value.fileId) } else if (result.status === 'rejected') { console.error(`[File Uploader] Attachment upload failed:`, result.reason) } } return fileIds }