import type { UrlWithParsedQuery } from 'url'; import type { FilterOperators } from 'mongodb'; import type { IMessage, IRoom, IUser, ILivechatDepartmentRecord, ILivechatAgent, OmnichannelAgentStatus, ILivechatInquiryRecord, ILivechatVisitor, VideoConference, ParsedUrl, OEmbedMeta, OEmbedUrlContent, Username, IOmnichannelRoom, ILivechatTag, SelectedAgent, InquiryWithAgentInfo, } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import type { Logger } from '../app/logger/server'; import type { IBusinessHourBehavior } from '../app/livechat/server/business-hour/AbstractBusinessHour'; import type { ILoginAttempt } from '../app/authentication/server/ILoginAttempt'; import { compareByRanking } from './utils/comparisons'; import type { CloseRoomParams } from '../app/livechat/server/lib/LivechatTyped'; enum CallbackPriority { HIGH = -1000, MEDIUM = 0, LOW = 1000, } /** * Callbacks returning void, like event listeners. * * TODO: move those to event-based systems */ // eslint-disable-next-line @typescript-eslint/naming-convention interface EventLikeCallbackSignatures { 'afterActivateUser': (user: IUser) => void; 'afterCreateChannel': (owner: IUser, room: IRoom) => void; 'afterCreatePrivateGroup': (owner: IUser, room: IRoom) => void; 'afterDeactivateUser': (user: IUser) => void; 'afterDeleteMessage': (message: IMessage, room: IRoom) => void; 'validateUserRoles': (userData: Partial) => void; 'workspaceLicenseChanged': (license: string) => void; 'afterReadMessages': (rid: IRoom['_id'], params: { uid: IUser['_id']; lastSeen?: Date; tmid?: IMessage['_id'] }) => void; 'beforeReadMessages': (rid: IRoom['_id'], uid: IUser['_id']) => void; 'afterDeleteUser': (user: IUser) => void; 'afterFileUpload': (params: { user: IUser; room: IRoom; message: IMessage }) => void; 'afterRoomNameChange': (params: { rid: string; name: string; oldName: string }) => void; 'afterSaveMessage': (message: IMessage, room: IRoom, uid?: string) => void; 'livechat.removeAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void; 'livechat.saveAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void; 'livechat.closeRoom': (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => void; 'livechat.saveRoom': (room: IRoom) => void; 'livechat:afterReturnRoomAsInquiry': (params: { room: IRoom }) => void; 'livechat.setUserStatusLivechat': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; 'livechat.agentStatusChanged': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; 'livechat.onNewAgentCreated': (agentId: string) => void; 'livechat.afterTakeInquiry': (inq: InquiryWithAgentInfo, agent: { agentId: string; username: string }) => void; 'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void; 'beforeAddedToRoom': (params: { user: IUser; inviter: IUser }) => void; 'afterCreateDirectRoom': (params: IRoom, second: { members: IUser[]; creatorId: IUser['_id'] }) => void; 'beforeDeleteRoom': (params: IRoom) => void; 'beforeJoinDefaultChannels': (user: IUser) => void; 'beforeCreateChannel': (owner: IUser, room: IRoom) => void; 'afterCreateRoom': (owner: IUser, room: IRoom) => void; 'onValidateLogin': (login: ILoginAttempt) => void; 'federation.afterCreateFederatedRoom': (room: IRoom, second: { owner: IUser; originalMemberList: string[] }) => void; 'beforeCreateDirectRoom': (members: IUser[]) => void; 'federation.beforeCreateDirectMessage': (members: IUser[]) => void; 'afterSetReaction': (message: IMessage, { user, reaction }: { user: IUser; reaction: string; shouldReact: boolean }) => void; 'afterUnsetReaction': ( message: IMessage, { user, reaction }: { user: IUser; reaction: string; shouldReact: boolean; oldMessage: IMessage }, ) => void; 'federation.beforeAddUserToARoom': (params: { user: IUser | string; inviter: IUser }, room: IRoom) => void; 'federation.onAddUsersToARoom': (params: { invitees: IUser[] | Username[]; inviter: IUser }, room: IRoom) => void; 'onJoinVideoConference': (callId: VideoConference['_id'], userId?: IUser['_id']) => Promise; 'usernameSet': () => void; 'beforeLeaveRoom': (user: IUser, room: IRoom) => void; 'beforeJoinRoom': (user: IUser, room: IRoom) => void; 'beforeMuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; 'afterMuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; 'beforeUnmuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; 'afterUnmuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; 'afterValidateLogin': (login: { user: IUser }) => void; 'afterJoinRoom': (user: IUser, room: IRoom) => void; 'beforeCreateRoom': (data: { type: IRoom['t']; extraData: { encrypted: boolean } }) => void; 'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser: IUser | null }) => void; } /** * Callbacks that are supposed to be composed like a chain. * * TODO: develop a middleware alternative and grant independence of execution order */ type ChainedCallbackSignatures = { 'livechat.beforeRoom': ( roomInfo: Record, extraData?: Record & { sla?: string }, ) => Record; 'livechat.newRoom': (room: IOmnichannelRoom) => IOmnichannelRoom; 'livechat.beforeForwardRoomToDepartment': ( options: T, ) => Promise; 'livechat.beforeRouteChat': (inquiry: ILivechatInquiryRecord, agent?: { agentId: string; username: string }) => ILivechatInquiryRecord; 'livechat.checkDefaultAgentOnNewRoom': (agent: SelectedAgent, visitor?: ILivechatVisitor) => SelectedAgent | null; 'livechat.onLoadForwardDepartmentRestrictions': (params: { departmentId: string }) => Record; 'livechat.saveInfo': ( newRoom: IOmnichannelRoom, props: { user: Required>; oldRoom: IOmnichannelRoom }, ) => IOmnichannelRoom; 'livechat.onCheckRoomApiParams': (params: Record) => Record; 'livechat.onLoadConfigApi': (config: { room: IOmnichannelRoom }) => Record; 'beforeSaveMessage': (message: IMessage, room?: IRoom) => IMessage; 'afterCreateUser': (user: IUser) => IUser; 'afterDeleteRoom': (rid: IRoom['_id']) => IRoom['_id']; 'livechat:afterOnHold': (room: Pick) => Pick; 'livechat:afterOnHoldChatResumed': (room: Pick) => Pick; 'livechat:onTransferFailure': (params: { room: IRoom; guest: ILivechatVisitor; transferData: { [k: string]: string | any } }) => { room: IRoom; guest: ILivechatVisitor; transferData: { [k: string]: string | any }; }; 'livechat.afterForwardChatToAgent': (params: { rid: IRoom['_id']; servedBy: { _id: string; ts: Date; username?: string }; oldServedBy: { _id: string; ts: Date; username?: string }; }) => { rid: IRoom['_id']; servedBy: { _id: string; ts: Date; username?: string }; oldServedBy: { _id: string; ts: Date; username?: string }; }; 'livechat.afterForwardChatToDepartment': (params: { rid: IRoom['_id']; newDepartmentId: ILivechatDepartmentRecord['_id']; oldDepartmentId: ILivechatDepartmentRecord['_id']; }) => { rid: IRoom['_id']; newDepartmentId: ILivechatDepartmentRecord['_id']; oldDepartmentId: ILivechatDepartmentRecord['_id']; }; 'livechat.afterInquiryQueued': (inquiry: ILivechatInquiryRecord) => ILivechatInquiryRecord; 'livechat.afterRemoveDepartment': (params: { department: ILivechatDepartmentRecord; agentsId: ILivechatAgent['_id'][] }) => { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][]; }; 'livechat.applySimultaneousChatRestrictions': (_: undefined, params: { departmentId?: ILivechatDepartmentRecord['_id'] }) => undefined; 'livechat.beforeDelegateAgent': (agent: SelectedAgent | undefined, params?: { department?: string }) => SelectedAgent | null | undefined; 'livechat.applyDepartmentRestrictions': ( query: FilterOperators, params: { userId: IUser['_id'] }, ) => FilterOperators; 'livechat.applyRoomRestrictions': (query: FilterOperators) => FilterOperators; 'livechat.onMaxNumberSimultaneousChatsReached': (inquiry: ILivechatInquiryRecord) => ILivechatInquiryRecord; 'on-business-hour-start': (params: { BusinessHourBehaviorClass: { new (): IBusinessHourBehavior } }) => { BusinessHourBehaviorClass: { new (): IBusinessHourBehavior }; }; 'renderMessage': (message: T) => T; 'oembed:beforeGetUrlContent': (data: { urlObj: Omit & { host?: unknown; search?: unknown }; parsedUrl: ParsedUrl; }) => { urlObj: UrlWithParsedQuery; parsedUrl: ParsedUrl; }; 'oembed:afterParseContent': (data: { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; parsedUrl: ParsedUrl; content: OEmbedUrlContent; }) => { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; parsedUrl: ParsedUrl; content: OEmbedUrlContent; }; 'livechat.beforeListTags': () => ILivechatTag[]; 'livechat.offlineMessage': (data: { name: string; email: string; message: string; department?: string; host?: string }) => void; 'livechat.chatQueued': (room: IOmnichannelRoom) => IOmnichannelRoom; 'livechat.leadCapture': (room: IOmnichannelRoom) => IOmnichannelRoom; 'beforeSendMessageNotifications': (message: string) => string; 'livechat.onAgentAssignmentFailed': (params: { inquiry: { _id: string; rid: string; status: string; }; room: IOmnichannelRoom; options: { forwardingToDepartment?: { oldDepartmentId: string; transferData: any }; clientAction?: boolean }; }) => (IOmnichannelRoom & { chatQueued: boolean }) | void; }; export type Hook = | keyof EventLikeCallbackSignatures | keyof ChainedCallbackSignatures | 'afterLeaveRoom' | 'afterLogoutCleanUp' | 'afterProcessOAuthUser' | 'afterRemoveFromRoom' | 'afterRoomArchived' | 'afterRoomTopicChange' | 'afterSaveUser' | 'afterValidateNewOAuthUser' | 'archiveRoom' | 'beforeActivateUser' | 'beforeCreateUser' | 'beforeGetMentions' | 'beforeReadMessages' | 'beforeRemoveFromRoom' | 'beforeValidateLogin' | 'livechat.beforeForwardRoomToDepartment' | 'livechat.beforeInquiry' | 'livechat.beforeRoom' | 'livechat.beforeRouteChat' | 'livechat.chatQueued' | 'livechat.checkAgentBeforeTakeInquiry' | 'livechat.sendTranscript' | 'livechat.closeRoom' | 'livechat.offlineMessage' | 'livechat.onCheckRoomApiParams' | 'livechat.onLoadConfigApi' | 'loginPageStateChange' | 'mapLDAPUserData' | 'onCreateUser' | 'onLDAPLogin' | 'onValidateLogin' | 'openBroadcast' | 'renderNotification' | 'roomAnnouncementChanged' | 'roomAvatarChanged' | 'roomNameChanged' | 'roomTopicChanged' | 'roomTypeChanged' | 'setReaction' | 'streamMessage' | 'streamNewMessage' | 'unarchiveRoom' | 'unsetReaction' | 'userAvatarSet' | 'userConfirmationEmailRequested' | 'userForgotPasswordEmailRequested' | 'usernameSet' | 'userPasswordReset' | 'userRegistered' | 'userStatusManuallySet' | 'test'; type Callback = { (item: unknown, constant?: unknown): Promise; hook: Hook; id: string; priority: CallbackPriority; stack: string; }; type CallbackTracker = (callback: Callback) => () => void; type HookTracker = (params: { hook: Hook; length: number }) => () => void; export class Callbacks { private logger: Logger | undefined = undefined; private trackCallback: CallbackTracker | undefined = undefined; private trackHook: HookTracker | undefined = undefined; private callbacks = new Map(); private sequentialRunners = new Map Promise>(); private asyncRunners = new Map unknown>(); readonly priority = CallbackPriority; setLogger(logger: Logger): void { this.logger = logger; } setMetricsTrackers({ trackCallback, trackHook }: { trackCallback?: CallbackTracker; trackHook?: HookTracker }): void { this.trackCallback = trackCallback; this.trackHook = trackHook; } private runOne(callback: Callback, item: unknown, constant: unknown): Promise { const stopTracking = this.trackCallback?.(callback); return Promise.resolve(callback(item, constant)).finally(stopTracking); } private createSequentialRunner(hook: Hook, callbacks: Callback[]): (item: unknown, constant?: unknown) => Promise { const wrapCallback = (callback: Callback) => async (item: unknown, constant?: unknown): Promise => { this.logger?.debug(`Executing callback with id ${callback.id} for hook ${callback.hook}`); return (await this.runOne(callback, item, constant)) ?? item; }; const identity = (item: TItem): Promise => Promise.resolve(item); const pipe = (curr: (item: unknown, constant?: unknown) => Promise, next: (item: unknown, constant?: unknown) => Promise) => async (item: unknown, constant?: unknown): Promise => next(await curr(item, constant), constant); const fn = callbacks.map(wrapCallback).reduce(pipe, identity); return async (item: unknown, constant?: unknown): Promise => { const stopTracking = this.trackHook?.({ hook, length: callbacks.length }); return fn(item, constant).finally(() => stopTracking?.()); }; } private createAsyncRunner(_: Hook, callbacks: Callback[]) { return (item: unknown, constant?: unknown): unknown => { if (typeof window !== 'undefined') { throw new Error('callbacks.runAsync on client server not allowed'); } for (const callback of callbacks) { setTimeout(() => { void this.runOne(callback, item, constant); }, 0); } return item; }; } getCallbacks(hook: Hook): Callback[] { return this.callbacks.get(hook) ?? []; } setCallbacks(hook: Hook, callbacks: Callback[]): void { this.callbacks.set(hook, callbacks); this.sequentialRunners.set(hook, this.createSequentialRunner(hook, callbacks)); this.asyncRunners.set(hook, this.createAsyncRunner(hook, callbacks)); } /** * Add a callback function to a hook * * @param hook the name of the hook * @param callback the callback function * @param priority the callback run priority (order) * @param id human friendly name for this callback */ add( hook: THook, callback: EventLikeCallbackSignatures[THook], priority?: CallbackPriority, id?: string, ): void; add( hook: THook, callback: ChainedCallbackSignatures[THook], priority?: CallbackPriority, id?: string, ): void; add( hook: Hook, callback: (item: TItem, constant?: TConstant) => TNextItem, priority?: CallbackPriority, id?: string, ): void; add(hook: Hook, callback: (item: unknown, constant?: unknown) => unknown, priority = this.priority.MEDIUM, id = Random.id()): void { const callbacks = this.getCallbacks(hook); if (callbacks.some((cb) => cb.id === id)) { return; } callbacks.push( Object.assign(callback as Callback, { hook, priority, id, stack: new Error().stack, }), ); callbacks.sort(compareByRanking((callback: Callback): number => callback.priority ?? this.priority.MEDIUM)); this.setCallbacks(hook, callbacks); } /** * Remove a callback from a hook * * @param hook the name of the hook * @param id the callback's id */ remove(hook: Hook, id: string): void { const hooks = this.getCallbacks(hook).filter((callback) => callback.id !== id); this.setCallbacks(hook, hooks); } run(hook: THook, ...args: Parameters): void; run( hook: THook, ...args: Parameters ): Promise>; run(hook: Hook, item: TItem, constant?: TConstant): Promise; /** * Successively run all of a hook's callbacks on an item * * @param hook the name of the hook * @param item the post, comment, modifier, etc. on which to run the callbacks * @param constant an optional constant that will be passed along to each callback * @returns returns the item after it's been through all the callbacks for this hook */ run(hook: Hook, item: unknown, constant?: unknown): Promise { const runner = this.sequentialRunners.get(hook) ?? (async (item: unknown, _constant?: unknown): Promise => item); return runner(item, constant); } runAsync(hook: THook, ...args: Parameters): void; /** * Successively run all of a hook's callbacks on an item, in async mode (only works on server) * * @param hook the name of the hook * @param item the post, comment, modifier, etc. on which to run the callbacks * @param constant an optional constant that will be passed along to each callback * @returns the post, comment, modifier, etc. on which to run the callbacks */ runAsync(hook: Hook, item: unknown, constant?: unknown): unknown { const runner = this.asyncRunners.get(hook) ?? ((item: unknown, _constant?: unknown): unknown => item); return runner(item, constant); } static create(hook: string): Cb { const callbacks = new Callbacks(); return { add: (callback, priority, id) => callbacks.add(hook as any, callback, priority, id), remove: (id) => callbacks.remove(hook as any, id), run: (item, constant) => callbacks.run(hook as any, item, constant) as any, }; } } /** * Callback hooks provide an easy way to add extra steps to common operations. * @deprecated */ type Cb = { add: (callback: (item: I, constant?: C) => R | undefined, priority?: CallbackPriority, id?: string) => void; remove: (id: string) => void; run: (item: I, constant?: C) => Promise; }; /** * Callback hooks provide an easy way to add extra steps to common operations. * @deprecated */ export const callbacks = new Callbacks();