From 657926c7736217cd413ddf3037d0d915465ca687 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Tue, 26 Aug 2025 11:45:15 +0530 Subject: [PATCH] feat: Add file upload rules and size limits for different messaging channels --- package.json | 2 +- src/fileUploadRules.ts | 315 +++++++++++++++++++++++++++++++++++ src/index.ts | 6 + test/fileUploadRules.test.ts | 232 ++++++++++++++++++++++++++ 4 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 src/fileUploadRules.ts create mode 100644 test/fileUploadRules.test.ts diff --git a/package.json b/package.json index f614f58..38b0df4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/utils", - "version": "0.0.49", + "version": "0.0.50", "description": "Chatwoot utils", "private": false, "license": "MIT", diff --git a/src/fileUploadRules.ts b/src/fileUploadRules.ts new file mode 100644 index 0000000..d2d4ae6 --- /dev/null +++ b/src/fileUploadRules.ts @@ -0,0 +1,315 @@ +// ---------- Types ---------- +interface MimeGroups { + image?: string[]; + audio?: string[]; + video?: string[]; + text?: string[]; + application?: string[]; +} + +interface ChannelNodeConfig { + mimeGroups?: MimeGroups; + extensions?: string[]; + max?: number; + maxByCategory?: { + image?: number; + video?: number; + audio?: number; + document?: number; + }; +} + +type DefaultNodeConfig = Omit & { max: number }; + +interface ChannelConfig { + [medium: string]: ChannelNodeConfig | undefined; // includes '*' +} + +type CategoryType = 'image' | 'video' | 'audio' | 'document' | undefined; + +interface GetChannelParams { + channelType?: ChannelKey; // align with ChannelKey + medium?: string; +} + +interface GetMaxUploadParams extends GetChannelParams { + mime?: string; +} + +// ---------- Channels ---------- +const INBOX_TYPES = { + WEB: 'Channel::WebWidget', + FB: 'Channel::FacebookPage', + TWITTER: 'Channel::TwitterProfile', + TWILIO: 'Channel::TwilioSms', + WHATSAPP: 'Channel::Whatsapp', + API: 'Channel::Api', + EMAIL: 'Channel::Email', + TELEGRAM: 'Channel::Telegram', + LINE: 'Channel::Line', + SMS: 'Channel::Sms', + INSTAGRAM: 'Channel::Instagram', + VOICE: 'Channel::Voice', +} as const; + +// derive key type AFTER INBOX_TYPES is declared +type ChannelKey = typeof INBOX_TYPES[keyof typeof INBOX_TYPES]; + +// CHANNEL_CONFIGS shape: channels are optional; default node requires max +type ChannelConfigs = Partial> & { + default: DefaultNodeConfig; +}; + +// ---------- Docs ---------- +/** + * LINE: https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message + * INSTAGRAM: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements + * WHATSAPP CLOUD: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types + * TWILIO WHATSAPP: https://www.twilio.com/docs/whatsapp/guidance-whatsapp-media-messages + * TWILIO SMS: https://www.twilio.com/docs/messaging/guides/accepted-mime-types + */ + +// ---------- Central config ---------- +/** + * Upload rules configuration. + * + * Each node can define: + * - mimeGroups: { prefix: [exts] } + * Example: { image: ["png","jpeg"] } → ["image/png","image/jpeg"] + * Special: ["*"] → allow all (e.g. "image/*"). + * - extensions: Raw file extensions (e.g. [".3gpp"]). + * - max: Default maximum size in MB for this channel. + * - maxByCategory: Override per category (image, video, audio, document). + * + * Resolution order: + * 1. channel + medium (e.g. TWILIO.whatsapp) + * 2. channel + "*" fallback + * 3. global default + */ +const CHANNEL_CONFIGS: ChannelConfigs = { + default: { + mimeGroups: { + image: ['*'], + audio: ['*'], + video: ['*'], + text: ['csv', 'plain', 'rtf', 'xml'], + application: [ + 'json', + 'pdf', + 'xml', + 'zip', + 'x-7z-compressed', + 'vnd.rar', + 'x-tar', + 'msword', + 'vnd.ms-excel', + 'vnd.ms-powerpoint', + 'vnd.oasis.opendocument.text', + 'vnd.openxmlformats-officedocument.presentationml.presentation', + 'vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + }, + extensions: ['.3gpp'], + max: 40, + }, + + [INBOX_TYPES.WHATSAPP]: { + '*': { + mimeGroups: { + audio: ['aac', 'amr', 'mp3', 'm4a', 'ogg'], + image: ['jpeg', 'png'], + video: ['3gp', 'mp4'], + text: ['plain'], + application: [ + 'pdf', + 'vnd.ms-excel', + 'vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'msword', + 'vnd.openxmlformats-officedocument.wordprocessingml.document', + 'vnd.ms-powerpoint', + 'vnd.openxmlformats-officedocument.presentationml.presentation', + ], + }, + maxByCategory: { image: 5, video: 16, audio: 16, document: 100 }, + }, + }, + + [INBOX_TYPES.INSTAGRAM]: { + '*': { + mimeGroups: { + audio: ['aac', 'm4a', 'wav', 'mp4'], + image: ['png', 'jpeg', 'gif'], + video: ['mp4', 'ogg', 'avi', 'mov', 'webm'], + }, + maxByCategory: { image: 16, video: 25, audio: 25 }, + }, + }, + + [INBOX_TYPES.FB]: { + '*': { + mimeGroups: { + audio: ['aac', 'm4a', 'wav', 'mp4'], + image: ['png', 'jpeg', 'gif'], + video: ['mp4', 'ogg', 'avi', 'mov', 'webm'], + text: ['plain'], + application: [ + 'pdf', + 'vnd.ms-excel', + 'vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'msword', + 'vnd.openxmlformats-officedocument.wordprocessingml.document', + 'vnd.ms-powerpoint', + 'vnd.openxmlformats-officedocument.presentationml.presentation', + ], + }, + maxByCategory: { image: 8, audio: 25, video: 25, document: 25 }, + }, + }, + + [INBOX_TYPES.LINE]: { + '*': { + mimeGroups: { + image: ['png', 'jpeg'], + video: ['mp4'], + }, + maxByCategory: { image: 10 }, + }, + }, + + [INBOX_TYPES.TWILIO]: { + sms: { max: 5 }, + whatsapp: { + mimeGroups: { + image: ['png', 'jpeg'], + audio: ['mpeg', 'opus', 'ogg', 'amr'], + video: ['mp4'], + application: ['pdf'], + }, + max: 5, + }, + }, +}; + +// ---------- Helpers ---------- +/** + * @name DOC_HEADS + * @description MIME type categories that should be considered "document" + */ +const DOC_HEADS = new Set(['application', 'text']); + +/** + * @name categoryFromMime + * @description Gets a high-level category name from a MIME type. + * + * @param {string} mime - MIME type string (e.g. "image/png"). + * @returns {"image"|"video"|"audio"|"document"|undefined} Category name. + */ +const categoryFromMime = (mime?: string): CategoryType => { + const head = mime?.split('/')?.[0] ?? ''; + return DOC_HEADS.has(head) ? 'document' : (head as CategoryType); +}; + +/** + * @name getNode + * @description Finds the matching rule node for a channel and optional medium. + * + * @param {ChannelKey} [channelType] - One of INBOX_TYPES. + * @param {string} [medium] - Optional sub-medium (e.g. "sms","whatsapp"). + * @returns {ChannelNodeConfig} Config node with rules. + */ +const getNode = ( + channelType?: ChannelKey, + medium?: string +): ChannelNodeConfig => { + if (!channelType) return CHANNEL_CONFIGS.default; + + const channelCfg = CHANNEL_CONFIGS[channelType]; + if (!channelCfg) return CHANNEL_CONFIGS.default; + + return ( + channelCfg[medium ?? '*'] ?? channelCfg['*'] ?? CHANNEL_CONFIGS.default + ); +}; + +/** + * @name expandMimeGroups + * @description Expands MIME groups and extensions into a list of strings. + * + * Examples: + * { image: ["*"] } → ["image/*"] + * { image: ["png"] } → ["image/png"] + * { application: ["pdf"] } → ["application/pdf"] + * extensions: [".3gpp"] → [".3gpp"] + * + * @param {Object} mimeGroups - Grouped MIME suffixes by prefix. + * @param {string[]} extensions - Extra raw extensions. + * @returns {string[]} Expanded list of MIME/extension strings. + */ +const expandMimeGroups = ( + mimeGroups: MimeGroups = {}, + extensions: string[] = [] +): string[] => { + const mimes = Object.entries(mimeGroups).flatMap(([prefix, exts]) => + (exts ?? []).map((ext: string) => + ext === '*' ? `${prefix}/*` : `${prefix}/${ext}` + ) + ); + return [...mimes, ...extensions]; +}; + +// ---------- Public API ---------- +/** + * @name getAllowedFileTypesByChannel + * @description Builds the full "accept" string for , + * based on channel + medium rules. + * + * @param {Object} params + * @param {string} [params.channelType] - Channel type (from INBOX_TYPES). + * @param {string} [params.medium] - Medium under the channel. + * @returns {string} Comma-separated list of allowed MIME types/extensions. + * + * @example + * getAllowedFileTypesByChannel({ channelType: INBOX_TYPES.WHATSAPP }); + * → "audio/aac, audio/amr, image/jpeg, image/png, video/3gp, ..." + */ +export const getAllowedFileTypesByChannel = ({ + channelType, + medium, +}: GetChannelParams = {}): string => { + const node = getNode(channelType, medium); + const { mimeGroups, extensions } = + !node.mimeGroups && !node.extensions ? CHANNEL_CONFIGS.default : node; + + return expandMimeGroups(mimeGroups, extensions).join(', '); +}; + +/** + * @name getMaxUploadSizeByChannel + * @description Gets the maximum allowed file size (in MB) for a channel, medium, and MIME type. + * + * Priority: + * - Category-specific size (image/video/audio/document). + * - Channel/medium-level max. + * - Global default max. + * + * @param {Object} params + * @param {string} [params.channelType] - Channel type (from INBOX_TYPES). + * @param {string} [params.medium] - Medium under the channel. + * @param {string} [params.mime] - MIME type string (for category lookup). + * @returns {number} Maximum file size in MB. + * + * @example + * getMaxUploadSizeByChannel({ channelType: INBOX_TYPES.WHATSAPP, mime: "image/png" }); + * → 5 + */ +export const getMaxUploadSizeByChannel = ({ + channelType, + medium, + mime, +}: GetMaxUploadParams = {}): number => { + const node = getNode(channelType, medium); + const cat = categoryFromMime(mime); + const catMax = cat ? node.maxByCategory?.[cat] : undefined; + return catMax ?? node.max ?? CHANNEL_CONFIGS.default.max; +}; diff --git a/src/index.ts b/src/index.ts index 1f04d59..08294f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,10 @@ import { createTypingIndicator } from './typingStatus'; import { evaluateSLAStatus } from './sla'; import { coerceToDate } from './date'; +import { + getAllowedFileTypesByChannel, + getMaxUploadSizeByChannel, +} from './fileUploadRules'; export { clamp, @@ -68,4 +72,6 @@ export { getFileInfo, getRecipients, formatNumber, + getAllowedFileTypesByChannel, + getMaxUploadSizeByChannel, }; diff --git a/test/fileUploadRules.test.ts b/test/fileUploadRules.test.ts new file mode 100644 index 0000000..8a0b8af --- /dev/null +++ b/test/fileUploadRules.test.ts @@ -0,0 +1,232 @@ +import { + INBOX_TYPES, + getAllowedFileTypesByChannel, + getMaxUploadSizeByChannel, +} from '../src/fileUploadRules'; + +describe('uploadRules helper', () => { + describe('getAllowedFileTypesByChannel', () => { + it('returns default accept list when no params are provided', () => { + const accept = getAllowedFileTypesByChannel(); + expect(typeof accept).toBe('string'); + expect(accept).toContain('image/*'); + expect(accept).toContain('audio/*'); + expect(accept).toContain('video/*'); + expect(accept).toContain('text/plain'); + expect(accept).toContain('application/json'); + expect(accept).toContain('.3gpp'); + }); + + it('returns WhatsApp specific accept list', () => { + const accept = getAllowedFileTypesByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + }); + expect(accept).toContain('image/jpeg'); + expect(accept).toContain('image/png'); + expect(accept).toContain('video/3gp'); + expect(accept).toContain('video/mp4'); + expect(accept).toContain('audio/aac'); + expect(accept).toContain('text/plain'); + expect(accept).toContain('application/pdf'); + + expect(accept).not.toContain('image/*'); + expect(accept).not.toContain('application/json'); + expect(accept).not.toContain('.3gpp'); + expect(accept).not.toContain('image/gif'); + }); + + it('returns Instagram specific accept list', () => { + const accept = getAllowedFileTypesByChannel({ + channelType: INBOX_TYPES.INSTAGRAM, + }); + expect(accept).toContain('image/png'); + expect(accept).toContain('image/jpeg'); + expect(accept).toContain('image/gif'); + expect(accept).toContain('video/mp4'); + expect(accept).toContain('video/webm'); + expect(accept).toContain('audio/mp4'); + }); + + it('returns Line specific accept list', () => { + const accept = getAllowedFileTypesByChannel({ + channelType: INBOX_TYPES.LINE, + }); + expect(accept).toContain('image/png'); + expect(accept).toContain('image/jpeg'); + expect(accept).toContain('video/mp4'); + + expect(accept).not.toContain('application/pdf'); + }); + + it('returns Twilio WhatsApp accept list', () => { + const accept = getAllowedFileTypesByChannel({ + channelType: INBOX_TYPES.TWILIO, + medium: 'whatsapp', + }); + expect(accept).toContain('image/png'); + expect(accept).toContain('image/jpeg'); + expect(accept).toContain('video/mp4'); + expect(accept).toContain('audio/ogg'); + expect(accept).toContain('audio/opus'); + expect(accept).toContain('application/pdf'); + + expect(accept).not.toContain('audio/mp3'); + }); + + it('falls back to default accept list for Twilio SMS (no mimeGroups)', () => { + const accept = getAllowedFileTypesByChannel({ + channelType: INBOX_TYPES.TWILIO, + medium: 'sms', + }); + expect(accept).toContain('image/*'); + expect(accept).toContain('.3gpp'); + }); + + it('handles empty object parameter', () => { + const accept = getAllowedFileTypesByChannel({}); + expect(accept).toContain('image/*'); + expect(accept).toContain('.3gpp'); + }); + }); + + describe('getMaxUploadSizeByChannel', () => { + it('returns default max (40MB) when no params are provided', () => { + expect(getMaxUploadSizeByChannel()).toBe(40); + }); + + it('returns default max (40MB) for default channel and any mime', () => { + expect(getMaxUploadSizeByChannel({ mime: 'image/png' })).toBe(40); + }); + + it('returns WhatsApp category-specific limits', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'image/png', + }) + ).toBe(5); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'video/mp4', + }) + ).toBe(16); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'audio/ogg', + }) + ).toBe(16); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'application/pdf', + }) + ).toBe(100); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'text/plain', + }) + ).toBe(100); + }); + + it('returns Instagram category-specific limits', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.INSTAGRAM, + mime: 'image/jpeg', + }) + ).toBe(16); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.INSTAGRAM, + mime: 'video/mp4', + }) + ).toBe(25); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.INSTAGRAM, + mime: 'audio/wav', + }) + ).toBe(25); + }); + + it('returns Line image limit and falls back to default for video', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.LINE, + mime: 'image/png', + }) + ).toBe(10); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.LINE, + mime: 'video/mp4', + }) + ).toBe(40); // fallback to default max + }); + + it('returns Twilio WhatsApp node max (5MB) for any category', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.TWILIO, + medium: 'whatsapp', + mime: 'image/png', + }) + ).toBe(5); + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.TWILIO, + medium: 'whatsapp', + mime: 'application/pdf', + }) + ).toBe(5); + }); + + it('returns Twilio SMS node max (5MB) when medium is sms', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.TWILIO, + medium: 'sms', + }) + ).toBe(5); + }); + + it('handles invalid MIME types gracefully', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'invalid', + }) + ).toBe(40); + + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: '', + }) + ).toBe(40); + + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: undefined, + }) + ).toBe(40); + }); + + it('handles unknown MIME type categories', () => { + expect( + getMaxUploadSizeByChannel({ + channelType: INBOX_TYPES.WHATSAPP, + mime: 'unknown/type', + }) + ).toBe(40); // 'unknown' is not in DOC_HEADS, so category is 'unknown', falls back to default + }); + + it('handles empty object parameter', () => { + expect(getMaxUploadSizeByChannel({})).toBe(40); + }); + }); +});