-
Notifications
You must be signed in to change notification settings - Fork 13.1k
feat: Send multiple files into a single message #32703
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
9a39914
20962c4
74cfee1
6508ebe
1a1788e
64a27cb
b5e1ba5
f108fd4
ae76481
18d0e87
e984ad2
c251e17
a96fa61
3f480b0
fa2456a
365cef8
bd90357
81dad69
8402305
503218e
09e3bb7
f6162af
b739659
a1f7848
74ed1f2
cbb41bb
c93da30
cfc177c
2e344a1
7f0b615
671fd31
8379c94
9a97c3d
62fbc56
f88cd26
f39042b
5a9f646
771314e
755c08b
f2bd552
39ff2a3
23eb009
e786f70
f4b60d5
1c0ddde
f5213b0
6f398b4
cdc0ff5
a1faa22
326a197
c078083
0e4d827
60a80f9
7ac5841
9f74219
9afeee6
97f743b
68f45dc
d7b92cc
21005c0
a0f6c83
de319d8
3ad5e63
72bc00d
fbd30c9
fe2b428
b3db9fc
3aa8c3d
2eb9b64
11aac8e
9ff1b9f
2370b14
47b592e
9722194
84bfcae
335e886
a5598f7
7ea18d7
822e2c5
d1b6922
b13bdd2
a8cff84
0fe3aee
7347447
65c7be7
c4fb104
12abfc5
ed463c0
8c91c8c
7e5081b
e45e376
d21dd7f
8508954
ea689a2
ce5b7dd
7a2324a
acdf3c1
ca89baf
1ccc5f8
724856b
f1ea0da
1d92433
8ce9034
4437083
5f22315
19df253
9d12bf6
71135f1
bbdd7a5
ec6a12f
beb5723
d9a7452
176d676
056bfed
5dbdfc6
d68c421
11e6002
d7e71b9
65e39c5
9fba829
945a224
c060422
3cc6b17
a89e0d7
08416f7
fcc474e
8b176c7
8db4e9d
007a5cc
94da580
c565f4e
7bf3b59
60b2262
e675268
22b981c
0a001b9
6340dbf
27464bb
a11bfc0
15237a2
2d7453e
eca7363
6f69c6c
56da938
dd2839a
7f34a38
b1796d4
872f0c6
da5dcf8
565abe1
4b98e47
6b8ab8e
c21b037
a003a4f
40189e2
7ab58a2
7c6d0e4
70ba3de
4c0be8e
be927dd
c206d67
931b595
bbfe281
7bea8d8
3fe2d3b
726e6ac
d8a38e3
df671b6
fbaa990
1635254
481f921
33b659d
29404ae
af68ef2
7f50d02
cbff55d
447c08a
8bac67f
769c798
afaf266
26e9097
788ee6c
4210798
c4a76fe
23e9407
e17fde6
720add4
4286095
e2b0cd8
8790dee
eb94859
43445e5
dfa3435
b3e2541
a96e873
7c8cfbb
822d1c1
573d5c6
21f11bf
6850713
230bc41
2934c06
cc55b16
a4f4f43
1f2cd49
6b6f901
00415a5
8aabc84
3bc60fc
c556588
91c5825
5688b08
9a24a2e
4e9d386
9077bfb
dce346c
615c301
e450f82
2dc7cc3
97f4868
84dc860
2dea937
b3bd39f
1dd6c3e
654ec75
5e81cf6
7b1567a
8314b0a
aa8a988
fefb435
c41e08a
d93c57e
5f7f95e
d218323
bc1f332
5d1151e
026bdc0
a100de2
d608af4
ec00572
4c7e72b
c93448c
b42cde1
815439c
068a511
b7c8931
189652b
cc3efee
eb9f404
52dc433
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@rocket.chat/model-typings': minor | ||
| '@rocket.chat/core-typings': minor | ||
| '@rocket.chat/models': minor | ||
| '@rocket.chat/i18n': minor | ||
| '@rocket.chat/meteor': minor | ||
| --- | ||
|
|
||
| Introduces the ability to upload multiple files and send them in a single message |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,131 +9,170 @@ import type { | |||||
| FileProp, | ||||||
| } from '@rocket.chat/core-typings'; | ||||||
| import type { ServerMethods } from '@rocket.chat/ddp-client'; | ||||||
| import { Logger } from '@rocket.chat/logger'; | ||||||
| import { Rooms, Uploads, Users } from '@rocket.chat/models'; | ||||||
| import { Match, check } from 'meteor/check'; | ||||||
| import { Meteor } from 'meteor/meteor'; | ||||||
|
|
||||||
| import { getFileExtension } from '../../../../lib/utils/getFileExtension'; | ||||||
| import { omit } from '../../../../lib/utils/omit'; | ||||||
| import { callbacks } from '../../../../server/lib/callbacks'; | ||||||
| import { SystemLogger } from '../../../../server/lib/logger/system'; | ||||||
| import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; | ||||||
| import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; | ||||||
| import { FileUpload } from '../lib/FileUpload'; | ||||||
|
|
||||||
| function validateFileRequiredFields(file: Partial<IUpload>): asserts file is AtLeast<IUpload, '_id' | 'name' | 'type' | 'size'> { | ||||||
| type MinimalUploadData = AtLeast<IUpload, '_id' | 'name' | 'type' | 'size'>; | ||||||
|
|
||||||
| function validateFilesRequiredFields(files: Partial<IUpload>[]): asserts files is MinimalUploadData[] { | ||||||
| const requiredFields = ['_id', 'name', 'type', 'size']; | ||||||
| requiredFields.forEach((field) => { | ||||||
| if (!Object.keys(file).includes(field)) { | ||||||
| throw new Meteor.Error('error-invalid-file', 'Invalid file'); | ||||||
| for (const file of files) { | ||||||
| const fields = Object.keys(file); | ||||||
|
|
||||||
| for (const field of requiredFields) { | ||||||
| if (!fields.includes(field)) { | ||||||
| throw new Meteor.Error('error-invalid-file', 'Invalid file'); | ||||||
| } | ||||||
| } | ||||||
| }); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| export const parseFileIntoMessageAttachments = async ( | ||||||
| file: Partial<IUpload>, | ||||||
| const logger = new Logger('sendFileMessage'); | ||||||
|
|
||||||
| export const parseMultipleFilesIntoMessageAttachments = async ( | ||||||
| filesToConfirm: Partial<IUpload>[], | ||||||
| roomId: string, | ||||||
| user: IUser, | ||||||
| ): Promise<FilesAndAttachments> => { | ||||||
| validateFileRequiredFields(file); | ||||||
|
|
||||||
| await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); | ||||||
|
|
||||||
| const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); | ||||||
| // Validate every file before we process any of them | ||||||
| validateFilesRequiredFields(filesToConfirm); | ||||||
|
|
||||||
| const attachments: MessageAttachment[] = []; | ||||||
| const files: FileProp[] = []; | ||||||
|
|
||||||
| // Process one file at a time, to avoid loading too many images into memory at the same time, or sending too many simultaneous requests to external services | ||||||
| for await (const file of filesToConfirm) { | ||||||
| await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); | ||||||
| const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); | ||||||
|
|
||||||
| const files: FileProp[] = [ | ||||||
| { | ||||||
| files.push({ | ||||||
| _id: file._id, | ||||||
| name: file.name || '', | ||||||
| type: file.type || 'file', | ||||||
| size: file.size || 0, | ||||||
| format: file.identify?.format || '', | ||||||
| typeGroup: file.typeGroup, | ||||||
| }, | ||||||
| ]; | ||||||
|
|
||||||
| if (/^image\/.+/.test(file.type as string)) { | ||||||
| const attachment: FileAttachmentProps = { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| description: file?.description, | ||||||
| title_link: fileUrl, | ||||||
| title_link_download: true, | ||||||
| image_url: fileUrl, | ||||||
| image_type: file.type as string, | ||||||
| image_size: file.size, | ||||||
| fileId: file._id, | ||||||
| }; | ||||||
| }); | ||||||
|
|
||||||
| if (file.identify?.size) { | ||||||
| attachment.image_dimensions = file.identify.size; | ||||||
| const { attachment, thumbnail } = await createFileAttachment(file, { fileUrl, roomId, uid: user._id }); | ||||||
| if (thumbnail) { | ||||||
| files.push(thumbnail); | ||||||
| } | ||||||
| attachments.push(attachment); | ||||||
| } | ||||||
|
|
||||||
| return { files, attachments }; | ||||||
| }; | ||||||
|
|
||||||
| async function createFileAttachment( | ||||||
| file: MinimalUploadData, | ||||||
| extraData: { fileUrl: string; roomId: string; uid: string }, | ||||||
| ): Promise<{ attachment: FileAttachmentProps; thumbnail?: FileProp }> { | ||||||
| const { fileUrl, roomId, uid } = extraData; | ||||||
|
|
||||||
| try { | ||||||
| attachment.image_preview = await FileUpload.resizeImagePreview(file); | ||||||
| const thumbResult = await FileUpload.createImageThumbnail(file); | ||||||
| if (thumbResult) { | ||||||
| const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult; | ||||||
| const thumbnail = await FileUpload.uploadImageThumbnail( | ||||||
| { | ||||||
| thumbFileName, | ||||||
| thumbFileType, | ||||||
| originalFileId, | ||||||
| }, | ||||||
| thumbBuffer, | ||||||
| roomId, | ||||||
| user._id, | ||||||
| ); | ||||||
| const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`); | ||||||
| attachment.image_url = thumbUrl; | ||||||
| attachment.image_type = thumbnail.type; | ||||||
| attachment.image_dimensions = { | ||||||
| width, | ||||||
| height, | ||||||
| }; | ||||||
| files.push({ | ||||||
| _id: thumbnail._id, | ||||||
| name: thumbnail.name || '', | ||||||
| type: thumbnail.type || 'file', | ||||||
| size: thumbnail.size || 0, | ||||||
| format: thumbnail.identify?.format || '', | ||||||
| typeGroup: thumbnail.typeGroup || '', | ||||||
| }); | ||||||
| if (file.type) { | ||||||
| if (/^image\/.+/.test(file.type)) { | ||||||
| const attachment: FileAttachmentProps = { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| description: file?.description, | ||||||
| title_link: fileUrl, | ||||||
| title_link_download: true, | ||||||
| image_url: fileUrl, | ||||||
| image_type: file.type, | ||||||
| image_size: file.size, | ||||||
| fileId: file._id, | ||||||
| }; | ||||||
|
|
||||||
| if (file.identify?.size) { | ||||||
| attachment.image_dimensions = file.identify.size; | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
| attachment.image_preview = await FileUpload.resizeImagePreview(file); | ||||||
| const thumbResult = await FileUpload.createImageThumbnail(file); | ||||||
| if (thumbResult) { | ||||||
| const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult; | ||||||
| const thumbnail = await FileUpload.uploadImageThumbnail( | ||||||
| { | ||||||
| thumbFileName, | ||||||
| thumbFileType, | ||||||
| originalFileId, | ||||||
| }, | ||||||
| thumbBuffer, | ||||||
| roomId, | ||||||
| uid, | ||||||
| ); | ||||||
| const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, let's look at the file and the specific area mentioned
head -n 130 apps/meteor/app/file-upload/server/methods/sendFileMessage.ts | tail -n 30Repository: RocketChat/Rocket.Chat Length of output: 1065 🏁 Script executed: #!/bin/bash
# Check the structure around the file upload logic to understand thumbnail object properties
rg -n "thumbnail\._id|thumbnail\.name" apps/meteor/app/file-upload/ --type=ts -B2 -A2Repository: RocketChat/Rocket.Chat Length of output: 1160 🏁 Script executed: #!/bin/bash
# Search for how thumbnails are created/stored to understand naming conventions
rg -n "getThumbnail|storeThumbnail|thumbnail" apps/meteor/app/file-upload/server/ --type=ts | head -40Repository: RocketChat/Rocket.Chat Length of output: 2274 🏁 Script executed: #!/bin/bash
# Check how getPath is used with other files to understand naming conventions
rg -n "FileUpload.getPath" apps/meteor/app/file-upload/server/ --type=ts -B1 -A1 | head -50Repository: RocketChat/Rocket.Chat Length of output: 1057 🏁 Script executed: #!/bin/bash
# Look at the uploadImageThumbnail method to understand what name is assigned to the thumbnail
rg -n "uploadImageThumbnail" apps/meteor/app/file-upload/ --type=ts -A15 | head -80Repository: RocketChat/Rocket.Chat Length of output: 2939 🏁 Script executed: #!/bin/bash
# Check what thumbnailFileName variable is and how it's constructed
rg -n "thumbFileName|thumbFileType" apps/meteor/app/file-upload/ --type=ts -B2 -A2 | head -50Repository: RocketChat/Rocket.Chat Length of output: 2588 Use thumbnail.name instead of file.name for thumbnail URL construction. At line 115, the thumbnail URL is constructed using the original file's name ( 🔧 Proposed fix- const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`);
+ const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(thumbnail.name || '')}`);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| attachment.image_url = thumbUrl; | ||||||
| attachment.image_type = thumbnail.type; | ||||||
| attachment.image_dimensions = { | ||||||
| width, | ||||||
| height, | ||||||
| }; | ||||||
| return { | ||||||
| attachment, | ||||||
| thumbnail: { | ||||||
| _id: thumbnail._id, | ||||||
| name: thumbnail.name || '', | ||||||
| type: thumbnail.type || 'file', | ||||||
| size: thumbnail.size || 0, | ||||||
| format: thumbnail.identify?.format || '', | ||||||
| typeGroup: thumbnail.typeGroup || '', | ||||||
| }, | ||||||
| }; | ||||||
| } | ||||||
| } catch (err) { | ||||||
| logger.error({ err }); | ||||||
| } | ||||||
| } catch (err) { | ||||||
| SystemLogger.error({ err }); | ||||||
|
|
||||||
| return { attachment }; | ||||||
| } | ||||||
| attachments.push(attachment); | ||||||
| } else if (/^audio\/.+/.test(file.type as string)) { | ||||||
| const attachment: FileAttachmentProps = { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| description: file.description, | ||||||
| title_link: fileUrl, | ||||||
| title_link_download: true, | ||||||
| audio_url: fileUrl, | ||||||
| audio_type: file.type as string, | ||||||
| audio_size: file.size, | ||||||
| fileId: file._id, | ||||||
| }; | ||||||
| attachments.push(attachment); | ||||||
| } else if (/^video\/.+/.test(file.type as string)) { | ||||||
| const attachment: FileAttachmentProps = { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| description: file.description, | ||||||
| title_link: fileUrl, | ||||||
| title_link_download: true, | ||||||
| video_url: fileUrl, | ||||||
| video_type: file.type as string, | ||||||
| video_size: file.size as number, | ||||||
| fileId: file._id, | ||||||
| }; | ||||||
| attachments.push(attachment); | ||||||
| } else { | ||||||
| const attachment = { | ||||||
|
|
||||||
| if (/^audio\/.+/.test(file.type)) { | ||||||
| return { | ||||||
| attachment: { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| description: file.description, | ||||||
| title_link: fileUrl, | ||||||
| title_link_download: true, | ||||||
| audio_url: fileUrl, | ||||||
| audio_type: file.type, | ||||||
| audio_size: file.size, | ||||||
| fileId: file._id, | ||||||
| }, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| if (/^video\/.+/.test(file.type)) { | ||||||
| return { | ||||||
| attachment: { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| description: file.description, | ||||||
| title_link: fileUrl, | ||||||
| title_link_download: true, | ||||||
| video_url: fileUrl, | ||||||
| video_type: file.type, | ||||||
| video_size: file.size as number, | ||||||
| fileId: file._id, | ||||||
| }, | ||||||
| }; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| return { | ||||||
| attachment: { | ||||||
| title: file.name, | ||||||
| type: 'file', | ||||||
| format: getFileExtension(file.name), | ||||||
|
|
@@ -142,10 +181,16 @@ export const parseFileIntoMessageAttachments = async ( | |||||
| title_link_download: true, | ||||||
| size: file.size as number, | ||||||
| fileId: file._id, | ||||||
| }; | ||||||
| attachments.push(attachment); | ||||||
| } | ||||||
| return { files, attachments }; | ||||||
| }, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| export const parseFileIntoMessageAttachments = async ( | ||||||
| file: Partial<IUpload>, | ||||||
| roomId: string, | ||||||
| user: IUser, | ||||||
| ): Promise<FilesAndAttachments> => { | ||||||
| return parseMultipleFilesIntoMessageAttachments([file], roomId, user); | ||||||
| }; | ||||||
|
|
||||||
| declare module '@rocket.chat/ddp-client' { | ||||||
|
|
@@ -179,7 +224,7 @@ export const sendFileMessage = async ( | |||||
| if (!user) { | ||||||
| throw new Meteor.Error('error-invalid-user', 'Invalid user', { | ||||||
| method: 'sendFileMessage', | ||||||
| } as any); | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| const room = await Rooms.findOneById(roomId); | ||||||
|
|
@@ -240,7 +285,7 @@ Meteor.methods<ServerMethods>({ | |||||
| if (!userId) { | ||||||
| throw new Meteor.Error('error-invalid-user', 'Invalid user', { | ||||||
| method: 'sendFileMessage', | ||||||
| } as any); | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| return sendFileMessage(userId, { roomId, file, msgData }); | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validation checks key presence but not value truthiness.
Object.keys(file).includes(field)passes even when the value isnull,undefined, or''. A caller could send{ _id: null, name: '', type: undefined, size: 0 }and this validation would not reject it, yet downstream code (e.g.,Uploads.updateFileComplete(file._id, ...)) would break or produce corrupt data.Consider checking that the value is actually present and meaningful:
🛡️ Proposed fix
🤖 Prompt for AI Agents