From 85cc85e4db2790e4627b5de8e968885a7bec2f7f Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 17:50:35 +0100 Subject: [PATCH] feat: fetch remote i18n --- api/.eslintrc.js | 2 +- api/src/channel/channel.service.ts | 19 +- api/src/channel/lib/Handler.ts | 72 +- api/src/channel/types.ts | 8 + api/src/chat/services/block.service.spec.ts | 4 +- api/src/chat/services/block.service.ts | 1 - api/src/chat/services/bot.service.ts | 1 - .../live-chat-tester/i18n/en/label.json | 23 + .../live-chat-tester/i18n/en/title.json | 3 + .../live-chat-tester/i18n/fr/label.json | 23 + .../live-chat-tester/i18n/fr/title.json | 3 + .../live-chat-tester/index.channel.ts | 19 +- .../channels/live-chat-tester/index.d.ts | 18 + .../channels/live-chat-tester/settings.ts | 44 +- .../channels/offline/base-web-channel.ts | 1301 ++++++++++++++++ .../channels/offline/i18n/en/label.json | 23 + .../channels/offline/i18n/en/title.json | 3 + .../channels/offline/i18n/fr/label.json | 23 + .../channels/offline/i18n/fr/title.json | 3 + .../channels/offline/index.channel.ts | 1302 +---------------- .../extensions/channels/offline/index.d.ts | 14 + .../extensions/channels/offline/settings.ts | 52 +- .../extensions/channels/offline/wrapper.ts | 11 +- .../nlp/default/__test__/index.spec.ts | 5 +- .../helpers/nlp/default/index.nlp.helper.ts | 6 +- api/src/i18n/controllers/i18n.controller.ts | 28 + .../i18n/controllers/language.controller.ts | 8 +- api/src/i18n/i18n.module.ts | 2 + api/src/i18n/services/i18n.service.ts | 68 +- .../i18n/services/translation.service.spec.ts | 3 +- api/src/index.d.ts | 10 +- api/src/nlp/lib/BaseNlpHelper.ts | 1 - api/src/plugins/types.ts | 4 +- api/src/seeder.ts | 4 +- api/src/setting/dto/setting.dto.ts | 17 +- api/src/setting/index.d.ts | 31 + api/src/setting/schemas/setting.schema.ts | 8 + api/src/setting/schemas/types.ts | 15 - api/src/setting/seeds/setting.seed-model.ts | 6 +- api/src/setting/services/setting.service.ts | 1 - api/src/user/guards/ability.guard.ts | 2 +- api/tsconfig.json | 4 +- .../public/locales/en/live-chat-tester.json | 28 - frontend/public/locales/en/offline.json | 28 - .../public/locales/fr/live-chat-tester.json | 28 - frontend/public/locales/fr/offline.json | 28 - frontend/src/contexts/setting.context.tsx | 4 + frontend/src/hooks/useRemoteI18n.ts | 49 + frontend/src/i18n/config.ts | 9 +- frontend/src/services/api.class.ts | 7 + 50 files changed, 1823 insertions(+), 1553 deletions(-) create mode 100644 api/src/channel/types.ts create mode 100644 api/src/extensions/channels/live-chat-tester/i18n/en/label.json create mode 100644 api/src/extensions/channels/live-chat-tester/i18n/en/title.json create mode 100644 api/src/extensions/channels/live-chat-tester/i18n/fr/label.json create mode 100644 api/src/extensions/channels/live-chat-tester/i18n/fr/title.json create mode 100644 api/src/extensions/channels/live-chat-tester/index.d.ts create mode 100644 api/src/extensions/channels/offline/base-web-channel.ts create mode 100644 api/src/extensions/channels/offline/i18n/en/label.json create mode 100644 api/src/extensions/channels/offline/i18n/en/title.json create mode 100644 api/src/extensions/channels/offline/i18n/fr/label.json create mode 100644 api/src/extensions/channels/offline/i18n/fr/title.json create mode 100644 api/src/extensions/channels/offline/index.d.ts create mode 100644 api/src/i18n/controllers/i18n.controller.ts create mode 100644 api/src/setting/index.d.ts delete mode 100644 frontend/public/locales/en/live-chat-tester.json delete mode 100644 frontend/public/locales/en/offline.json delete mode 100644 frontend/public/locales/fr/live-chat-tester.json delete mode 100644 frontend/public/locales/fr/offline.json create mode 100644 frontend/src/hooks/useRemoteI18n.ts diff --git a/api/.eslintrc.js b/api/.eslintrc.js index 76726423..41837772 100644 --- a/api/.eslintrc.js +++ b/api/.eslintrc.js @@ -72,4 +72,4 @@ module.exports = { }, ], }, -}; +}; \ No newline at end of file diff --git a/api/src/channel/channel.service.ts b/api/src/channel/channel.service.ts index af278965..e87d0f3f 100644 --- a/api/src/channel/channel.service.ts +++ b/api/src/channel/channel.service.ts @@ -26,7 +26,7 @@ import ChannelHandler from './lib/Handler'; @Injectable() export class ChannelService { - private registry: Map = new Map(); + private registry: Map> = new Map(); constructor( private readonly logger: LoggerService, @@ -40,7 +40,10 @@ export class ChannelService { * @param channel - The channel handler associated with the channel name. * @typeParam C The channel handler's type that extends `ChannelHandler`. */ - public setChannel(name: string, channel: C) { + public setChannel>( + name: T, + channel: C, + ) { this.registry.set(name, channel); } @@ -71,7 +74,9 @@ export class ChannelService { * @param channelName - The name of the channel (messenger, offline, ...). * @returns The handler for the specified channel. */ - public getChannelHandler(name: string): C { + public getChannelHandler>( + name: T, + ): C { const handler = this.registry.get(name); if (!handler) { throw new Error(`Channel ${name} not found`); @@ -98,8 +103,8 @@ export class ChannelService { * @param req - The websocket request object. * @param res - The websocket response object. */ - @SocketGet('/webhook/offline/') - @SocketPost('/webhook/offline/') + @SocketGet(`/webhook/${OFFLINE_CHANNEL_NAME}/`) + @SocketPost(`/webhook/${OFFLINE_CHANNEL_NAME}/`) handleWebsocketForOffline( @SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse, @@ -116,8 +121,8 @@ export class ChannelService { * @param req - The websocket request object. * @param res - The websocket response object. */ - @SocketGet('/webhook/live-chat-tester/') - @SocketPost('/webhook/live-chat-tester/') + @SocketGet(`/webhook/${LIVE_CHAT_TEST_CHANNEL_NAME}/`) + @SocketPost(`/webhook/${LIVE_CHAT_TEST_CHANNEL_NAME}/`) async handleWebsocketForLiveChatTester( @SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse, diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 0f3767a3..05ca8ad3 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -18,35 +18,57 @@ import { import { LoggerService } from '@/logger/logger.service'; import BaseNlpHelper from '@/nlp/lib/BaseNlpHelper'; import { NlpService } from '@/nlp/services/nlp.service'; -import { SettingCreateDto } from '@/setting/dto/setting.dto'; import { SettingService } from '@/setting/services/setting.service'; import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import { ChannelService } from '../channel.service'; +import { ChannelSetting } from '../types'; + +import EventWrapper from './EventWrapper'; import EventWrapper from './EventWrapper'; @Injectable() -export default abstract class ChannelHandler { - protected settings: SettingCreateDto[] = []; +export default abstract class ChannelHandler { + private readonly name: N; + + private readonly settings: ChannelSetting[]; protected NLP: BaseNlpHelper; constructor( + name: N, + settings: ChannelSetting[], protected readonly settingService: SettingService, private readonly channelService: ChannelService, protected readonly nlpService: NlpService, protected readonly logger: LoggerService, - ) {} + ) { + this.name = name; + this.settings = settings; + } onModuleInit() { - this.channelService.setChannel(this.getChannel(), this); + this.channelService.setChannel( + this.getChannel(), + this as unknown as ChannelHandler, + ); this.setup(); } + protected getGroup() { + return this.getChannel().replaceAll('-', '_'); + } + async setup() { - await this.settingService.seedIfNotExist(this.getChannel(), this.settings); + await this.settingService.seedIfNotExist( + this.getChannel(), + this.settings.map((s, i) => ({ + ...s, + weight: i + 1, + })), + ); const nlp = this.nlpService.getNLP(); this.setNLP(nlp); this.init(); @@ -61,30 +83,32 @@ export default abstract class ChannelHandler { } /** - * Returns the channel specific settings + * Returns the channel's name + * @returns Channel's name */ - async getSettings() { - const settings = await this.settingService.getSettings(); - return settings[this.getChannel()] as S; + getChannel() { + return this.name; } /** - * Returns the channel's name - * @returns {String} + * Returns the channel's settings + * @returns Channel's settings */ - abstract getChannel(): string; + async getSettings() { + const settings = await this.settingService.getSettings(); + return settings[this.getGroup()]; + } /** * Perform any initialization needed - * @returns - */ abstract init(): void; /** + * Process incoming channel data via POST/GET methods + * * @param {module:Controller.req} req * @param {module:Controller.res} res - * Process incoming channel data via POST/GET methods */ abstract handle( req: Request | SocketRequest, @@ -93,26 +117,28 @@ export default abstract class ChannelHandler { /** * Format a text message that will be sent to the channel + * * @param message - A text to be sent to the end user * @param options - might contain additional settings * @returns {Object} - A text message in the channel specific format - */ abstract _textFormat(message: StdOutgoingMessage, options?: any): any; /** + * Format a text + quick replies message that can be sent to the channel + * * @param message - A text + quick replies to be sent to the end user * @param options - might contain additional settings * @returns {Object} - A quick replies message in the channel specific format - * Format a text + quick replies message that can be sent to the channel */ abstract _quickRepliesFormat(message: StdOutgoingMessage, options?: any): any; /** + * From raw buttons, construct a channel understable message containing those buttons + * * @param message - A text + buttons to be sent to the end user * @param options - Might contain additional settings * @returns {Object} - A buttons message in the format required by the channel - * From raw buttons, construct a channel understable message containing those buttons */ abstract _buttonsFormat( message: StdOutgoingMessage, @@ -121,27 +147,29 @@ export default abstract class ChannelHandler { ): any; /** + * Format an attachment + quick replies message that can be sent to the channel + * * @param message - An attachment + quick replies to be sent to the end user * @param options - Might contain additional settings * @returns {Object} - An attachment message in the format required by the channel - * Format an attachment + quick replies message that can be sent to the channel */ abstract _attachmentFormat(message: StdOutgoingMessage, options?: any): any; /** + * Format a collection of items to be sent to the channel in carousel/list format + * * @param data - A list of data items to be sent to the end user * @param options - Might contain additional settings * @returns {Object[]} - An array of element objects - * Format a collection of items to be sent to the channel in carousel/list format */ abstract _formatElements(data: any[], options: any, ...args: any): any[]; /** * Format a list of elements + * * @param message - Contains elements to be sent to the end user * @param options - Might contain additional settings * @returns {Object} - A ready to be sent list template message in the format required by the channel - */ abstract _listFormat( message: StdOutgoingMessage, diff --git a/api/src/channel/types.ts b/api/src/channel/types.ts new file mode 100644 index 00000000..4b9f3329 --- /dev/null +++ b/api/src/channel/types.ts @@ -0,0 +1,8 @@ +import { SettingCreateDto } from '@/setting/dto/setting.dto'; + +export type ChannelSetting = Omit< + SettingCreateDto, + 'group' | 'weight' +> & { + group: HyphenToUnderscore; +}; diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 36fdc5df..a4824ce6 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -34,7 +34,6 @@ import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; import { PluginService } from '@/plugins/plugins.service'; -import { Settings } from '@/setting/schemas/types'; import { SettingService } from '@/setting/services/setting.service'; import { blockFixtures, @@ -67,6 +66,9 @@ import { FileType } from '../schemas/types/attachment'; import { Context } from '../schemas/types/context'; import { PayloadType, StdOutgoingListMessage } from '../schemas/types/message'; import { SubscriberContext } from '../schemas/types/subscriberContext'; +import { CategoryRepository } from './../repositories/category.repository'; +import { BlockService } from './block.service'; +import { CategoryService } from './category.service'; import { CategoryRepository } from './../repositories/category.repository'; import { BlockService } from './block.service'; diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 6b34de94..952a6af6 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -18,7 +18,6 @@ import { LoggerService } from '@/logger/logger.service'; import { Nlp } from '@/nlp/lib/types'; import { PluginService } from '@/plugins/plugins.service'; import { PluginType } from '@/plugins/types'; -import { Settings } from '@/setting/schemas/types'; import { SettingService } from '@/setting/services/setting.service'; import { BaseService } from '@/utils/generics/base-service'; import { getRandom } from '@/utils/helpers/safeRandom'; diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 9ff1937a..132769f5 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -11,7 +11,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import EventWrapper from '@/channel/lib/EventWrapper'; import { LoggerService } from '@/logger/logger.service'; -import { Settings } from '@/setting/schemas/types'; import { SettingService } from '@/setting/services/setting.service'; import { MessageCreateDto } from '../dto/message.dto'; diff --git a/api/src/extensions/channels/live-chat-tester/i18n/en/label.json b/api/src/extensions/channels/live-chat-tester/i18n/en/label.json new file mode 100644 index 00000000..3c717b86 --- /dev/null +++ b/api/src/extensions/channels/live-chat-tester/i18n/en/label.json @@ -0,0 +1,23 @@ +{ + "verification_token": "Verification Token", + "allowed_domains": "Allowed Domains", + "start_button": "Enable `Get Started`", + "input_disabled": "Disable Input", + "persistent_menu": "Display Persistent Menu", + "greeting_message": "Greeting Message", + "theme_color": "Widget Theme", + "theme_color_options": { + "orange": "Orange", + "red": "Red", + "green": "Green", + "blue": "Blue", + "dark": "Dark" + }, + "window_title": "Chat Window Title", + "avatar_url": "Chatbot Avatar URL", + "show_emoji": "Enable Emoji Picker", + "show_file": "Enable Attachment Uploader", + "show_location": "Enable Geolocation Share", + "allowed_upload_size": "Max Upload Size (in bytes)", + "allowed_upload_types": "Allowed Upload Mime Types (comma separated)" +} diff --git a/api/src/extensions/channels/live-chat-tester/i18n/en/title.json b/api/src/extensions/channels/live-chat-tester/i18n/en/title.json new file mode 100644 index 00000000..67ff4226 --- /dev/null +++ b/api/src/extensions/channels/live-chat-tester/i18n/en/title.json @@ -0,0 +1,3 @@ +{ + "live_chat_tester": "Live Chat Tester" +} diff --git a/api/src/extensions/channels/live-chat-tester/i18n/fr/label.json b/api/src/extensions/channels/live-chat-tester/i18n/fr/label.json new file mode 100644 index 00000000..23c3bb70 --- /dev/null +++ b/api/src/extensions/channels/live-chat-tester/i18n/fr/label.json @@ -0,0 +1,23 @@ +{ + "verification_token": "Jeton de vérification", + "allowed_domains": "Domaines autorisés", + "start_button": "Activer `Démarrer`", + "input_disabled": "Désactiver la saisie", + "persistent_menu": "Afficher le menu persistent", + "greeting_message": "Message de bienvenue", + "theme_color": "Thème du widget", + "theme_color_options": { + "orange": "Orange", + "red": "Rouge", + "green": "Vert", + "blue": "Bleu", + "dark": "Sombre" + }, + "window_title": "Titre de la fenêtre de chat", + "avatar_url": "Avatar du chatbot (URL)", + "show_emoji": "Activer le sélecteur d'Emojis", + "show_file": "Activer l'upload de fichiers", + "show_location": "Activer le partage de géolocalisation", + "allowed_upload_size": "Taille maximale de téléchargement (en octets)", + "allowed_upload_types": "Types MIME autorisés pour le téléchargement (séparés par des virgules)" +} diff --git a/api/src/extensions/channels/live-chat-tester/i18n/fr/title.json b/api/src/extensions/channels/live-chat-tester/i18n/fr/title.json new file mode 100644 index 00000000..6671fd6a --- /dev/null +++ b/api/src/extensions/channels/live-chat-tester/i18n/fr/title.json @@ -0,0 +1,3 @@ +{ + "live_chat_tester": "Testeur Live Chat" +} diff --git a/api/src/extensions/channels/live-chat-tester/index.channel.ts b/api/src/extensions/channels/live-chat-tester/index.channel.ts index 33dc1d1f..6f8868bd 100644 --- a/api/src/extensions/channels/live-chat-tester/index.channel.ts +++ b/api/src/extensions/channels/live-chat-tester/index.channel.ts @@ -17,11 +17,10 @@ import { MenuService } from '@/cms/services/menu.service'; import { I18nService } from '@/i18n/services/i18n.service'; import { LoggerService } from '@/logger/logger.service'; import { NlpService } from '@/nlp/services/nlp.service'; -import { SettingCreateDto } from '@/setting/dto/setting.dto'; import { SettingService } from '@/setting/services/setting.service'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; -import OfflineHandler from '../offline/index.channel'; +import BaseWebChannelHandler from '../offline/base-web-channel'; import { DEFAULT_LIVE_CHAT_TEST_SETTINGS, @@ -29,9 +28,9 @@ import { } from './settings'; @Injectable() -export default class LiveChatTesterHandler extends OfflineHandler { - protected settings: SettingCreateDto[] = DEFAULT_LIVE_CHAT_TEST_SETTINGS; - +export default class LiveChatTesterHandler extends BaseWebChannelHandler< + typeof LIVE_CHAT_TEST_CHANNEL_NAME +> { constructor( settingService: SettingService, channelService: ChannelService, @@ -46,6 +45,8 @@ export default class LiveChatTesterHandler extends OfflineHandler { websocketGateway: WebsocketGateway, ) { super( + LIVE_CHAT_TEST_CHANNEL_NAME, + DEFAULT_LIVE_CHAT_TEST_SETTINGS, settingService, channelService, nlpService, @@ -59,12 +60,4 @@ export default class LiveChatTesterHandler extends OfflineHandler { websocketGateway, ); } - - /** - * Returns the channel's name - * @returns {String} - */ - getChannel() { - return LIVE_CHAT_TEST_CHANNEL_NAME; - } } diff --git a/api/src/extensions/channels/live-chat-tester/index.d.ts b/api/src/extensions/channels/live-chat-tester/index.d.ts new file mode 100644 index 00000000..9e5d4f76 --- /dev/null +++ b/api/src/extensions/channels/live-chat-tester/index.d.ts @@ -0,0 +1,18 @@ +import { + DEFAULT_LIVE_CHAT_TEST_SETTINGS, + LIVE_CHAT_TEST_CHANNEL_NAME, +} from './settings'; + +declare global { + interface Settings + extends SettingTree {} +} + +declare module '@nestjs/event-emitter' { + interface IHookSettingsGroupLabelOperationMap { + [name: HyphenToUnderscore]: TDefinition< + object, + SettingObject + >; + } +} diff --git a/api/src/extensions/channels/live-chat-tester/settings.ts b/api/src/extensions/channels/live-chat-tester/settings.ts index 9272a690..fe9e657e 100644 --- a/api/src/extensions/channels/live-chat-tester/settings.ts +++ b/api/src/extensions/channels/live-chat-tester/settings.ts @@ -6,99 +6,89 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { ChannelSetting } from '@/channel/types'; import { config } from '@/config'; -import { SettingCreateDto } from '@/setting/dto/setting.dto'; import { SettingType } from '@/setting/schemas/types'; import { Offline } from '../offline/types'; export const LIVE_CHAT_TEST_CHANNEL_NAME = 'live-chat-tester'; -export const DEFAULT_LIVE_CHAT_TEST_SETTINGS: SettingCreateDto[] = [ +export const LIVE_CHAT_TEST_GROUP_NAME = 'live_chat_tester'; + +export const DEFAULT_LIVE_CHAT_TEST_SETTINGS = [ { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.verification_token, value: 'test', type: SettingType.text, - weight: 2, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.allowed_domains, value: config.frontendPath, type: SettingType.text, - weight: 3, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.start_button, value: true, type: SettingType.checkbox, - weight: 4, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.input_disabled, value: false, type: SettingType.checkbox, - weight: 5, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.persistent_menu, value: true, type: SettingType.checkbox, - weight: 6, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.greeting_message, value: 'Welcome! Ready to start a conversation with our chatbot?', type: SettingType.textarea, - weight: 7, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.theme_color, value: 'teal', type: SettingType.select, options: ['teal', 'orange', 'red', 'green', 'blue', 'dark'], - weight: 8, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.show_emoji, value: true, type: SettingType.checkbox, - weight: 11, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.show_file, value: true, type: SettingType.checkbox, - weight: 12, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.show_location, value: true, type: SettingType.checkbox, - weight: 13, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.allowed_upload_size, value: 2500000, type: SettingType.number, - weight: 14, }, { - group: LIVE_CHAT_TEST_CHANNEL_NAME, + group: LIVE_CHAT_TEST_GROUP_NAME, label: Offline.SettingLabel.allowed_upload_types, value: 'audio/mpeg,audio/x-ms-wma,audio/vnd.rn-realaudio,audio/x-wav,image/gif,image/jpeg,image/png,image/tiff,image/vnd.microsoft.icon,image/vnd.djvu,image/svg+xml,text/css,text/csv,text/html,text/plain,text/xml,video/mpeg,video/mp4,video/quicktime,video/x-ms-wmv,video/x-msvideo,video/x-flv,video/web,application/msword,application/vnd.ms-powerpoint,application/pdf,application/vnd.ms-excel,application/vnd.oasis.opendocument.presentation,application/vnd.oasis.opendocument.tex,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.graphics,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: SettingType.textarea, - weight: 15, }, -]; +] as const satisfies ChannelSetting[]; diff --git a/api/src/extensions/channels/offline/base-web-channel.ts b/api/src/extensions/channels/offline/base-web-channel.ts new file mode 100644 index 00000000..108879be --- /dev/null +++ b/api/src/extensions/channels/offline/base-web-channel.ts @@ -0,0 +1,1301 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { promises as fsPromises } from 'fs'; +import path from 'path'; + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { Request, Response } from 'express'; +import multer, { diskStorage } from 'multer'; +import { Socket } from 'socket.io'; +import { v4 as uuidv4 } from 'uuid'; + +import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { AttachmentService } from '@/attachment/services/attachment.service'; +import { ChannelService } from '@/channel/channel.service'; +import EventWrapper from '@/channel/lib/EventWrapper'; +import ChannelHandler from '@/channel/lib/Handler'; +import { ChannelSetting } from '@/channel/types'; +import { MessageCreateDto } from '@/chat/dto/message.dto'; +import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; +import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants'; +import { Subscriber, SubscriberFull } from '@/chat/schemas/subscriber.schema'; +import { WithUrl } from '@/chat/schemas/types/attachment'; +import { Button, ButtonType } from '@/chat/schemas/types/button'; +import { + AnyMessage, + FileType, + IncomingMessage, + OutgoingMessage, + OutgoingMessageFormat, + PayloadType, + StdEventType, + StdOutgoingAttachmentMessage, + StdOutgoingButtonsMessage, + StdOutgoingEnvelope, + StdOutgoingListMessage, + StdOutgoingMessage, + StdOutgoingQuickRepliesMessage, + StdOutgoingTextMessage, +} from '@/chat/schemas/types/message'; +import { BlockOptions } from '@/chat/schemas/types/options'; +import { MessageService } from '@/chat/services/message.service'; +import { SubscriberService } from '@/chat/services/subscriber.service'; +import { Content } from '@/cms/schemas/content.schema'; +import { MenuService } from '@/cms/services/menu.service'; +import { config } from '@/config'; +import { I18nService } from '@/i18n/services/i18n.service'; +import { LoggerService } from '@/logger/logger.service'; +import { NlpService } from '@/nlp/services/nlp.service'; +import { SettingService } from '@/setting/services/setting.service'; +import { SocketRequest } from '@/websocket/utils/socket-request'; +import { SocketResponse } from '@/websocket/utils/socket-response'; +import { WebsocketGateway } from '@/websocket/websocket.gateway'; + +import { Offline } from './types'; +import OfflineEventWrapper from './wrapper'; + +@Injectable() +export default class BaseWebChannelHandler< + N extends string, +> extends ChannelHandler { + constructor( + name: N, + settings: ChannelSetting[], + settingService: SettingService, + channelService: ChannelService, + nlpService: NlpService, + logger: LoggerService, + protected readonly eventEmitter: EventEmitter2, + protected readonly i18n: I18nService, + protected readonly subscriberService: SubscriberService, + protected readonly attachmentService: AttachmentService, + protected readonly messageService: MessageService, + protected readonly menuService: MenuService, + private readonly websocketGateway: WebsocketGateway, + ) { + super(name, settings, settingService, channelService, nlpService, logger); + } + + /** + * No init needed for the moment + * + * @returns - + */ + init(): void { + this.logger.debug('Offline Channel Handler : initialization ...'); + } + + /** + * Verify offline websocket connection and return settings + * + * @param client - The socket client + */ + @OnEvent('hook:websocket:connection', { async: true }) + async onWebSocketConnection(client: Socket) { + const settings = await this.getSettings(); + const handshake = client.handshake; + const { channel } = handshake.query; + if (channel !== this.getChannel()) { + return; + } + try { + const { verification_token } = client.handshake.query; + await this.verifyToken(verification_token.toString()); + try { + this.logger.debug( + 'Offline Channel Handler : WS connected .. sending settings', + ); + try { + const menu = await this.menuService.getTree(); + return client.emit('settings', { menu, ...settings }); + } catch (err) { + this.logger.warn( + 'Offline Channel Handler : Unable to retrieve menu ', + err, + ); + return client.emit('settings', settings); + } + } catch (err) { + this.logger.warn( + 'Offline Channel Handler : Unable to verify token, disconnecting ...', + err, + ); + client.disconnect(); + return; + } + } catch (err) { + this.logger.error( + 'Offline Channel Handler : Unable to initiate websocket connection', + err, + ); + } + } + + /** + * Adapt incoming message structure for offline channel + * + * @param incoming - Incoming message + * @returns Formatted offline message + */ + private formatIncomingHistoryMessage( + incoming: IncomingMessage, + ): Offline.IncomingMessageBase { + // Format incoming message + if ('type' in incoming.message) { + if (incoming.message.type === PayloadType.location) { + const coordinates = incoming.message.coordinates; + return { + type: Offline.IncomingMessageType.location, + data: { + coordinates: { + lat: coordinates.lat, + lng: coordinates.lon, + }, + }, + }; + } else { + // @TODO : handle multiple files + const attachment = Array.isArray(incoming.message.attachment) + ? incoming.message.attachment[0] + : incoming.message.attachment; + return { + type: Offline.IncomingMessageType.file, + data: { + type: attachment.type, + url: attachment.payload.url, + }, + }; + } + } else { + return { + type: Offline.IncomingMessageType.text, + data: incoming.message, + }; + } + } + + /** + * Adapt the outgoing message structure for offline channel + * + * @param outgoing - The outgoing message + * @returns Formatted offline message + */ + private formatOutgoingHistoryMessage( + outgoing: OutgoingMessage, + ): Offline.OutgoingMessageBase { + // Format outgoing message + if ('buttons' in outgoing.message) { + return this._buttonsFormat(outgoing.message); + } else if ('attachment' in outgoing.message) { + return this._attachmentFormat(outgoing.message); + } else if ('quickReplies' in outgoing.message) { + return this._quickRepliesFormat(outgoing.message); + } else if ('options' in outgoing.message) { + if (outgoing.message.options.display === 'carousel') { + return this._carouselFormat(outgoing.message, { + content: outgoing.message.options, + }); + } else { + return this._listFormat(outgoing.message, { + content: outgoing.message.options, + }); + } + } else { + return this._textFormat(outgoing.message); + } + } + + /** + * Adapt the message structure for offline channel + * + * @param messages - The messages to be formatted + * + * @returns Formatted message + */ + private formatHistoryMessages(messages: AnyMessage[]): Offline.Message[] { + return messages.map((anyMessage: AnyMessage) => { + if ('sender' in anyMessage && anyMessage.sender) { + return { + ...this.formatIncomingHistoryMessage(anyMessage as IncomingMessage), + author: anyMessage.sender, + read: true, // Temporary fix as read is false in the bd + mid: anyMessage.mid, + createdAt: anyMessage.createdAt, + } as Offline.IncomingMessage; + } else { + const outgoingMessage = anyMessage as OutgoingMessage; + return { + ...this.formatOutgoingHistoryMessage(outgoingMessage), + author: 'chatbot', + read: true, // Temporary fix as read is false in the bd + mid: outgoingMessage.mid, + handover: !!outgoingMessage.handover, + createdAt: outgoingMessage.createdAt, + } as Offline.OutgoingMessage; + } + }); + } + + /** + * Fetches the messaging history from the DB. + * + * @param until - Date before which to fetch + * @param n - Number of messages to fetch + * @returns Promise to an array of message, rejects into error. + * Promise to fetch the 'n' last message since a giving date the session profile. + */ + protected async fetchHistory( + req: Request | SocketRequest, + until: Date = new Date(), + n: number = 30, + ): Promise { + const profile = req.session?.offline?.profile; + if (profile) { + const messages = await this.messageService.findHistoryUntilDate( + profile, + until, + n, + ); + return this.formatHistoryMessages(messages.reverse()); + } + return []; + } + + /** + * Poll new messages by a giving start datetime + * + * @param since - Date after which to fetch + * @param n - Number of messages to fetch + * @returns Promise to an array of message, rejects into error. + * Promise to fetch the 'n' new messages since a giving date for the session profile. + */ + private async pollMessages( + req: Request, + since: Date = new Date(10e14), + n: number = 30, + ): Promise { + const profile = req.session?.offline?.profile; + if (profile) { + const messages = await this.messageService.findHistorySinceDate( + profile, + since, + n, + ); + return this.formatHistoryMessages(messages); + } + return []; + } + + /** + * Verify the received token. + * + * @param verificationToken - Verification Token + */ + private async verifyToken(verificationToken: string) { + const settings = await this.getSettings(); + const verifyToken = settings.verification_token; + + if (!verifyToken) { + throw new Error('You need to specify a verifyToken in your config.'); + } + if (!verificationToken) { + throw new Error('Did not recieve any verification token.'); + } + if (verificationToken !== verifyToken) { + throw new Error('Make sure the validation tokens match.'); + } + this.logger.log( + 'Offline Channel Handler : Token has been verified successfully!', + ); + } + + /** + * Verify the origin against whitelisted domains. + * + * @param req + * @param res + */ + private async validateCors( + req: Request | SocketRequest, + res: Response | SocketResponse, + ) { + const settings = await this.getSettings(); + // If we have an origin header... + if (req.headers && req.headers.origin) { + // Get the allowed origins + const origins: string[] = settings.allowed_domains.split(','); + const foundOrigin = origins.some((origin: string) => { + origin = origin.trim(); + // If we find a whitelisted origin, send the Access-Control-Allow-Origin header + // to greenlight the request. + return origin == req.headers.origin || origin == '*'; + }); + + if (!foundOrigin) { + // For HTTP requests, set the Access-Control-Allow-Origin header to '', which the browser will + // interpret as, 'no way Jose.' + res.set('Access-Control-Allow-Origin', ''); + this.logger.debug( + 'Offline Channel Handler : No origin found ', + req.headers.origin, + ); + throw new Error('CORS - Domain not allowed!'); + } else { + res.set('Access-Control-Allow-Origin', req.headers.origin); + } + // Determine whether or not to allow cookies to be passed cross-origin + res.set('Access-Control-Allow-Credentials', 'true'); + // This header lets a server whitelist headers that browsers are allowed to access + res.set('Access-Control-Expose-Headers', ''); + // Handle preflight requests + if (req.method == 'OPTIONS') { + res.set('Access-Control-Allow-Methods', 'GET, POST'); + res.set('Access-Control-Allow-Headers', 'content-type'); + } + return; + } + this.logger.debug('Offline Channel Handler : No origin ', req.headers); + throw new Error('CORS - No origin provided!'); + } + + /** + * Makes sure that message request is legitimate. + * + * @param req + * @param res + */ + private validateSession( + req: Request | SocketRequest, + res: Response | SocketResponse, + next: (profile: Subscriber) => void, + ) { + if (!req.session?.offline?.profile?.id) { + this.logger.warn( + 'Offline Channel Handler : No session ID to be found!', + req.session, + ); + return res + .status(403) + .json({ err: 'Offline Channel Handler : Unauthorized!' }); + } else if ( + ('isSocket' in req && !!req.isSocket !== req.session.offline.isSocket) || + !Array.isArray(req.session.offline.messageQueue) + ) { + this.logger.warn( + 'Offline Channel Handler : Mixed channel request or invalid session data!', + req.session, + ); + return res + .status(403) + .json({ err: 'Offline Channel Handler : Unauthorized!' }); + } + next(req.session?.offline?.profile); + } + + /** + * Perform all security measures on the request + * + * @param req + * @param res + */ + private async checkRequest( + req: Request | SocketRequest, + res: Response | SocketResponse, + ) { + try { + await this.validateCors(req, res); + try { + const { verification_token } = + 'verification_token' in req.query ? req.query : req.body; + await this.verifyToken(verification_token); + } catch (err) { + this.logger.warn( + 'Offline Channel Handler : Unable to verify token ', + err, + ); + throw new Error('Unauthorized, invalid token!'); + } + } catch (err) { + this.logger.warn( + 'Offline Channel Handler : Attempt to access from an unauthorized origin', + err, + ); + throw new Error('Unauthorized, invalid origin !'); + } + } + + /** + * Get or create a session profile for the subscriber + * + * @param req + * + * @returns Subscriber's profile + */ + protected async getOrCreateSession( + req: Request | SocketRequest, + ): Promise { + const data = req.query; + // Subscriber has already a session + const sessionProfile = req.session?.offline?.profile; + if (sessionProfile) { + const subscriber = await this.subscriberService.findOneAndPopulate( + sessionProfile.id, + ); + if (!subscriber || !req.session.offline) { + throw new Error('Subscriber session was not persisted in DB'); + } + req.session.offline.profile = subscriber; + return subscriber; + } + + const channelData = this.getChannelData(req); + const newProfile: SubscriberCreateDto = { + foreign_id: this.generateId(), + first_name: data.first_name ? data.first_name.toString() : 'Anon.', + last_name: data.last_name ? data.last_name.toString() : 'Offline User', + assignedTo: null, + assignedAt: null, + lastvisit: new Date(), + retainedFrom: new Date(), + channel: { + ...channelData, + name: this.getChannel(), + }, + language: '', + locale: '', + timezone: 0, + gender: 'male', + country: '', + labels: [], + }; + const subscriber = await this.subscriberService.create(newProfile); + // Init session + const profile: SubscriberFull = { + ...subscriber, + labels: [], + assignedTo: null, + avatar: null, + }; + + req.session.offline = { + profile, + isSocket: 'isSocket' in req && !!req.isSocket, + messageQueue: [], + polling: false, + }; + return profile; + } + + /** + * Return message queue (using by long polling case only) + * + * @param req + * @param res + */ + private getMessageQueue(req: Request, res: Response) { + // Polling not authorized when using websockets + if ('isSocket' in req && req.isSocket) { + this.logger.warn( + 'Offline Channel Handler : Polling not authorized when using websockets', + ); + return res + .status(403) + .json({ err: 'Polling not authorized when using websockets' }); + } + // Session must be active + if ( + !(req.session && req.session.offline && req.session.offline.profile.id) + ) { + this.logger.warn( + 'Offline Channel Handler : Must be connected to poll messages', + ); + return res + .status(403) + .json({ err: 'Polling not authorized : Must be connected' }); + } + + // Can only request polling once at a time + if (req.session && req.session.offline && req.session.offline.polling) { + this.logger.warn( + 'Offline Channel Handler : Poll rejected ... already requested', + ); + return res + .status(403) + .json({ err: 'Poll rejected ... already requested' }); + } + + req.session.offline.polling = true; + + const fetchMessages = async (req: Request, res: Response, retrials = 1) => { + try { + const since = new Date(req.query.since.toString()); + const messages = await this.pollMessages(req, since); + if (messages.length === 0 && retrials <= 5) { + // No messages found, retry after 5 sec + setTimeout(async () => { + await fetchMessages(req, res, retrials * 2); + }, retrials * 1000); + } else if (req.session.offline) { + req.session.offline.polling = false; + return res.status(200).json(messages.map((msg) => ['message', msg])); + } else { + this.logger.error( + 'Offline Channel Handler : Polling failed .. no session data', + ); + return res.status(500).json({ err: 'No session data' }); + } + } catch (err) { + if (req.session.offline) { + req.session.offline.polling = false; + } + this.logger.error('Offline Channel Handler : Polling failed', err); + return res.status(500).json({ err: 'Polling failed' }); + } + }; + fetchMessages(req, res); + } + + /** + * Allow the subscription to a offline's webhook after verification + * + * @param req + * @param res + */ + protected async subscribe( + req: Request | SocketRequest, + res: Response | SocketResponse, + ) { + this.logger.debug( + 'Offline Channel Handler : subscribe (isSocket=' + + ('isSocket' in req && !!req.isSocket) + + ')', + ); + try { + const profile = await this.getOrCreateSession(req); + // Join socket room when using websocket + if ('isSocket' in req && !!req.isSocket) { + try { + await req.socket.join(profile.foreign_id); + } catch (err) { + this.logger.error( + 'Offline Channel Handler : Unable to subscribe via websocket', + err, + ); + } + } + // Fetch message history + const criteria = + 'since' in req.query + ? req.query.since // Long polling case + : req.body?.since || undefined; // Websocket case + return this.fetchHistory(req, criteria).then((messages) => { + return res.status(200).json({ profile, messages }); + }); + } catch (err) { + this.logger.warn('Offline Channel Handler : Unable to subscribe ', err); + return res.status(500).json({ err: 'Unable to subscribe' }); + } + } + + /** + * Upload file as attachment if provided + * + * @param upload + * @param filename + */ + private async storeAttachment( + upload: Omit, + filename: string, + next: ( + err: Error | null, + payload: { type: string; url: string } | false, + ) => void, + ): Promise { + try { + this.logger.debug('Offline Channel Handler : Successfully uploaded file'); + + const attachment = await this.attachmentService.create({ + name: upload.name || '', + type: upload.type || 'text/txt', + size: upload.size || 0, + location: filename, + channel: { offline: {} }, + }); + + this.logger.debug( + 'Offline Channel Handler : Successfully stored file as attachment', + ); + + next(null, { + type: Attachment.getTypeByMime(attachment.type), + url: Attachment.getAttachmentUrl(attachment.id, attachment.name), + }); + } catch (err) { + this.logger.error( + 'Offline Channel Handler : Unable to store uploaded file as attachment', + err, + ); + next(err, false); + } + } + + /** + * Upload file as attachment if provided + * + * @param req + * @param res + */ + async handleFilesUpload( + req: Request | SocketRequest, + res: Response | SocketResponse, + next: ( + err: null | Error, + result: { type: string; url: string } | false, + ) => void, + ): Promise { + const data: Offline.IncomingMessage = req.body; + // Check if any file is provided + if (!req.session.offline) { + this.logger.debug('Offline Channel Handler : No session provided'); + return next(null, false); + } + // Check if any file is provided + if (!data || !data.type || data.type !== 'file') { + this.logger.debug('Offline Channel Handler : No files provided'); + return next(null, false); + } + + // Parse json form data (in case of content-type multipart/form-data) + data.data = + typeof data.data === 'string' ? JSON.parse(data.data) : data.data; + + // Check max size upload + const upload = data.data; + if (typeof upload.size === 'undefined') { + return next(new Error('File upload probably failed.'), false); + } + + // Store file as attachment + const dirPath = path.join(config.parameters.uploadDir); + const filename = `${req.session.offline.profile.id}_${+new Date()}_${ + upload.name + }`; + if ('isSocket' in req && req.isSocket) { + // @TODO : test this + try { + await fsPromises.writeFile(path.join(dirPath, filename), upload.file); + this.storeAttachment(upload, filename, next); + } catch (err) { + this.logger.error( + 'Offline Channel Handler : Unable to write uploaded file', + err, + ); + return next(new Error('Unable to upload file!'), false); + } + } else { + const upload = multer({ + storage: diskStorage({ + destination: dirPath, // Set the destination directory for file storage + filename: (_req, _file, cb) => { + cb(null, filename); // Set the file name + }, + }), + }).single('file'); // 'file' is the field name in the form + + upload(req as Request, res as Response, (err) => { + if (err) { + this.logger.error( + 'Offline Channel Handler : Unable to write uploaded file', + err, + ); + return next(new Error('Unable to upload file!'), false); + } + // @TODO : test upload + // @ts-expect-error @TODO : This needs to be fixed at a later point + const file = req.file; + this.storeAttachment( + { + name: file.filename, + type: file.mimetype as FileType, // @Todo : test this + size: file.size, + }, + file.path.replace(dirPath, ''), + next, + ); + }); + } + } + + /** + * Returns the request client IP address + * + * @param req + * + * @returns IP Address + */ + private getIpAddress(req: Request | SocketRequest): string { + if ('isSocket' in req && req.isSocket) { + return req.socket.handshake.address; + } else if (Array.isArray(req.ips) && req.ips.length > 0) { + // If config.http.trustProxy is enabled, this variable contains the IP addresses + // in this request's "X-Forwarded-For" header as an array of the IP address strings. + // Otherwise an empty array is returned. + return req.ips.join(','); + } else { + return req.ip || '0.0.0.0'; + } + } + + /** + * Handle channel event (probably a message) + * + * @param req + * + * @returns The channel's data + */ + protected getChannelData(req: Request | SocketRequest): Offline.ChannelData { + return { + isSocket: 'isSocket' in req && !!req.isSocket, + ipAddress: this.getIpAddress(req), + agent: req.headers['user-agent'], + }; + } + + /** + * Handle channel event (probably a message) + * + * @param req + * @param res + */ + _handleEvent( + req: Request | SocketRequest, + res: Response | SocketResponse, + ): void { + const data: Offline.IncomingMessage = req.body; + this.validateSession(req, res, (profile) => { + this.handleFilesUpload( + req, + res, + // @ts-expect-error @TODO : This needs to be fixed at a later point @TODO + (err: Error, upload: Offline.IncomingMessageData) => { + if (err) { + this.logger.warn( + 'Offline Channel Handler : Unable to upload file ', + err, + ); + return res + .status(403) + .json({ err: 'Offline Channel Handler : File upload failed!' }); + } + // Set data in file upload case + if (upload) { + data.data = upload; + } + const channelData = this.getChannelData(req); + const event: OfflineEventWrapper = new OfflineEventWrapper( + this, + data, + channelData, + ); + if (event.getEventType() === 'message') { + // Handler sync message sent by chabbot + if (data.sync && data.author === 'chatbot') { + const sentMessage: MessageCreateDto = { + mid: event.getId(), + message: event.getMessage() as StdOutgoingMessage, + recipient: profile.id, + read: true, + delivery: true, + }; + this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event); + return res.status(200).json(event._adapter.raw); + } else { + // Generate unique ID and handle message + event.set('mid', this.generateId()); + } + } + // Force author id from session + event.set('author', profile.foreign_id); + event.setSender(profile); + + const type = event.getEventType(); + if (type) { + this.eventEmitter.emit(`hook:chatbot:${type}`, event); + } else { + this.logger.error( + 'Offline Channel Handler : Webhook received unknown event ', + event, + ); + } + res.status(200).json(event._adapter.raw); + }, + ); + }); + } + + /** + * Process incoming Offline data (finding out its type and assigning it to its proper handler) + * + * @param req + * @param res + */ + async handle(req: Request | SocketRequest, res: Response | SocketResponse) { + const settings = await this.getSettings(); + // Offline messaging can be done through websockets or long-polling + try { + await this.checkRequest(req, res); + if (req.method === 'GET') { + if (!('isSocket' in req) && req.query._get) { + switch (req.query._get) { + case 'settings': + this.logger.debug( + 'Offline Channel Handler : connected .. sending settings', + ); + try { + const menu = await this.menuService.getTree(); + return res.status(200).json({ + menu, + server_date: new Date().toISOString(), + ...settings, + }); + } catch (err) { + this.logger.warn( + 'Offline Channel Handler : Unable to retrieve menu ', + err, + ); + return res.status(500).json({ err: 'Unable to retrieve menu' }); + } + case 'polling': + // Handle polling when user is not connected via websocket + return this.getMessageQueue(req, res as Response); + default: + this.logger.error( + 'Offline Channel Handler : Webhook received unknown command', + ); + return res + .status(500) + .json({ err: 'Webhook received unknown command' }); + } + } else if (req.query._disconnect) { + req.session.offline = undefined; + return res.status(200).json({ _disconnect: true }); + } else { + // Handle webhook subscribe requests + return await this.subscribe(req, res); + } + } else { + // Handle incoming messages (through POST) + return this._handleEvent(req, res); + } + } catch (err) { + this.logger.warn('Offline Channel Handler : Request check failed', err); + return res + .status(403) + .json({ err: 'Offline Channel Handler : Unauthorized!' }); + } + } + + /** + * Returns a unique identifier for the subscriber + * + * @returns UUID + */ + generateId(): string { + return 'offline-' + uuidv4(); + } + + /** + * Formats a text message that will be sent to the widget + * + * @param message - A text to be sent to the end user + * @param _options - might contain additional settings + * + * @returns A ready to be sent text message + */ + _textFormat( + message: StdOutgoingTextMessage, + _options?: BlockOptions, + ): Offline.OutgoingMessageBase { + return { + type: Offline.OutgoingMessageType.text, + data: message, + }; + } + + /** + * Formats a text + quick replies message that can be sent back + * + * @param message - A text + quick replies to be sent to the end user + * @param _options - might contain additional settings + * + * @returns A ready to be sent text message + */ + _quickRepliesFormat( + message: StdOutgoingQuickRepliesMessage, + _options?: BlockOptions, + ): Offline.OutgoingMessageBase { + return { + type: Offline.OutgoingMessageType.quick_replies, + data: { + text: message.text, + quick_replies: message.quickReplies, + }, + }; + } + + /** + * Formats a text + buttons message that can be sent back + * + * @param message - A text + buttons to be sent to the end user + * @param _options - Might contain additional settings + * + * @returns A formatted Object understandable by the widget + */ + _buttonsFormat( + message: StdOutgoingButtonsMessage, + _options?: BlockOptions, + ): Offline.OutgoingMessageBase { + return { + type: Offline.OutgoingMessageType.buttons, + data: { + text: message.text, + buttons: message.buttons, + }, + }; + } + + /** + * Formats an attachment + quick replies message that can be sent to the widget + * + * @param message - An attachment + quick replies to be sent to the end user + * @param _options - Might contain additional settings + * + * @returns A ready to be sent attachment message + */ + _attachmentFormat( + message: StdOutgoingAttachmentMessage>, + _options?: BlockOptions, + ): Offline.OutgoingMessageBase { + const payload: Offline.OutgoingMessageBase = { + type: Offline.OutgoingMessageType.file, + data: { + type: message.attachment.type, + url: message.attachment.payload.url, + }, + }; + if (message.quickReplies && message.quickReplies.length > 0) { + payload.data.quick_replies = message.quickReplies; + } + return payload; + } + + /** + * Formats a collection of items to be sent to the widget (carousel/list) + * + * @param data - A list of data items to be sent to the end user + * @param options - Might contain additional settings + * + * @returns An array of elements object + */ + _formatElements( + data: any[], + options: BlockOptions, + ): Offline.MessageElement[] { + if (!options.content || !options.content.fields) { + throw new Error('Content options are missing the fields'); + } + + const fields = options.content.fields; + const buttons: Button[] = options.content.buttons; + return data.map((item) => { + const element: Offline.MessageElement = { + title: item[fields.title], + buttons: item.buttons || [], + }; + if (fields.subtitle && item[fields.subtitle]) { + element.subtitle = item[fields.subtitle]; + } + if (fields.image_url && item[fields.image_url]) { + const attachmentPayload = item[fields.image_url].payload; + if (attachmentPayload.url) { + if (!attachmentPayload.id) { + // @deprecated + this.logger.warn( + 'Offline Channel Handler: Attachment remote url has been deprecated', + item, + ); + } + element.image_url = attachmentPayload.url; + } + } + + buttons.forEach((button: Button, index) => { + const btn = { ...button }; + if (btn.type === ButtonType.web_url) { + // Get built-in or an external URL from custom field + const urlField = fields.url; + btn.url = + urlField && item[urlField] ? item[urlField] : Content.getUrl(item); + if (!btn.url.startsWith('http')) { + btn.url = 'https://' + btn.url; + } + // Set default action the same as the first web_url button + if (!element.default_action) { + const { title: _title, ...defaultAction } = btn; + element.default_action = defaultAction; + } + } else { + if ( + 'action_payload' in fields && + fields.action_payload && + fields.action_payload in item + ) { + btn.payload = btn.title + ':' + item[fields.action_payload]; + } else { + const postback = Content.getPayload(item); + btn.payload = btn.title + ':' + postback; + } + } + // Set custom title for first button if provided + if (index === 0 && fields.action_title && item[fields.action_title]) { + btn.title = item[fields.action_title]; + } + element.buttons?.push(btn); + }); + if (Array.isArray(element.buttons) && element.buttons.length === 0) { + delete element.buttons; + } + return element; + }); + } + + /** + * Format a list of elements + * + * @param message - Contains elements to be sent to the end user + * @param options - Might contain additional settings + * + * @returns A ready to be sent list template message + */ + _listFormat( + message: StdOutgoingListMessage, + options: BlockOptions, + ): Offline.OutgoingMessageBase { + const data = message.elements || []; + const pagination = message.pagination; + let buttons: Button[] = [], + elements: Offline.MessageElement[] = []; + + // Items count min check + if (!data.length) { + this.logger.error( + 'Offline Channel Handler : Unsufficient content count (must be >= 0 for list)', + ); + throw new Error('Unsufficient content count (list >= 0)'); + } + + // Toggle "View More" button (check if there's more items to display) + if (pagination.total - pagination.skip - pagination.limit > 0) { + buttons = [ + { + type: ButtonType.postback, + title: this.i18n.t('View More'), + payload: VIEW_MORE_PAYLOAD, + }, + ]; + } + + // Populate items (elements/cards) with content + elements = this._formatElements(data, options); + const topElementStyle = options.content?.top_element_style + ? { + top_element_style: options.content?.top_element_style, + } + : {}; + return { + type: Offline.OutgoingMessageType.list, + data: { + elements, + buttons, + ...topElementStyle, + }, + }; + } + + /** + * Format a carousel message + * + * @param message - Contains elements to be sent to the end user + * @param options - Might contain additional settings + * + * @returns A carousel ready to be sent as a message + */ + _carouselFormat( + message: StdOutgoingListMessage, + options: BlockOptions, + ): Offline.OutgoingMessageBase { + const data = message.elements || []; + // Items count min check + if (data.length === 0) { + this.logger.error( + 'Offline Channel Handler : Unsufficient content count (must be > 0 for carousel)', + ); + throw new Error('Unsufficient content count (carousel > 0)'); + } + + // Populate items (elements/cards) with content + const elements = this._formatElements(data, options); + return { + type: Offline.OutgoingMessageType.carousel, + data: { + elements, + }, + }; + } + + /** + * Creates an widget compliant data structure for any message envelope + * + * @param envelope - The message standard envelope + * @param options - The block options related to the message + * + * @returns A template filled with its payload + */ + _formatMessage( + envelope: StdOutgoingEnvelope, + options: BlockOptions, + ): Offline.OutgoingMessageBase { + switch (envelope.format) { + case OutgoingMessageFormat.attachment: + return this._attachmentFormat(envelope.message, options); + case OutgoingMessageFormat.buttons: + return this._buttonsFormat(envelope.message, options); + case OutgoingMessageFormat.carousel: + return this._carouselFormat(envelope.message, options); + case OutgoingMessageFormat.list: + return this._listFormat(envelope.message, options); + case OutgoingMessageFormat.quickReplies: + return this._quickRepliesFormat(envelope.message, options); + case OutgoingMessageFormat.text: + return this._textFormat(envelope.message, options); + + default: + throw new Error('Unknown message format'); + } + } + + /** + * Sends a message to the end-user using websocket + * + * @param subscriber - End-user toward which message will be sent + * @param type - The message to be sent (message, typing, ...) + * @param content - The message payload contain additional settings + */ + private broadcast( + subscriber: Subscriber, + type: StdEventType, + content: any, + ): void { + if (subscriber.channel.isSocket) { + this.websocketGateway.broadcast(subscriber, type, content); + } else { + // Do nothing, messages will be retrieved via polling + } + } + + /** + * Send a Offline Message to the end-user + * + * @param event - Incoming event/message being responded to + * @param envelope - The message to be sent {format, message} + * @param options - Might contain additional settings + * @param _context - Contextual data + * + * @returns The offline's response, otherwise an error + */ + async sendMessage( + event: EventWrapper, + envelope: StdOutgoingEnvelope, + options: BlockOptions, + _context?: any, + ): Promise<{ mid: string }> { + const messageBase: Offline.OutgoingMessageBase = this._formatMessage( + envelope, + options, + ); + const subscriber = event.getSender(); + + const message: Offline.OutgoingMessage = { + ...messageBase, + mid: this.generateId(), + author: 'chatbot', + createdAt: new Date(), + handover: !!(options && options.assignTo), + }; + const next = async (): Promise => { + this.broadcast(subscriber, StdEventType.message, message); + return { mid: message.mid }; + }; + + if (options && options.typing) { + const autoTimeout = + message && message.data && 'text' in message.data + ? message.data.text.length * 10 + : 1000; + const timeout = + typeof options.typing === 'number' ? options.typing : autoTimeout; + try { + await this.sendTypingIndicator(subscriber, timeout); + return next(); + } catch (err) { + this.logger.error( + 'Offline Channel Handler : Failed in sending typing indicator ', + err, + ); + } + } + + return next(); + } + + /** + * Send a typing indicator (waterline) to the end user for a given duration + * + * @param recipient - The end-user object + * @param timeout - Duration of the typing indicator in milliseconds + */ + async sendTypingIndicator( + recipient: Subscriber, + timeout: number, + ): Promise { + return new Promise((resolve, reject) => { + try { + this.broadcast(recipient, StdEventType.typing, true); + setTimeout(() => { + this.broadcast(recipient, StdEventType.typing, false); + return resolve(); + }, timeout); + } catch (err) { + reject(err); + } + }); + } + + /** + * Fetch the end-user profile data + * + * @param event - The message event received + * + * @returns The offline's response, otherwise an error + */ + async getUserData(event: OfflineEventWrapper): Promise { + return event.getSender() as SubscriberCreateDto; + } +} diff --git a/api/src/extensions/channels/offline/i18n/en/label.json b/api/src/extensions/channels/offline/i18n/en/label.json new file mode 100644 index 00000000..3c717b86 --- /dev/null +++ b/api/src/extensions/channels/offline/i18n/en/label.json @@ -0,0 +1,23 @@ +{ + "verification_token": "Verification Token", + "allowed_domains": "Allowed Domains", + "start_button": "Enable `Get Started`", + "input_disabled": "Disable Input", + "persistent_menu": "Display Persistent Menu", + "greeting_message": "Greeting Message", + "theme_color": "Widget Theme", + "theme_color_options": { + "orange": "Orange", + "red": "Red", + "green": "Green", + "blue": "Blue", + "dark": "Dark" + }, + "window_title": "Chat Window Title", + "avatar_url": "Chatbot Avatar URL", + "show_emoji": "Enable Emoji Picker", + "show_file": "Enable Attachment Uploader", + "show_location": "Enable Geolocation Share", + "allowed_upload_size": "Max Upload Size (in bytes)", + "allowed_upload_types": "Allowed Upload Mime Types (comma separated)" +} diff --git a/api/src/extensions/channels/offline/i18n/en/title.json b/api/src/extensions/channels/offline/i18n/en/title.json new file mode 100644 index 00000000..74d381c7 --- /dev/null +++ b/api/src/extensions/channels/offline/i18n/en/title.json @@ -0,0 +1,3 @@ +{ + "offline": "Canal Web" +} diff --git a/api/src/extensions/channels/offline/i18n/fr/label.json b/api/src/extensions/channels/offline/i18n/fr/label.json new file mode 100644 index 00000000..23c3bb70 --- /dev/null +++ b/api/src/extensions/channels/offline/i18n/fr/label.json @@ -0,0 +1,23 @@ +{ + "verification_token": "Jeton de vérification", + "allowed_domains": "Domaines autorisés", + "start_button": "Activer `Démarrer`", + "input_disabled": "Désactiver la saisie", + "persistent_menu": "Afficher le menu persistent", + "greeting_message": "Message de bienvenue", + "theme_color": "Thème du widget", + "theme_color_options": { + "orange": "Orange", + "red": "Rouge", + "green": "Vert", + "blue": "Bleu", + "dark": "Sombre" + }, + "window_title": "Titre de la fenêtre de chat", + "avatar_url": "Avatar du chatbot (URL)", + "show_emoji": "Activer le sélecteur d'Emojis", + "show_file": "Activer l'upload de fichiers", + "show_location": "Activer le partage de géolocalisation", + "allowed_upload_size": "Taille maximale de téléchargement (en octets)", + "allowed_upload_types": "Types MIME autorisés pour le téléchargement (séparés par des virgules)" +} diff --git a/api/src/extensions/channels/offline/i18n/fr/title.json b/api/src/extensions/channels/offline/i18n/fr/title.json new file mode 100644 index 00000000..6671fd6a --- /dev/null +++ b/api/src/extensions/channels/offline/i18n/fr/title.json @@ -0,0 +1,3 @@ +{ + "live_chat_tester": "Testeur Live Chat" +} diff --git a/api/src/extensions/channels/offline/index.channel.ts b/api/src/extensions/channels/offline/index.channel.ts index f439df1a..3b71989c 100644 --- a/api/src/extensions/channels/offline/index.channel.ts +++ b/api/src/extensions/channels/offline/index.channel.ts @@ -6,1304 +6,54 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { promises as fsPromises } from 'fs'; -import path from 'path'; - import { Injectable } from '@nestjs/common'; -import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { Request, Response } from 'express'; -import multer, { diskStorage } from 'multer'; -import { Socket } from 'socket.io'; -import { v4 as uuidv4 } from 'uuid'; +import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { ChannelService } from '@/channel/channel.service'; -import EventWrapper from '@/channel/lib/EventWrapper'; -import ChannelHandler from '@/channel/lib/Handler'; -import { MessageCreateDto } from '@/chat/dto/message.dto'; -import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; -import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants'; -import { Subscriber, SubscriberFull } from '@/chat/schemas/subscriber.schema'; -import { WithUrl } from '@/chat/schemas/types/attachment'; -import { Button, ButtonType } from '@/chat/schemas/types/button'; -import { - AnyMessage, - FileType, - IncomingMessage, - OutgoingMessage, - OutgoingMessageFormat, - PayloadType, - StdEventType, - StdOutgoingAttachmentMessage, - StdOutgoingButtonsMessage, - StdOutgoingEnvelope, - StdOutgoingListMessage, - StdOutgoingMessage, - StdOutgoingQuickRepliesMessage, - StdOutgoingTextMessage, -} from '@/chat/schemas/types/message'; -import { BlockOptions } from '@/chat/schemas/types/options'; import { MessageService } from '@/chat/services/message.service'; import { SubscriberService } from '@/chat/services/subscriber.service'; -import { Content } from '@/cms/schemas/content.schema'; import { MenuService } from '@/cms/services/menu.service'; -import { config } from '@/config'; import { I18nService } from '@/i18n/services/i18n.service'; import { LoggerService } from '@/logger/logger.service'; import { NlpService } from '@/nlp/services/nlp.service'; -import { SettingCreateDto } from '@/setting/dto/setting.dto'; import { SettingService } from '@/setting/services/setting.service'; -import { SocketRequest } from '@/websocket/utils/socket-request'; -import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; +import BaseWebChannelHandler from './base-web-channel'; import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_CHANNEL_NAME } from './settings'; -import { Offline } from './types'; -import OfflineEventWrapper from './wrapper'; @Injectable() -export default class OfflineHandler extends ChannelHandler { - protected settings: SettingCreateDto[] = DEFAULT_OFFLINE_SETTINGS; - +export default class OfflineHandler extends BaseWebChannelHandler< + typeof OFFLINE_CHANNEL_NAME +> { constructor( settingService: SettingService, channelService: ChannelService, nlpService: NlpService, logger: LoggerService, - protected readonly eventEmitter: EventEmitter2, - protected readonly i18n: I18nService, - protected readonly subscriberService: SubscriberService, - protected readonly attachmentService: AttachmentService, - protected readonly messageService: MessageService, - protected readonly menuService: MenuService, - private readonly websocketGateway: WebsocketGateway, + eventEmitter: EventEmitter2, + i18n: I18nService, + subscriberService: SubscriberService, + attachmentService: AttachmentService, + messageService: MessageService, + menuService: MenuService, + websocketGateway: WebsocketGateway, ) { - super(settingService, channelService, nlpService, logger); - } - - /** - * Returns the channel's name - * - * @returns Name of the channel - */ - getChannel() { - return OFFLINE_CHANNEL_NAME; - } - - /** - * No init needed for the moment - * - * @returns - - */ - init(): void { - this.logger.debug('Offline Channel Handler : initialization ...'); - } - - /** - * Verify offline websocket connection and return settings - * - * @param client - The socket client - */ - @OnEvent('hook:websocket:connection', { async: true }) - async onWebSocketConnection(client: Socket) { - const settings = await this.getSettings(); - const handshake = client.handshake; - const { channel } = handshake.query; - if (channel !== this.getChannel()) { - return; - } - try { - const { verification_token } = client.handshake.query; - await this.verifyToken(verification_token.toString()); - try { - this.logger.debug( - 'Offline Channel Handler : WS connected .. sending settings', - ); - try { - const menu = await this.menuService.getTree(); - return client.emit('settings', { menu, ...settings }); - } catch (err) { - this.logger.warn( - 'Offline Channel Handler : Unable to retrieve menu ', - err, - ); - return client.emit('settings', settings); - } - } catch (err) { - this.logger.warn( - 'Offline Channel Handler : Unable to verify token, disconnecting ...', - err, - ); - client.disconnect(); - return; - } - } catch (err) { - this.logger.error( - 'Offline Channel Handler : Unable to initiate websocket connection', - err, - ); - } - } - - /** - * Adapt incoming message structure for offline channel - * - * @param incoming - Incoming message - * @returns Formatted offline message - */ - private formatIncomingHistoryMessage( - incoming: IncomingMessage, - ): Offline.IncomingMessageBase { - // Format incoming message - if ('type' in incoming.message) { - if (incoming.message.type === PayloadType.location) { - const coordinates = incoming.message.coordinates; - return { - type: Offline.IncomingMessageType.location, - data: { - coordinates: { - lat: coordinates.lat, - lng: coordinates.lon, - }, - }, - }; - } else { - // @TODO : handle multiple files - const attachment = Array.isArray(incoming.message.attachment) - ? incoming.message.attachment[0] - : incoming.message.attachment; - return { - type: Offline.IncomingMessageType.file, - data: { - type: attachment.type, - url: attachment.payload.url, - }, - }; - } - } else { - return { - type: Offline.IncomingMessageType.text, - data: incoming.message, - }; - } - } - - /** - * Adapt the outgoing message structure for offline channel - * - * @param outgoing - The outgoing message - * @returns Formatted offline message - */ - private formatOutgoingHistoryMessage( - outgoing: OutgoingMessage, - ): Offline.OutgoingMessageBase { - // Format outgoing message - if ('buttons' in outgoing.message) { - return this._buttonsFormat(outgoing.message); - } else if ('attachment' in outgoing.message) { - return this._attachmentFormat(outgoing.message); - } else if ('quickReplies' in outgoing.message) { - return this._quickRepliesFormat(outgoing.message); - } else if ('options' in outgoing.message) { - if (outgoing.message.options.display === 'carousel') { - return this._carouselFormat(outgoing.message, { - content: outgoing.message.options, - }); - } else { - return this._listFormat(outgoing.message, { - content: outgoing.message.options, - }); - } - } else { - return this._textFormat(outgoing.message); - } - } - - /** - * Adapt the message structure for offline channel - * - * @param messages - The messages to be formatted - * - * @returns Formatted message - */ - private formatHistoryMessages(messages: AnyMessage[]): Offline.Message[] { - return messages.map((anyMessage: AnyMessage) => { - if ('sender' in anyMessage && anyMessage.sender) { - return { - ...this.formatIncomingHistoryMessage(anyMessage as IncomingMessage), - author: anyMessage.sender, - read: true, // Temporary fix as read is false in the bd - mid: anyMessage.mid, - createdAt: anyMessage.createdAt, - } as Offline.IncomingMessage; - } else { - const outgoingMessage = anyMessage as OutgoingMessage; - return { - ...this.formatOutgoingHistoryMessage(outgoingMessage), - author: 'chatbot', - read: true, // Temporary fix as read is false in the bd - mid: outgoingMessage.mid, - handover: !!outgoingMessage.handover, - createdAt: outgoingMessage.createdAt, - } as Offline.OutgoingMessage; - } - }); - } - - /** - * Fetches the messaging history from the DB. - * - * @param until - Date before which to fetch - * @param n - Number of messages to fetch - * @returns Promise to an array of message, rejects into error. - * Promise to fetch the 'n' last message since a giving date the session profile. - */ - protected async fetchHistory( - req: Request | SocketRequest, - until: Date = new Date(), - n: number = 30, - ): Promise { - const profile = req.session?.offline?.profile; - if (profile) { - const messages = await this.messageService.findHistoryUntilDate( - profile, - until, - n, - ); - return this.formatHistoryMessages(messages.reverse()); - } - return []; - } - - /** - * Poll new messages by a giving start datetime - * - * @param since - Date after which to fetch - * @param n - Number of messages to fetch - * @returns Promise to an array of message, rejects into error. - * Promise to fetch the 'n' new messages since a giving date for the session profile. - */ - private async pollMessages( - req: Request, - since: Date = new Date(10e14), - n: number = 30, - ): Promise { - const profile = req.session?.offline?.profile; - if (profile) { - const messages = await this.messageService.findHistorySinceDate( - profile, - since, - n, - ); - return this.formatHistoryMessages(messages); - } - return []; - } - - /** - * Verify the received token. - * - * @param verificationToken - Verification Token - */ - private async verifyToken(verificationToken: string) { - const settings = await this.getSettings(); - const verifyToken = settings.verification_token; - - if (!verifyToken) { - throw new Error('You need to specify a verifyToken in your config.'); - } - if (!verificationToken) { - throw new Error('Did not recieve any verification token.'); - } - if (verificationToken !== verifyToken) { - throw new Error('Make sure the validation tokens match.'); - } - this.logger.log( - 'Offline Channel Handler : Token has been verified successfully!', + super( + OFFLINE_CHANNEL_NAME, + DEFAULT_OFFLINE_SETTINGS, + settingService, + channelService, + nlpService, + logger, + eventEmitter, + i18n, + subscriberService, + attachmentService, + messageService, + menuService, + websocketGateway, ); } - - /** - * Verify the origin against whitelisted domains. - * - * @param req - * @param res - */ - private async validateCors( - req: Request | SocketRequest, - res: Response | SocketResponse, - ) { - const settings = await this.getSettings(); - // If we have an origin header... - if (req.headers && req.headers.origin) { - // Get the allowed origins - const origins: string[] = settings.allowed_domains.split(','); - const foundOrigin = origins.some((origin: string) => { - origin = origin.trim(); - // If we find a whitelisted origin, send the Access-Control-Allow-Origin header - // to greenlight the request. - return origin == req.headers.origin || origin == '*'; - }); - - if (!foundOrigin) { - // For HTTP requests, set the Access-Control-Allow-Origin header to '', which the browser will - // interpret as, 'no way Jose.' - res.set('Access-Control-Allow-Origin', ''); - this.logger.debug( - 'Offline Channel Handler : No origin found ', - req.headers.origin, - ); - throw new Error('CORS - Domain not allowed!'); - } else { - res.set('Access-Control-Allow-Origin', req.headers.origin); - } - // Determine whether or not to allow cookies to be passed cross-origin - res.set('Access-Control-Allow-Credentials', 'true'); - // This header lets a server whitelist headers that browsers are allowed to access - res.set('Access-Control-Expose-Headers', ''); - // Handle preflight requests - if (req.method == 'OPTIONS') { - res.set('Access-Control-Allow-Methods', 'GET, POST'); - res.set('Access-Control-Allow-Headers', 'content-type'); - } - return; - } - this.logger.debug('Offline Channel Handler : No origin ', req.headers); - throw new Error('CORS - No origin provided!'); - } - - /** - * Makes sure that message request is legitimate. - * - * @param req - * @param res - */ - private validateSession( - req: Request | SocketRequest, - res: Response | SocketResponse, - next: (profile: Subscriber) => void, - ) { - if (!req.session?.offline?.profile?.id) { - this.logger.warn( - 'Offline Channel Handler : No session ID to be found!', - req.session, - ); - return res - .status(403) - .json({ err: 'Offline Channel Handler : Unauthorized!' }); - } else if ( - ('isSocket' in req && !!req.isSocket !== req.session.offline.isSocket) || - !Array.isArray(req.session.offline.messageQueue) - ) { - this.logger.warn( - 'Offline Channel Handler : Mixed channel request or invalid session data!', - req.session, - ); - return res - .status(403) - .json({ err: 'Offline Channel Handler : Unauthorized!' }); - } - next(req.session?.offline?.profile); - } - - /** - * Perform all security measures on the request - * - * @param req - * @param res - */ - private async checkRequest( - req: Request | SocketRequest, - res: Response | SocketResponse, - ) { - try { - await this.validateCors(req, res); - try { - const { verification_token } = - 'verification_token' in req.query ? req.query : req.body; - await this.verifyToken(verification_token); - } catch (err) { - this.logger.warn( - 'Offline Channel Handler : Unable to verify token ', - err, - ); - throw new Error('Unauthorized, invalid token!'); - } - } catch (err) { - this.logger.warn( - 'Offline Channel Handler : Attempt to access from an unauthorized origin', - err, - ); - throw new Error('Unauthorized, invalid origin !'); - } - } - - /** - * Get or create a session profile for the subscriber - * - * @param req - * - * @returns Subscriber's profile - */ - protected async getOrCreateSession( - req: Request | SocketRequest, - ): Promise { - const data = req.query; - // Subscriber has already a session - const sessionProfile = req.session?.offline?.profile; - if (sessionProfile) { - const subscriber = await this.subscriberService.findOneAndPopulate( - sessionProfile.id, - ); - if (!subscriber || !req.session.offline) { - throw new Error('Subscriber session was not persisted in DB'); - } - req.session.offline.profile = subscriber; - return subscriber; - } - - const channelData = this.getChannelData(req); - const newProfile: SubscriberCreateDto = { - foreign_id: this.generateId(), - first_name: data.first_name ? data.first_name.toString() : 'Anon.', - last_name: data.last_name ? data.last_name.toString() : 'Offline User', - assignedTo: null, - assignedAt: null, - lastvisit: new Date(), - retainedFrom: new Date(), - channel: { - ...channelData, - name: this.getChannel(), - }, - language: '', - locale: '', - timezone: 0, - gender: 'male', - country: '', - labels: [], - }; - const subscriber = await this.subscriberService.create(newProfile); - // Init session - const profile: SubscriberFull = { - ...subscriber, - labels: [], - assignedTo: null, - avatar: null, - }; - - req.session.offline = { - profile, - isSocket: 'isSocket' in req && !!req.isSocket, - messageQueue: [], - polling: false, - }; - return profile; - } - - /** - * Return message queue (using by long polling case only) - * - * @param req - * @param res - */ - private getMessageQueue(req: Request, res: Response) { - // Polling not authorized when using websockets - if ('isSocket' in req && req.isSocket) { - this.logger.warn( - 'Offline Channel Handler : Polling not authorized when using websockets', - ); - return res - .status(403) - .json({ err: 'Polling not authorized when using websockets' }); - } - // Session must be active - if ( - !(req.session && req.session.offline && req.session.offline.profile.id) - ) { - this.logger.warn( - 'Offline Channel Handler : Must be connected to poll messages', - ); - return res - .status(403) - .json({ err: 'Polling not authorized : Must be connected' }); - } - - // Can only request polling once at a time - if (req.session && req.session.offline && req.session.offline.polling) { - this.logger.warn( - 'Offline Channel Handler : Poll rejected ... already requested', - ); - return res - .status(403) - .json({ err: 'Poll rejected ... already requested' }); - } - - req.session.offline.polling = true; - - const fetchMessages = async (req: Request, res: Response, retrials = 1) => { - try { - const since = new Date(req.query.since.toString()); - const messages = await this.pollMessages(req, since); - if (messages.length === 0 && retrials <= 5) { - // No messages found, retry after 5 sec - setTimeout(async () => { - await fetchMessages(req, res, retrials * 2); - }, retrials * 1000); - } else if (req.session.offline) { - req.session.offline.polling = false; - return res.status(200).json(messages.map((msg) => ['message', msg])); - } else { - this.logger.error( - 'Offline Channel Handler : Polling failed .. no session data', - ); - return res.status(500).json({ err: 'No session data' }); - } - } catch (err) { - if (req.session.offline) { - req.session.offline.polling = false; - } - this.logger.error('Offline Channel Handler : Polling failed', err); - return res.status(500).json({ err: 'Polling failed' }); - } - }; - fetchMessages(req, res); - } - - /** - * Allow the subscription to a offline's webhook after verification - * - * @param req - * @param res - */ - protected async subscribe( - req: Request | SocketRequest, - res: Response | SocketResponse, - ) { - this.logger.debug( - 'Offline Channel Handler : subscribe (isSocket=' + - ('isSocket' in req && !!req.isSocket) + - ')', - ); - try { - const profile = await this.getOrCreateSession(req); - // Join socket room when using websocket - if ('isSocket' in req && !!req.isSocket) { - try { - await req.socket.join(profile.foreign_id); - } catch (err) { - this.logger.error( - 'Offline Channel Handler : Unable to subscribe via websocket', - err, - ); - } - } - // Fetch message history - const criteria = - 'since' in req.query - ? req.query.since // Long polling case - : req.body?.since || undefined; // Websocket case - return this.fetchHistory(req, criteria).then((messages) => { - return res.status(200).json({ profile, messages }); - }); - } catch (err) { - this.logger.warn('Offline Channel Handler : Unable to subscribe ', err); - return res.status(500).json({ err: 'Unable to subscribe' }); - } - } - - /** - * Upload file as attachment if provided - * - * @param upload - * @param filename - */ - private async storeAttachment( - upload: Omit, - filename: string, - next: ( - err: Error | null, - payload: { type: string; url: string } | false, - ) => void, - ): Promise { - try { - this.logger.debug('Offline Channel Handler : Successfully uploaded file'); - - const attachment = await this.attachmentService.create({ - name: upload.name || '', - type: upload.type || 'text/txt', - size: upload.size || 0, - location: filename, - channel: { offline: {} }, - }); - - this.logger.debug( - 'Offline Channel Handler : Successfully stored file as attachment', - ); - - next(null, { - type: Attachment.getTypeByMime(attachment.type), - url: Attachment.getAttachmentUrl(attachment.id, attachment.name), - }); - } catch (err) { - this.logger.error( - 'Offline Channel Handler : Unable to store uploaded file as attachment', - err, - ); - next(err, false); - } - } - - /** - * Upload file as attachment if provided - * - * @param req - * @param res - */ - async handleFilesUpload( - req: Request | SocketRequest, - res: Response | SocketResponse, - next: ( - err: null | Error, - result: { type: string; url: string } | false, - ) => void, - ): Promise { - const data: Offline.IncomingMessage = req.body; - // Check if any file is provided - if (!req.session.offline) { - this.logger.debug('Offline Channel Handler : No session provided'); - return next(null, false); - } - // Check if any file is provided - if (!data || !data.type || data.type !== 'file') { - this.logger.debug('Offline Channel Handler : No files provided'); - return next(null, false); - } - - // Parse json form data (in case of content-type multipart/form-data) - data.data = - typeof data.data === 'string' ? JSON.parse(data.data) : data.data; - - // Check max size upload - const upload = data.data; - if (typeof upload.size === 'undefined') { - return next(new Error('File upload probably failed.'), false); - } - - // Store file as attachment - const dirPath = path.join(config.parameters.uploadDir); - const filename = `${req.session.offline.profile.id}_${+new Date()}_${ - upload.name - }`; - if ('isSocket' in req && req.isSocket) { - // @TODO : test this - try { - await fsPromises.writeFile(path.join(dirPath, filename), upload.file); - this.storeAttachment(upload, filename, next); - } catch (err) { - this.logger.error( - 'Offline Channel Handler : Unable to write uploaded file', - err, - ); - return next(new Error('Unable to upload file!'), false); - } - } else { - const upload = multer({ - storage: diskStorage({ - destination: dirPath, // Set the destination directory for file storage - filename: (_req, _file, cb) => { - cb(null, filename); // Set the file name - }, - }), - }).single('file'); // 'file' is the field name in the form - - upload(req as Request, res as Response, (err) => { - if (err) { - this.logger.error( - 'Offline Channel Handler : Unable to write uploaded file', - err, - ); - return next(new Error('Unable to upload file!'), false); - } - // @TODO : test upload - // @ts-expect-error @TODO : This needs to be fixed at a later point - const file = req.file; - this.storeAttachment( - { - name: file.filename, - type: file.mimetype as FileType, // @Todo : test this - size: file.size, - }, - file.path.replace(dirPath, ''), - next, - ); - }); - } - } - - /** - * Returns the request client IP address - * - * @param req - * - * @returns IP Address - */ - private getIpAddress(req: Request | SocketRequest): string { - if ('isSocket' in req && req.isSocket) { - return req.socket.handshake.address; - } else if (Array.isArray(req.ips) && req.ips.length > 0) { - // If config.http.trustProxy is enabled, this variable contains the IP addresses - // in this request's "X-Forwarded-For" header as an array of the IP address strings. - // Otherwise an empty array is returned. - return req.ips.join(','); - } else { - return req.ip || '0.0.0.0'; - } - } - - /** - * Handle channel event (probably a message) - * - * @param req - * - * @returns The channel's data - */ - protected getChannelData(req: Request | SocketRequest): Offline.ChannelData { - return { - isSocket: 'isSocket' in req && !!req.isSocket, - ipAddress: this.getIpAddress(req), - agent: req.headers['user-agent'], - }; - } - - /** - * Handle channel event (probably a message) - * - * @param req - * @param res - */ - _handleEvent( - req: Request | SocketRequest, - res: Response | SocketResponse, - ): void { - const data: Offline.IncomingMessage = req.body; - this.validateSession(req, res, (profile) => { - this.handleFilesUpload( - req, - res, - // @ts-expect-error @TODO : This needs to be fixed at a later point @TODO - (err: Error, upload: Offline.IncomingMessageData) => { - if (err) { - this.logger.warn( - 'Offline Channel Handler : Unable to upload file ', - err, - ); - return res - .status(403) - .json({ err: 'Offline Channel Handler : File upload failed!' }); - } - // Set data in file upload case - if (upload) { - data.data = upload; - } - const channelData = this.getChannelData(req); - const event: OfflineEventWrapper = new OfflineEventWrapper( - this, - data, - channelData, - ); - if (event.getEventType() === 'message') { - // Handler sync message sent by chabbot - if (data.sync && data.author === 'chatbot') { - const sentMessage: MessageCreateDto = { - mid: event.getId(), - message: event.getMessage() as StdOutgoingMessage, - recipient: profile.id, - read: true, - delivery: true, - }; - this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event); - return res.status(200).json(event._adapter.raw); - } else { - // Generate unique ID and handle message - event.set('mid', this.generateId()); - } - } - // Force author id from session - event.set('author', profile.foreign_id); - event.setSender(profile); - - const type = event.getEventType(); - if (type) { - this.eventEmitter.emit(`hook:chatbot:${type}`, event); - } else { - this.logger.error( - 'Offline Channel Handler : Webhook received unknown event ', - event, - ); - } - res.status(200).json(event._adapter.raw); - }, - ); - }); - } - - /** - * Process incoming Offline data (finding out its type and assigning it to its proper handler) - * - * @param req - * @param res - */ - async handle(req: Request | SocketRequest, res: Response | SocketResponse) { - const settings = await this.getSettings(); - // Offline messaging can be done through websockets or long-polling - try { - await this.checkRequest(req, res); - if (req.method === 'GET') { - if (!('isSocket' in req) && req.query._get) { - switch (req.query._get) { - case 'settings': - this.logger.debug( - 'Offline Channel Handler : connected .. sending settings', - ); - try { - const menu = await this.menuService.getTree(); - return res.status(200).json({ - menu, - server_date: new Date().toISOString(), - ...settings, - }); - } catch (err) { - this.logger.warn( - 'Offline Channel Handler : Unable to retrieve menu ', - err, - ); - return res.status(500).json({ err: 'Unable to retrieve menu' }); - } - case 'polling': - // Handle polling when user is not connected via websocket - return this.getMessageQueue(req, res as Response); - default: - this.logger.error( - 'Offline Channel Handler : Webhook received unknown command', - ); - return res - .status(500) - .json({ err: 'Webhook received unknown command' }); - } - } else if (req.query._disconnect) { - req.session.offline = undefined; - return res.status(200).json({ _disconnect: true }); - } else { - // Handle webhook subscribe requests - return await this.subscribe(req, res); - } - } else { - // Handle incoming messages (through POST) - return this._handleEvent(req, res); - } - } catch (err) { - this.logger.warn('Offline Channel Handler : Request check failed', err); - return res - .status(403) - .json({ err: 'Offline Channel Handler : Unauthorized!' }); - } - } - - /** - * Returns a unique identifier for the subscriber - * - * @returns UUID - */ - generateId(): string { - return 'offline-' + uuidv4(); - } - - /** - * Formats a text message that will be sent to the widget - * - * @param message - A text to be sent to the end user - * @param _options - might contain additional settings - * - * @returns A ready to be sent text message - */ - _textFormat( - message: StdOutgoingTextMessage, - _options?: BlockOptions, - ): Offline.OutgoingMessageBase { - return { - type: Offline.OutgoingMessageType.text, - data: message, - }; - } - - /** - * Formats a text + quick replies message that can be sent back - * - * @param message - A text + quick replies to be sent to the end user - * @param _options - might contain additional settings - * - * @returns A ready to be sent text message - */ - _quickRepliesFormat( - message: StdOutgoingQuickRepliesMessage, - _options?: BlockOptions, - ): Offline.OutgoingMessageBase { - return { - type: Offline.OutgoingMessageType.quick_replies, - data: { - text: message.text, - quick_replies: message.quickReplies, - }, - }; - } - - /** - * Formats a text + buttons message that can be sent back - * - * @param message - A text + buttons to be sent to the end user - * @param _options - Might contain additional settings - * - * @returns A formatted Object understandable by the widget - */ - _buttonsFormat( - message: StdOutgoingButtonsMessage, - _options?: BlockOptions, - ): Offline.OutgoingMessageBase { - return { - type: Offline.OutgoingMessageType.buttons, - data: { - text: message.text, - buttons: message.buttons, - }, - }; - } - - /** - * Formats an attachment + quick replies message that can be sent to the widget - * - * @param message - An attachment + quick replies to be sent to the end user - * @param _options - Might contain additional settings - * - * @returns A ready to be sent attachment message - */ - _attachmentFormat( - message: StdOutgoingAttachmentMessage>, - _options?: BlockOptions, - ): Offline.OutgoingMessageBase { - const payload: Offline.OutgoingMessageBase = { - type: Offline.OutgoingMessageType.file, - data: { - type: message.attachment.type, - url: message.attachment.payload.url, - }, - }; - if (message.quickReplies && message.quickReplies.length > 0) { - payload.data.quick_replies = message.quickReplies; - } - return payload; - } - - /** - * Formats a collection of items to be sent to the widget (carousel/list) - * - * @param data - A list of data items to be sent to the end user - * @param options - Might contain additional settings - * - * @returns An array of elements object - */ - _formatElements( - data: any[], - options: BlockOptions, - ): Offline.MessageElement[] { - if (!options.content || !options.content.fields) { - throw new Error('Content options are missing the fields'); - } - - const fields = options.content.fields; - const buttons: Button[] = options.content.buttons; - return data.map((item) => { - const element: Offline.MessageElement = { - title: item[fields.title], - buttons: item.buttons || [], - }; - if (fields.subtitle && item[fields.subtitle]) { - element.subtitle = item[fields.subtitle]; - } - if (fields.image_url && item[fields.image_url]) { - const attachmentPayload = item[fields.image_url].payload; - if (attachmentPayload.url) { - if (!attachmentPayload.id) { - // @deprecated - this.logger.warn( - 'Offline Channel Handler: Attachment remote url has been deprecated', - item, - ); - } - element.image_url = attachmentPayload.url; - } - } - - buttons.forEach((button: Button, index) => { - const btn = { ...button }; - if (btn.type === ButtonType.web_url) { - // Get built-in or an external URL from custom field - const urlField = fields.url; - btn.url = - urlField && item[urlField] ? item[urlField] : Content.getUrl(item); - if (!btn.url.startsWith('http')) { - btn.url = 'https://' + btn.url; - } - // Set default action the same as the first web_url button - if (!element.default_action) { - const { title: _title, ...defaultAction } = btn; - element.default_action = defaultAction; - } - } else { - if ( - 'action_payload' in fields && - fields.action_payload && - fields.action_payload in item - ) { - btn.payload = btn.title + ':' + item[fields.action_payload]; - } else { - const postback = Content.getPayload(item); - btn.payload = btn.title + ':' + postback; - } - } - // Set custom title for first button if provided - if (index === 0 && fields.action_title && item[fields.action_title]) { - btn.title = item[fields.action_title]; - } - element.buttons?.push(btn); - }); - if (Array.isArray(element.buttons) && element.buttons.length === 0) { - delete element.buttons; - } - return element; - }); - } - - /** - * Format a list of elements - * - * @param message - Contains elements to be sent to the end user - * @param options - Might contain additional settings - * - * @returns A ready to be sent list template message - */ - _listFormat( - message: StdOutgoingListMessage, - options: BlockOptions, - ): Offline.OutgoingMessageBase { - const data = message.elements || []; - const pagination = message.pagination; - let buttons: Button[] = [], - elements: Offline.MessageElement[] = []; - - // Items count min check - if (!data.length) { - this.logger.error( - 'Offline Channel Handler : Unsufficient content count (must be >= 0 for list)', - ); - throw new Error('Unsufficient content count (list >= 0)'); - } - - // Toggle "View More" button (check if there's more items to display) - if (pagination.total - pagination.skip - pagination.limit > 0) { - buttons = [ - { - type: ButtonType.postback, - title: this.i18n.t('View More'), - payload: VIEW_MORE_PAYLOAD, - }, - ]; - } - - // Populate items (elements/cards) with content - elements = this._formatElements(data, options); - const topElementStyle = options.content?.top_element_style - ? { - top_element_style: options.content?.top_element_style, - } - : {}; - return { - type: Offline.OutgoingMessageType.list, - data: { - elements, - buttons, - ...topElementStyle, - }, - }; - } - - /** - * Format a carousel message - * - * @param message - Contains elements to be sent to the end user - * @param options - Might contain additional settings - * - * @returns A carousel ready to be sent as a message - */ - _carouselFormat( - message: StdOutgoingListMessage, - options: BlockOptions, - ): Offline.OutgoingMessageBase { - const data = message.elements || []; - // Items count min check - if (data.length === 0) { - this.logger.error( - 'Offline Channel Handler : Unsufficient content count (must be > 0 for carousel)', - ); - throw new Error('Unsufficient content count (carousel > 0)'); - } - - // Populate items (elements/cards) with content - const elements = this._formatElements(data, options); - return { - type: Offline.OutgoingMessageType.carousel, - data: { - elements, - }, - }; - } - - /** - * Creates an widget compliant data structure for any message envelope - * - * @param envelope - The message standard envelope - * @param options - The block options related to the message - * - * @returns A template filled with its payload - */ - _formatMessage( - envelope: StdOutgoingEnvelope, - options: BlockOptions, - ): Offline.OutgoingMessageBase { - switch (envelope.format) { - case OutgoingMessageFormat.attachment: - return this._attachmentFormat(envelope.message, options); - case OutgoingMessageFormat.buttons: - return this._buttonsFormat(envelope.message, options); - case OutgoingMessageFormat.carousel: - return this._carouselFormat(envelope.message, options); - case OutgoingMessageFormat.list: - return this._listFormat(envelope.message, options); - case OutgoingMessageFormat.quickReplies: - return this._quickRepliesFormat(envelope.message, options); - case OutgoingMessageFormat.text: - return this._textFormat(envelope.message, options); - - default: - throw new Error('Unknown message format'); - } - } - - /** - * Sends a message to the end-user using websocket - * - * @param subscriber - End-user toward which message will be sent - * @param type - The message to be sent (message, typing, ...) - * @param content - The message payload contain additional settings - */ - private broadcast( - subscriber: Subscriber, - type: StdEventType, - content: any, - ): void { - if (subscriber.channel.isSocket) { - this.websocketGateway.broadcast(subscriber, type, content); - } else { - // Do nothing, messages will be retrieved via polling - } - } - - /** - * Send a Offline Message to the end-user - * - * @param event - Incoming event/message being responded to - * @param envelope - The message to be sent {format, message} - * @param options - Might contain additional settings - * @param _context - Contextual data - * - * @returns The offline's response, otherwise an error - */ - async sendMessage( - event: EventWrapper, - envelope: StdOutgoingEnvelope, - options: BlockOptions, - _context?: any, - ): Promise<{ mid: string }> { - const messageBase: Offline.OutgoingMessageBase = this._formatMessage( - envelope, - options, - ); - const subscriber = event.getSender(); - - const message: Offline.OutgoingMessage = { - ...messageBase, - mid: this.generateId(), - author: 'chatbot', - createdAt: new Date(), - handover: !!(options && options.assignTo), - }; - const next = async (): Promise => { - this.broadcast(subscriber, StdEventType.message, message); - return { mid: message.mid }; - }; - - if (options && options.typing) { - const autoTimeout = - message && message.data && 'text' in message.data - ? message.data.text.length * 10 - : 1000; - const timeout = - typeof options.typing === 'number' ? options.typing : autoTimeout; - try { - await this.sendTypingIndicator(subscriber, timeout); - return next(); - } catch (err) { - this.logger.error( - 'Offline Channel Handler : Failed in sending typing indicator ', - err, - ); - } - } - - return next(); - } - - /** - * Send a typing indicator (waterline) to the end user for a given duration - * - * @param recipient - The end-user object - * @param timeout - Duration of the typing indicator in milliseconds - */ - async sendTypingIndicator( - recipient: Subscriber, - timeout: number, - ): Promise { - return new Promise((resolve, reject) => { - try { - this.broadcast(recipient, StdEventType.typing, true); - setTimeout(() => { - this.broadcast(recipient, StdEventType.typing, false); - return resolve(); - }, timeout); - } catch (err) { - reject(err); - } - }); - } - - /** - * Fetch the end-user profile data - * - * @param event - The message event received - * - * @returns The offline's response, otherwise an error - */ - async getUserData(event: OfflineEventWrapper): Promise { - return event.getSender() as SubscriberCreateDto; - } } diff --git a/api/src/extensions/channels/offline/index.d.ts b/api/src/extensions/channels/offline/index.d.ts new file mode 100644 index 00000000..b1a5006d --- /dev/null +++ b/api/src/extensions/channels/offline/index.d.ts @@ -0,0 +1,14 @@ +import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_CHANNEL_NAME } from './settings'; + +declare global { + interface Settings extends SettingTree {} +} + +declare module '@nestjs/event-emitter' { + interface IHookSettingsGroupLabelOperationMap { + [key: HyphenToUnderscore]: TDefinition< + object, + SettingObject + >; + } +} diff --git a/api/src/extensions/channels/offline/settings.ts b/api/src/extensions/channels/offline/settings.ts index 4036bf5e..4d93c56e 100644 --- a/api/src/extensions/channels/offline/settings.ts +++ b/api/src/extensions/channels/offline/settings.ts @@ -6,112 +6,100 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { SettingCreateDto } from '@/setting/dto/setting.dto'; +import { ChannelSetting } from '@/channel/types'; import { SettingType } from '@/setting/schemas/types'; import { Offline } from './types'; -export const OFFLINE_CHANNEL_NAME = 'offline'; +export const OFFLINE_CHANNEL_NAME = 'offline' as const; -export const DEFAULT_OFFLINE_SETTINGS: SettingCreateDto[] = [ +export const OFFLINE_GROUP_NAME = OFFLINE_CHANNEL_NAME; + +export const DEFAULT_OFFLINE_SETTINGS = [ { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.verification_token, value: 'token123', type: SettingType.secret, - weight: 2, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.allowed_domains, value: 'http://localhost:8080,http://localhost:4000', type: SettingType.text, - weight: 3, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.start_button, value: true, type: SettingType.checkbox, - weight: 4, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.input_disabled, value: false, type: SettingType.checkbox, - weight: 5, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.persistent_menu, value: true, type: SettingType.checkbox, - weight: 6, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.greeting_message, value: 'Welcome! Ready to start a conversation with our chatbot?', type: SettingType.textarea, - weight: 7, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.theme_color, value: 'teal', type: SettingType.select, options: ['teal', 'orange', 'red', 'green', 'blue', 'dark'], - weight: 8, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.window_title, value: 'Widget Title', type: SettingType.text, - weight: 9, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.avatar_url, value: 'https://eu.ui-avatars.com/api/?name=Hexa+Bot&size=64', type: SettingType.text, - weight: 10, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.show_emoji, value: true, type: SettingType.checkbox, - weight: 11, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.show_file, value: true, type: SettingType.checkbox, - weight: 12, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.show_location, value: true, type: SettingType.checkbox, - weight: 13, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.allowed_upload_size, value: 2500000, type: SettingType.number, - weight: 14, }, { - group: OFFLINE_CHANNEL_NAME, + group: OFFLINE_GROUP_NAME, label: Offline.SettingLabel.allowed_upload_types, value: 'audio/mpeg,audio/x-ms-wma,audio/vnd.rn-realaudio,audio/x-wav,image/gif,image/jpeg,image/png,image/tiff,image/vnd.microsoft.icon,image/vnd.djvu,image/svg+xml,text/css,text/csv,text/html,text/plain,text/xml,video/mpeg,video/mp4,video/quicktime,video/x-ms-wmv,video/x-msvideo,video/x-flv,video/web,application/msword,application/vnd.ms-powerpoint,application/pdf,application/vnd.ms-excel,application/vnd.oasis.opendocument.presentation,application/vnd.oasis.opendocument.tex,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.graphics,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: SettingType.textarea, - weight: 15, }, -]; +] as const satisfies ChannelSetting[]; diff --git a/api/src/extensions/channels/offline/wrapper.ts b/api/src/extensions/channels/offline/wrapper.ts index 5a74a51b..5ab278d0 100644 --- a/api/src/extensions/channels/offline/wrapper.ts +++ b/api/src/extensions/channels/offline/wrapper.ts @@ -19,7 +19,7 @@ import { } from '@/chat/schemas/types/message'; import { Payload } from '@/chat/schemas/types/quick-reply'; -import OfflineHandler from './index.channel'; +import BaseWebChannelHandler from './base-web-channel'; import { Offline } from './types'; type OfflineEventAdapter = @@ -66,10 +66,9 @@ type OfflineEventAdapter = raw: Offline.IncomingMessage; }; -export default class OfflineEventWrapper extends EventWrapper< - OfflineEventAdapter, - Offline.Event -> { +export default class OfflineEventWrapper< + T extends BaseWebChannelHandler = BaseWebChannelHandler, +> extends EventWrapper { /** * Constructor : channel's event wrapper * @@ -77,7 +76,7 @@ export default class OfflineEventWrapper extends EventWrapper< * @param event - The message event received * @param channelData - Channel's specific extra data {isSocket, ipAddress} */ - constructor(handler: OfflineHandler, event: Offline.Event, channelData: any) { + constructor(handler: T, event: Offline.Event, channelData: any) { super(handler, event, channelData); } diff --git a/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts b/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts index 1d829f03..12e4f02e 100644 --- a/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts +++ b/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts @@ -152,9 +152,12 @@ describe('NLP Default Helper', () => { const nlp = nlpService.getNLP(); const results = nlp.bestGuess(nlpParseResult, true); const settings = await settingService.getSettings(); + const threshold = settings.nlp_settings.threshold; const thresholdGuess = { entities: nlpBestGuess.entities.filter( - (g) => g.confidence > parseFloat(settings.nlp_settings.threshold), + (g) => + g.confidence > + (typeof threshold === 'string' ? parseFloat(threshold) : threshold), ), }; expect(results).toEqual(thresholdGuess); diff --git a/api/src/extensions/helpers/nlp/default/index.nlp.helper.ts b/api/src/extensions/helpers/nlp/default/index.nlp.helper.ts index f81dd219..b605126f 100644 --- a/api/src/extensions/helpers/nlp/default/index.nlp.helper.ts +++ b/api/src/extensions/helpers/nlp/default/index.nlp.helper.ts @@ -135,7 +135,11 @@ export default class DefaultNlpHelper extends BaseNlpHelper { entities: nlp.entities.slice(), }; if (threshold) { - minConfidence = Number.parseFloat(this.settings.threshold); + const threshold = this.settings.threshold; + minConfidence = + typeof threshold === 'string' + ? Number.parseFloat(threshold) + : threshold; guess.entities = guess.entities .map((e) => { e.confidence = diff --git a/api/src/i18n/controllers/i18n.controller.ts b/api/src/i18n/controllers/i18n.controller.ts new file mode 100644 index 00000000..be857047 --- /dev/null +++ b/api/src/i18n/controllers/i18n.controller.ts @@ -0,0 +1,28 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { Controller, Get, UseInterceptors } from '@nestjs/common'; + +import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; + +import { I18nService } from '../services/i18n.service'; + +@UseInterceptors(CsrfInterceptor) +@Controller('i18n') +export class I18nController { + constructor(private readonly i18nService: I18nService) {} + + /** + * Retrieves translations of all the installed extensions. + * @returns An nested object that holds the translations grouped by language and extension name. + */ + @Get() + getTranslations() { + return this.i18nService.getExtensionI18nTranslations(); + } +} diff --git a/api/src/i18n/controllers/language.controller.ts b/api/src/i18n/controllers/language.controller.ts index b3bd7fa8..7c367c2f 100644 --- a/api/src/i18n/controllers/language.controller.ts +++ b/api/src/i18n/controllers/language.controller.ts @@ -46,10 +46,10 @@ export class LanguageController extends BaseController { } /** - * Retrieves a paginated list of categories based on provided filters and pagination settings. + * Retrieves a paginated list of languages based on provided filters and pagination settings. * @param pageQuery - The pagination settings. * @param filters - The filters to apply to the language search. - * @returns A Promise that resolves to a paginated list of categories. + * @returns A Promise that resolves to a paginated list of languages. */ @Get() async findPage( @@ -61,8 +61,8 @@ export class LanguageController extends BaseController { } /** - * Counts the filtered number of categories. - * @returns A promise that resolves to an object representing the filtered number of categories. + * Counts the filtered number of languages. + * @returns A promise that resolves to an object representing the filtered number of languages. */ @Get('count') async filterCount( diff --git a/api/src/i18n/i18n.module.ts b/api/src/i18n/i18n.module.ts index bd78742a..8ad4238f 100644 --- a/api/src/i18n/i18n.module.ts +++ b/api/src/i18n/i18n.module.ts @@ -26,6 +26,7 @@ import { Observable } from 'rxjs'; import { ChatModule } from '@/chat/chat.module'; +import { I18nController } from './controllers/i18n.controller'; import { LanguageController } from './controllers/language.controller'; import { TranslationController } from './controllers/translation.controller'; import { LanguageRepository } from './repositories/language.repository'; @@ -62,6 +63,7 @@ export class I18nModule extends NativeI18nModule { controllers: (controllers || []).concat([ LanguageController, TranslationController, + I18nController, ]), providers: providers.concat([ I18nService, diff --git a/api/src/i18n/services/i18n.service.ts b/api/src/i18n/services/i18n.service.ts index 9e5b93aa..3e06910b 100644 --- a/api/src/i18n/services/i18n.service.ts +++ b/api/src/i18n/services/i18n.service.ts @@ -6,8 +6,13 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { Injectable } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { Injectable, OnModuleInit } from '@nestjs/common'; import { + I18nJsonLoader, + I18nTranslation, I18nService as NativeI18nService, Path, PathValue, @@ -19,11 +24,22 @@ import { config } from '@/config'; import { Translation } from '@/i18n/schemas/translation.schema'; @Injectable() -export class I18nService< - K = Record, -> extends NativeI18nService { +export class I18nService> + extends NativeI18nService + implements OnModuleInit +{ private dynamicTranslations: Record> = {}; + private extensionTranslations: I18nTranslation = {}; + + onModuleInit() { + this.loadExtensionI18nTranslations(); + } + + getExtensionI18nTranslations() { + return this.extensionTranslations; + } + t

= any, R = PathValue>( key: P, options?: TranslateOptions, @@ -66,4 +82,48 @@ export class I18nService< return acc; }, this.dynamicTranslations); } + + async loadExtensionI18nTranslations() { + const extensionsDir = path.join( + __dirname, + '..', + '..', + 'extensions', + 'channels', + ); + try { + const extensionFolders = await fs.readdir(extensionsDir, { + withFileTypes: true, + }); + + for (const folder of extensionFolders) { + if (folder.isDirectory()) { + const i18nPath = path.join(extensionsDir, folder.name, 'i18n'); + const extensionName = folder.name.replaceAll('-', '_'); + try { + // Check if the i18n directory exists + await fs.access(i18nPath); + + // Load and merge translations + const i18nLoader = new I18nJsonLoader({ path: i18nPath }); + const translations = await i18nLoader.load(); + for (const lang in translations) { + if (!this.extensionTranslations[lang]) { + this.extensionTranslations[lang] = { + [extensionName]: translations[lang], + }; + } else { + this.extensionTranslations[lang][extensionName] = + translations[lang]; + } + } + } catch (error) { + // If the i18n folder does not exist or error in reading, skip this folder + } + } + } + } catch (error) { + throw new Error(`Failed to read extensions directory: ${error.message}`); + } + } } diff --git a/api/src/i18n/services/translation.service.spec.ts b/api/src/i18n/services/translation.service.spec.ts index 0db4de37..5396e89c 100644 --- a/api/src/i18n/services/translation.service.spec.ts +++ b/api/src/i18n/services/translation.service.spec.ts @@ -10,7 +10,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; import { I18nService } from '@/i18n/services/i18n.service'; -import { Settings } from '@/setting/schemas/types'; import { SettingService } from '@/setting/services/setting.service'; import { Block } from '../../chat/schemas/block.schema'; @@ -64,7 +63,7 @@ describe('TranslationService', () => { global_fallback: true, fallback_message: ['Global fallback message'], }, - } as Settings), + }), }, }, { diff --git a/api/src/index.d.ts b/api/src/index.d.ts index 2c4d3c50..b6c3f103 100644 --- a/api/src/index.d.ts +++ b/api/src/index.d.ts @@ -9,9 +9,9 @@ import 'mongoose'; import { SubscriberStub } from './chat/schemas/subscriber.schema'; import { - WithoutGenericAny, - RecursivePartial, ObjectWithNestedKeys, + RecursivePartial, + WithoutGenericAny, } from './utils/types/filter.types'; type TOmitId = Omit; @@ -63,3 +63,9 @@ declare module 'mongoose' { type THydratedDocument = TOmitId>; } + +declare global { + type HyphenToUnderscore = S extends `${infer P}-${infer Q}` + ? `${P}_${HyphenToUnderscore}` + : S; +} diff --git a/api/src/nlp/lib/BaseNlpHelper.ts b/api/src/nlp/lib/BaseNlpHelper.ts index b710bd56..805973e2 100644 --- a/api/src/nlp/lib/BaseNlpHelper.ts +++ b/api/src/nlp/lib/BaseNlpHelper.ts @@ -31,7 +31,6 @@ import { NlpValueDocument, NlpValueFull, } from '@/nlp/schemas/nlp-value.schema'; -import { Settings } from '@/setting/schemas/types'; import { NlpEntityService } from '../services/nlp-entity.service'; import { NlpSampleService } from '../services/nlp-sample.service'; diff --git a/api/src/plugins/types.ts b/api/src/plugins/types.ts index e01ba900..13a7eeef 100644 --- a/api/src/plugins/types.ts +++ b/api/src/plugins/types.ts @@ -9,7 +9,7 @@ import { BlockCreateDto } from '@/chat/dto/block.dto'; import { Block } from '@/chat/schemas/block.schema'; import { Conversation } from '@/chat/schemas/conversation.schema'; -import { Setting } from '@/setting/schemas/setting.schema'; +import { SettingCreateDto } from '@/setting/dto/setting.dto'; export enum PluginType { event = 'event', @@ -22,7 +22,7 @@ export interface CustomBlocks {} type ChannelEvent = any; type BlockAttrs = Partial & { name: string }; -export type PluginSetting = Omit; +export type PluginSetting = SettingCreateDto; export type PluginBlockTemplate = Omit< BlockAttrs, diff --git a/api/src/seeder.ts b/api/src/seeder.ts index afbb10c8..336aed29 100644 --- a/api/src/seeder.ts +++ b/api/src/seeder.ts @@ -22,7 +22,7 @@ import { nlpEntityModels } from './nlp/seeds/nlp-entity.seed-model'; import { NlpValueSeeder } from './nlp/seeds/nlp-value.seed'; import { nlpValueModels } from './nlp/seeds/nlp-value.seed-model'; import { SettingSeeder } from './setting/seeds/setting.seed'; -import { settingModels } from './setting/seeds/setting.seed-model'; +import { DEFAULT_SETTINGS } from './setting/seeds/setting.seed-model'; import { ModelSeeder } from './user/seeds/model.seed'; import { modelModels } from './user/seeds/model.seed-model'; import { PermissionSeeder } from './user/seeds/permission.seed'; @@ -106,7 +106,7 @@ export async function seedDatabase(app: INestApplicationContext) { } // Seed users try { - await settingSeeder.seed(settingModels); + await settingSeeder.seed(DEFAULT_SETTINGS); } catch (e) { logger.error('Unable to seed the database with settings!'); throw e; diff --git a/api/src/setting/dto/setting.dto.ts b/api/src/setting/dto/setting.dto.ts index f30c4bcd..6a3b025c 100644 --- a/api/src/setting/dto/setting.dto.ts +++ b/api/src/setting/dto/setting.dto.ts @@ -11,25 +11,30 @@ import { IsArray, IsIn, IsNotEmpty, - IsString, IsOptional, + IsString, } from 'class-validator'; import { SettingType } from '../schemas/types'; export class SettingCreateDto { - @ApiProperty({ description: 'Setting group of setting', type: String }) + @ApiProperty({ description: 'Setting group', type: String }) @IsNotEmpty() @IsString() group: string; - @ApiProperty({ description: 'Setting label of setting', type: String }) + @ApiProperty({ description: 'Setting subgroup', type: String }) + @IsOptional() + @IsString() + subgroup?: string; + + @ApiProperty({ description: 'Setting label (system name)', type: String }) @IsNotEmpty() @IsString() label: string; @ApiProperty({ - description: 'Setting type of the setting', + description: 'Setting type', enum: [ 'text', 'multiple_text', @@ -44,12 +49,12 @@ export class SettingCreateDto { @IsIn(Object.values(SettingType)) type: SettingType; - @ApiProperty({ description: 'Setting value of the setting' }) + @ApiProperty({ description: 'Setting value' }) @IsNotEmpty() value: any; @ApiPropertyOptional({ - description: 'Setting options', + description: 'Setting options (required when type is select)', isArray: true, type: Array, }) diff --git a/api/src/setting/index.d.ts b/api/src/setting/index.d.ts new file mode 100644 index 00000000..a8c058f4 --- /dev/null +++ b/api/src/setting/index.d.ts @@ -0,0 +1,31 @@ +import { DEFAULT_SETTINGS } from './seeds/setting.seed-model'; + +declare global { + type TNativeType = T extends string + ? string + : T extends number + ? number + : T extends boolean + ? boolean + : T extends Array + ? TNativeType[] + : T extends object + ? { [K in keyof T]: TNativeType } + : T; + + type SettingObject< + T extends Omit[], + > = { + [K in T[number] as K['label']]: TNativeType; + }; + + type SettingTree< + T extends Omit[], + > = { + [G in T[number] as G['group']]: { + [K in T[number] as K['label']]: TNativeType; + }; + }; + + interface Settings extends SettingTree {} +} diff --git a/api/src/setting/schemas/setting.schema.ts b/api/src/setting/schemas/setting.schema.ts index 8e49a630..077a8279 100644 --- a/api/src/setting/schemas/setting.schema.ts +++ b/api/src/setting/schemas/setting.schema.ts @@ -7,6 +7,7 @@ */ import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Transform } from 'class-transformer'; import { IsArray, IsIn } from 'class-validator'; import { BaseSchema } from '@/utils/generics/base-schema'; @@ -22,6 +23,13 @@ export class Setting extends BaseSchema { }) group: string; + @Prop({ + type: String, + default: '', + }) + @Transform(({ obj }) => obj.subgroup || undefined) + subgroup?: string; + @Prop({ type: String, required: true, diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index 8bf1a14d..988c0e81 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -94,18 +94,3 @@ export type AnySetting = | MultipleAttachmentSetting; export type SettingDict = { [group: string]: Setting[] }; - -export type Settings = { - nlp_settings: { - threshold: string; - provider: string; - endpoint: string; - token: string; - }; - contact: { [key: string]: string }; - chatbot_settings: { - global_fallback: boolean; - fallback_message: string[]; - fallback_block: string; - }; -} & Record; diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index ce239097..be580afa 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -9,7 +9,7 @@ import { SettingCreateDto } from '../dto/setting.dto'; import { SettingType } from '../schemas/types'; -export const settingModels: SettingCreateDto[] = [ +export const DEFAULT_SETTINGS = [ { group: 'chatbot_settings', label: 'global_fallback', @@ -38,7 +38,7 @@ export const settingModels: SettingCreateDto[] = [ value: [ "Sorry but i didn't understand your request. Maybe you can check the menu", "I'm really sorry but i don't quite understand what you are saying :(", - ], + ] as string[], type: SettingType.multiple_text, weight: 3, }, @@ -146,4 +146,4 @@ export const settingModels: SettingCreateDto[] = [ type: SettingType.text, weight: 10, }, -]; +] as const satisfies SettingCreateDto[]; diff --git a/api/src/setting/services/setting.service.ts b/api/src/setting/services/setting.service.ts index 961bb8ac..7ac73bf6 100644 --- a/api/src/setting/services/setting.service.ts +++ b/api/src/setting/services/setting.service.ts @@ -21,7 +21,6 @@ import { BaseService } from '@/utils/generics/base-service'; import { SettingCreateDto } from '../dto/setting.dto'; import { SettingRepository } from '../repositories/setting.repository'; import { Setting } from '../schemas/setting.schema'; -import { Settings } from '../schemas/types'; import { SettingSeeder } from '../seeds/setting.seed'; @Injectable() diff --git a/api/src/user/guards/ability.guard.ts b/api/src/user/guards/ability.guard.ts index 816f1929..60e5dc13 100644 --- a/api/src/user/guards/ability.guard.ts +++ b/api/src/user/guards/ability.guard.ts @@ -53,7 +53,7 @@ export class Ability implements CanActivate { if (user?.roles?.length) { if ( - ['/auth/logout', '/logout', '/auth/me', '/channel'].includes( + ['/auth/logout', '/logout', '/auth/me', '/channel', '/i18n'].includes( _parsedUrl.pathname, ) ) { diff --git a/api/tsconfig.json b/api/tsconfig.json index 4a0f8b0e..2839d486 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -17,9 +17,11 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, + "resolveJsonModule": true, "esModuleInterop": true, "paths": { "@/*": ["src/*"] } - } + }, + "include": ["src/**/*.ts", "src/**/*.json"] } diff --git a/frontend/public/locales/en/live-chat-tester.json b/frontend/public/locales/en/live-chat-tester.json deleted file mode 100644 index 71695690..00000000 --- a/frontend/public/locales/en/live-chat-tester.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "title": { - "live-chat-tester": "Live Chat Tester" - }, - "label": { - "verification_token": "Verification Token", - "allowed_domains": "Allowed Domains", - "start_button": "Enable `Get Started`", - "input_disabled": "Disable Input", - "persistent_menu": "Display Persistent Menu", - "greeting_message": "Greeting Message", - "theme_color": "Widget Theme", - "theme_color_options": { - "orange": "Orange", - "red": "Red", - "green": "Green", - "blue": "Blue", - "dark": "Dark" - }, - "window_title": "Chat Window Title", - "avatar_url": "Chatbot Avatar URL", - "show_emoji": "Enable Emoji Picker", - "show_file": "Enable Attachment Uploader", - "show_location": "Enable Geolocation Share", - "allowed_upload_size": "Max Upload Size (in bytes)", - "allowed_upload_types": "Allowed Upload Mime Types (comma separated)" - } -} diff --git a/frontend/public/locales/en/offline.json b/frontend/public/locales/en/offline.json deleted file mode 100644 index d29d5e28..00000000 --- a/frontend/public/locales/en/offline.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "title": { - "offline": "Web Channel" - }, - "label": { - "verification_token": "Verification Token", - "allowed_domains": "Allowed Domains", - "start_button": "Enable `Get Started`", - "input_disabled": "Disable Input", - "persistent_menu": "Display Persistent Menu", - "greeting_message": "Greeting Message", - "theme_color": "Widget Theme", - "theme_color_options": { - "orange": "Orange", - "red": "Red", - "green": "Green", - "blue": "Blue", - "dark": "Dark" - }, - "window_title": "Chat Window Title", - "avatar_url": "Chatbot Avatar URL", - "show_emoji": "Enable Emoji Picker", - "show_file": "Enable Attachment Uploader", - "show_location": "Enable Geolocation Share", - "allowed_upload_size": "Max Upload Size (in bytes)", - "allowed_upload_types": "Allowed Upload Mime Types (comma separated)" - } -} diff --git a/frontend/public/locales/fr/live-chat-tester.json b/frontend/public/locales/fr/live-chat-tester.json deleted file mode 100644 index 6590bbe4..00000000 --- a/frontend/public/locales/fr/live-chat-tester.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "title": { - "live-chat-tester": "Testeur Live Chat" - }, - "label": { - "verification_token": "Jeton de vérification", - "allowed_domains": "Domaines autorisés", - "start_button": "Activer `Démarrer`", - "input_disabled": "Désactiver la saisie", - "persistent_menu": "Afficher le menu persistent", - "greeting_message": "Message de bienvenue", - "theme_color": "Thème du widget", - "theme_color_options": { - "orange": "Orange", - "red": "Rouge", - "green": "Vert", - "blue": "Bleu", - "dark": "Sombre" - }, - "window_title": "Titre de la fenêtre de chat", - "avatar_url": "Avatar du chatbot (URL)", - "show_emoji": "Activer le sélecteur d'Emojis", - "show_file": "Activer l'upload de fichiers", - "show_location": "Activer le partage de géolocalisation", - "allowed_upload_size": "Taille maximale de téléchargement (en octets)", - "allowed_upload_types": "Types MIME autorisés pour le téléchargement (séparés par des virgules)" - } -} diff --git a/frontend/public/locales/fr/offline.json b/frontend/public/locales/fr/offline.json deleted file mode 100644 index f12d2a1d..00000000 --- a/frontend/public/locales/fr/offline.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "title": { - "offline": "Canal Web" - }, - "label": { - "verification_token": "Jeton de vérification", - "allowed_domains": "Domaines autorisés", - "start_button": "Activer `Démarrer`", - "input_disabled": "Désactiver la saisie", - "persistent_menu": "Afficher le menu persistent", - "greeting_message": "Message de bienvenue", - "theme_color": "Thème du widget", - "theme_color_options": { - "orange": "Orange", - "red": "Rouge", - "green": "Vert", - "blue": "Bleu", - "dark": "Sombre" - }, - "window_title": "Titre de la fenêtre de chat", - "avatar_url": "Avatar du chatbot (URL)", - "show_emoji": "Activer le sélecteur d'Emojis", - "show_file": "Activer l'upload de fichiers", - "show_location": "Activer le partage de géolocalisation", - "allowed_upload_size": "Taille maximale de téléchargement (en octets)", - "allowed_upload_types": "Types MIME autorisés pour le téléchargement (séparés par des virgules)" - } -} diff --git a/frontend/src/contexts/setting.context.tsx b/frontend/src/contexts/setting.context.tsx index a047c214..d3a685a6 100644 --- a/frontend/src/contexts/setting.context.tsx +++ b/frontend/src/contexts/setting.context.tsx @@ -10,6 +10,7 @@ import { createContext, ReactNode } from "react"; import { Progress } from "@/app-components/displays/Progress"; import { useLoadSettings } from "@/hooks/entities/auth-hooks"; +import { useRemoteI18n } from "@/hooks/useRemoteI18n"; import { ISetting } from "@/types/setting.types"; export const SettingsContext = createContext<{ @@ -27,6 +28,9 @@ export const SettingsProvider = ({ }: SettingsProviderProps): JSX.Element => { const { data, isLoading } = useLoadSettings(); + // Load API i18n Translations (extensions, ...) + useRemoteI18n(); + if (isLoading) return ; return ( diff --git a/frontend/src/hooks/useRemoteI18n.ts b/frontend/src/hooks/useRemoteI18n.ts new file mode 100644 index 00000000..407e91bd --- /dev/null +++ b/frontend/src/hooks/useRemoteI18n.ts @@ -0,0 +1,49 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { useEffect, useRef } from "react"; + +import i18n from "@/i18n/config"; + +import { useApiClient } from "./useApiClient"; +import { useAuth } from "./useAuth"; + +export const useRemoteI18n = () => { + const { isAuthenticated } = useAuth(); + const { apiClient } = useApiClient(); + const isRemoteI18nLoaded = useRef(false); + + useEffect(() => { + const fetchRemoteI18n = async () => { + try { + const additionalTranslations = await apiClient.fetchRemoteI18n(); + // Assuming additionalTranslations is an object like { en: { translation: { key: 'value' } } } + + Object.keys(additionalTranslations).forEach((lang) => { + Object.keys(additionalTranslations[lang]).forEach((namespace) => { + i18n.addResourceBundle( + lang, + namespace, + additionalTranslations[lang][namespace], + true, + true, + ); + }); + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to fetch remote i18n translations:", error); + } + }; + + if (isAuthenticated && !isRemoteI18nLoaded.current) { + fetchRemoteI18n(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated]); +}; diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts index 86a6bf56..b4fd317d 100644 --- a/frontend/src/i18n/config.ts +++ b/frontend/src/i18n/config.ts @@ -26,14 +26,7 @@ i18n backend: { loadPath: "/locales/{{lng}}/{{ns}}.json", }, - ns: [ - "translation", - "chatbot_settings.json", - "contact", - "nlp_settings", - "offline", - "live-chat-tester", - ], + ns: ["translation", "chatbot_settings", "contact", "nlp_settings"], interpolation: { escapeValue: false, }, diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index f1f6d99b..ea6bc568 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -32,6 +32,7 @@ export const ROUTES = { CSRF: "/csrftoken", BOTSTATS: "/botstats", REFRESH_TRANSLATIONS: "/translation/refresh", + FETCH_REMOTE_I18N: "/i18n", RESET: "/user/reset", NLP_SAMPLE_IMPORT: "/nlpsample/import", NLP_SAMPLE_PREDICT: "/nlpsample/message", @@ -190,6 +191,12 @@ export class ApiClient { return data; } + async fetchRemoteI18n() { + const { data } = await this.request.get(ROUTES.FETCH_REMOTE_I18N); + + return data; + } + async reset(token: string, payload: IResetPayload) { const { data } = await this.request.post< IResetPayload,