Skip to content

Commit 657926c

Browse files
committed
feat: Add file upload rules and size limits for different messaging channels
1 parent 75860ac commit 657926c

File tree

4 files changed

+554
-1
lines changed

4 files changed

+554
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@chatwoot/utils",
3-
"version": "0.0.49",
3+
"version": "0.0.50",
44
"description": "Chatwoot utils",
55
"private": false,
66
"license": "MIT",

src/fileUploadRules.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// ---------- Types ----------
2+
interface MimeGroups {
3+
image?: string[];
4+
audio?: string[];
5+
video?: string[];
6+
text?: string[];
7+
application?: string[];
8+
}
9+
10+
interface ChannelNodeConfig {
11+
mimeGroups?: MimeGroups;
12+
extensions?: string[];
13+
max?: number;
14+
maxByCategory?: {
15+
image?: number;
16+
video?: number;
17+
audio?: number;
18+
document?: number;
19+
};
20+
}
21+
22+
type DefaultNodeConfig = Omit<ChannelNodeConfig, 'max'> & { max: number };
23+
24+
interface ChannelConfig {
25+
[medium: string]: ChannelNodeConfig | undefined; // includes '*'
26+
}
27+
28+
type CategoryType = 'image' | 'video' | 'audio' | 'document' | undefined;
29+
30+
interface GetChannelParams {
31+
channelType?: ChannelKey; // align with ChannelKey
32+
medium?: string;
33+
}
34+
35+
interface GetMaxUploadParams extends GetChannelParams {
36+
mime?: string;
37+
}
38+
39+
// ---------- Channels ----------
40+
const INBOX_TYPES = {
41+
WEB: 'Channel::WebWidget',
42+
FB: 'Channel::FacebookPage',
43+
TWITTER: 'Channel::TwitterProfile',
44+
TWILIO: 'Channel::TwilioSms',
45+
WHATSAPP: 'Channel::Whatsapp',
46+
API: 'Channel::Api',
47+
EMAIL: 'Channel::Email',
48+
TELEGRAM: 'Channel::Telegram',
49+
LINE: 'Channel::Line',
50+
SMS: 'Channel::Sms',
51+
INSTAGRAM: 'Channel::Instagram',
52+
VOICE: 'Channel::Voice',
53+
} as const;
54+
55+
// derive key type AFTER INBOX_TYPES is declared
56+
type ChannelKey = typeof INBOX_TYPES[keyof typeof INBOX_TYPES];
57+
58+
// CHANNEL_CONFIGS shape: channels are optional; default node requires max
59+
type ChannelConfigs = Partial<Record<ChannelKey, ChannelConfig>> & {
60+
default: DefaultNodeConfig;
61+
};
62+
63+
// ---------- Docs ----------
64+
/**
65+
* LINE: https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
66+
* INSTAGRAM: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
67+
* WHATSAPP CLOUD: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types
68+
* TWILIO WHATSAPP: https://www.twilio.com/docs/whatsapp/guidance-whatsapp-media-messages
69+
* TWILIO SMS: https://www.twilio.com/docs/messaging/guides/accepted-mime-types
70+
*/
71+
72+
// ---------- Central config ----------
73+
/**
74+
* Upload rules configuration.
75+
*
76+
* Each node can define:
77+
* - mimeGroups: { prefix: [exts] }
78+
* Example: { image: ["png","jpeg"] } → ["image/png","image/jpeg"]
79+
* Special: ["*"] → allow all (e.g. "image/*").
80+
* - extensions: Raw file extensions (e.g. [".3gpp"]).
81+
* - max: Default maximum size in MB for this channel.
82+
* - maxByCategory: Override per category (image, video, audio, document).
83+
*
84+
* Resolution order:
85+
* 1. channel + medium (e.g. TWILIO.whatsapp)
86+
* 2. channel + "*" fallback
87+
* 3. global default
88+
*/
89+
const CHANNEL_CONFIGS: ChannelConfigs = {
90+
default: {
91+
mimeGroups: {
92+
image: ['*'],
93+
audio: ['*'],
94+
video: ['*'],
95+
text: ['csv', 'plain', 'rtf', 'xml'],
96+
application: [
97+
'json',
98+
'pdf',
99+
'xml',
100+
'zip',
101+
'x-7z-compressed',
102+
'vnd.rar',
103+
'x-tar',
104+
'msword',
105+
'vnd.ms-excel',
106+
'vnd.ms-powerpoint',
107+
'vnd.oasis.opendocument.text',
108+
'vnd.openxmlformats-officedocument.presentationml.presentation',
109+
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
110+
'vnd.openxmlformats-officedocument.wordprocessingml.document',
111+
],
112+
},
113+
extensions: ['.3gpp'],
114+
max: 40,
115+
},
116+
117+
[INBOX_TYPES.WHATSAPP]: {
118+
'*': {
119+
mimeGroups: {
120+
audio: ['aac', 'amr', 'mp3', 'm4a', 'ogg'],
121+
image: ['jpeg', 'png'],
122+
video: ['3gp', 'mp4'],
123+
text: ['plain'],
124+
application: [
125+
'pdf',
126+
'vnd.ms-excel',
127+
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
128+
'msword',
129+
'vnd.openxmlformats-officedocument.wordprocessingml.document',
130+
'vnd.ms-powerpoint',
131+
'vnd.openxmlformats-officedocument.presentationml.presentation',
132+
],
133+
},
134+
maxByCategory: { image: 5, video: 16, audio: 16, document: 100 },
135+
},
136+
},
137+
138+
[INBOX_TYPES.INSTAGRAM]: {
139+
'*': {
140+
mimeGroups: {
141+
audio: ['aac', 'm4a', 'wav', 'mp4'],
142+
image: ['png', 'jpeg', 'gif'],
143+
video: ['mp4', 'ogg', 'avi', 'mov', 'webm'],
144+
},
145+
maxByCategory: { image: 16, video: 25, audio: 25 },
146+
},
147+
},
148+
149+
[INBOX_TYPES.FB]: {
150+
'*': {
151+
mimeGroups: {
152+
audio: ['aac', 'm4a', 'wav', 'mp4'],
153+
image: ['png', 'jpeg', 'gif'],
154+
video: ['mp4', 'ogg', 'avi', 'mov', 'webm'],
155+
text: ['plain'],
156+
application: [
157+
'pdf',
158+
'vnd.ms-excel',
159+
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
160+
'msword',
161+
'vnd.openxmlformats-officedocument.wordprocessingml.document',
162+
'vnd.ms-powerpoint',
163+
'vnd.openxmlformats-officedocument.presentationml.presentation',
164+
],
165+
},
166+
maxByCategory: { image: 8, audio: 25, video: 25, document: 25 },
167+
},
168+
},
169+
170+
[INBOX_TYPES.LINE]: {
171+
'*': {
172+
mimeGroups: {
173+
image: ['png', 'jpeg'],
174+
video: ['mp4'],
175+
},
176+
maxByCategory: { image: 10 },
177+
},
178+
},
179+
180+
[INBOX_TYPES.TWILIO]: {
181+
sms: { max: 5 },
182+
whatsapp: {
183+
mimeGroups: {
184+
image: ['png', 'jpeg'],
185+
audio: ['mpeg', 'opus', 'ogg', 'amr'],
186+
video: ['mp4'],
187+
application: ['pdf'],
188+
},
189+
max: 5,
190+
},
191+
},
192+
};
193+
194+
// ---------- Helpers ----------
195+
/**
196+
* @name DOC_HEADS
197+
* @description MIME type categories that should be considered "document"
198+
*/
199+
const DOC_HEADS = new Set<string>(['application', 'text']);
200+
201+
/**
202+
* @name categoryFromMime
203+
* @description Gets a high-level category name from a MIME type.
204+
*
205+
* @param {string} mime - MIME type string (e.g. "image/png").
206+
* @returns {"image"|"video"|"audio"|"document"|undefined} Category name.
207+
*/
208+
const categoryFromMime = (mime?: string): CategoryType => {
209+
const head = mime?.split('/')?.[0] ?? '';
210+
return DOC_HEADS.has(head) ? 'document' : (head as CategoryType);
211+
};
212+
213+
/**
214+
* @name getNode
215+
* @description Finds the matching rule node for a channel and optional medium.
216+
*
217+
* @param {ChannelKey} [channelType] - One of INBOX_TYPES.
218+
* @param {string} [medium] - Optional sub-medium (e.g. "sms","whatsapp").
219+
* @returns {ChannelNodeConfig} Config node with rules.
220+
*/
221+
const getNode = (
222+
channelType?: ChannelKey,
223+
medium?: string
224+
): ChannelNodeConfig => {
225+
if (!channelType) return CHANNEL_CONFIGS.default;
226+
227+
const channelCfg = CHANNEL_CONFIGS[channelType];
228+
if (!channelCfg) return CHANNEL_CONFIGS.default;
229+
230+
return (
231+
channelCfg[medium ?? '*'] ?? channelCfg['*'] ?? CHANNEL_CONFIGS.default
232+
);
233+
};
234+
235+
/**
236+
* @name expandMimeGroups
237+
* @description Expands MIME groups and extensions into a list of strings.
238+
*
239+
* Examples:
240+
* { image: ["*"] } → ["image/*"]
241+
* { image: ["png"] } → ["image/png"]
242+
* { application: ["pdf"] } → ["application/pdf"]
243+
* extensions: [".3gpp"] → [".3gpp"]
244+
*
245+
* @param {Object} mimeGroups - Grouped MIME suffixes by prefix.
246+
* @param {string[]} extensions - Extra raw extensions.
247+
* @returns {string[]} Expanded list of MIME/extension strings.
248+
*/
249+
const expandMimeGroups = (
250+
mimeGroups: MimeGroups = {},
251+
extensions: string[] = []
252+
): string[] => {
253+
const mimes = Object.entries(mimeGroups).flatMap(([prefix, exts]) =>
254+
(exts ?? []).map((ext: string) =>
255+
ext === '*' ? `${prefix}/*` : `${prefix}/${ext}`
256+
)
257+
);
258+
return [...mimes, ...extensions];
259+
};
260+
261+
// ---------- Public API ----------
262+
/**
263+
* @name getAllowedFileTypesByChannel
264+
* @description Builds the full "accept" string for <input type="file">,
265+
* based on channel + medium rules.
266+
*
267+
* @param {Object} params
268+
* @param {string} [params.channelType] - Channel type (from INBOX_TYPES).
269+
* @param {string} [params.medium] - Medium under the channel.
270+
* @returns {string} Comma-separated list of allowed MIME types/extensions.
271+
*
272+
* @example
273+
* getAllowedFileTypesByChannel({ channelType: INBOX_TYPES.WHATSAPP });
274+
* → "audio/aac, audio/amr, image/jpeg, image/png, video/3gp, ..."
275+
*/
276+
export const getAllowedFileTypesByChannel = ({
277+
channelType,
278+
medium,
279+
}: GetChannelParams = {}): string => {
280+
const node = getNode(channelType, medium);
281+
const { mimeGroups, extensions } =
282+
!node.mimeGroups && !node.extensions ? CHANNEL_CONFIGS.default : node;
283+
284+
return expandMimeGroups(mimeGroups, extensions).join(', ');
285+
};
286+
287+
/**
288+
* @name getMaxUploadSizeByChannel
289+
* @description Gets the maximum allowed file size (in MB) for a channel, medium, and MIME type.
290+
*
291+
* Priority:
292+
* - Category-specific size (image/video/audio/document).
293+
* - Channel/medium-level max.
294+
* - Global default max.
295+
*
296+
* @param {Object} params
297+
* @param {string} [params.channelType] - Channel type (from INBOX_TYPES).
298+
* @param {string} [params.medium] - Medium under the channel.
299+
* @param {string} [params.mime] - MIME type string (for category lookup).
300+
* @returns {number} Maximum file size in MB.
301+
*
302+
* @example
303+
* getMaxUploadSizeByChannel({ channelType: INBOX_TYPES.WHATSAPP, mime: "image/png" });
304+
* → 5
305+
*/
306+
export const getMaxUploadSizeByChannel = ({
307+
channelType,
308+
medium,
309+
mime,
310+
}: GetMaxUploadParams = {}): number => {
311+
const node = getNode(channelType, medium);
312+
const cat = categoryFromMime(mime);
313+
const catMax = cat ? node.maxByCategory?.[cat] : undefined;
314+
return catMax ?? node.max ?? CHANNEL_CONFIGS.default.max;
315+
};

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import { createTypingIndicator } from './typingStatus';
3737
import { evaluateSLAStatus } from './sla';
3838

3939
import { coerceToDate } from './date';
40+
import {
41+
getAllowedFileTypesByChannel,
42+
getMaxUploadSizeByChannel,
43+
} from './fileUploadRules';
4044

4145
export {
4246
clamp,
@@ -68,4 +72,6 @@ export {
6872
getFileInfo,
6973
getRecipients,
7074
formatNumber,
75+
getAllowedFileTypesByChannel,
76+
getMaxUploadSizeByChannel,
7177
};

0 commit comments

Comments
 (0)