From 85cc85e4db2790e4627b5de8e968885a7bec2f7f Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 17:50:35 +0100 Subject: [PATCH 1/7] 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, From 879f5be1c2ac249759eaee0199315229eb773763 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 18:02:23 +0100 Subject: [PATCH 2/7] fix: unit test + sanitize uploaded filename --- api/package-lock.json | 22 +++++++++++++++++++ api/package.json | 1 + api/src/channel/lib/Handler.ts | 2 -- api/src/chat/services/block.service.spec.ts | 3 --- .../channels/offline/base-web-channel.ts | 7 +++--- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 54720c53..126fff02 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -51,6 +51,7 @@ "patch-package": "^8.0.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "sanitize-filename": "^1.6.3", "slug": "^8.2.2", "ts-migrate-mongoose": "^3.8.4", "uuid": "^9.0.1" @@ -16980,6 +16981,14 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/sax": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", @@ -18114,6 +18123,14 @@ "tree-kill": "cli.js" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -18657,6 +18674,11 @@ "punycode": "^2.1.0" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/api/package.json b/api/package.json index 39b2bbbc..e3e0aacd 100644 --- a/api/package.json +++ b/api/package.json @@ -72,6 +72,7 @@ "patch-package": "^8.0.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "sanitize-filename": "^1.6.3", "slug": "^8.2.2", "ts-migrate-mongoose": "^3.8.4", "uuid": "^9.0.1" diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 05ca8ad3..c14c1b03 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -27,8 +27,6 @@ import { ChannelSetting } from '../types'; import EventWrapper from './EventWrapper'; -import EventWrapper from './EventWrapper'; - @Injectable() export default abstract class ChannelHandler { private readonly name: N; diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index a4824ce6..72fae56f 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -66,9 +66,6 @@ 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/extensions/channels/offline/base-web-channel.ts b/api/src/extensions/channels/offline/base-web-channel.ts index 108879be..4c5a9c85 100644 --- a/api/src/extensions/channels/offline/base-web-channel.ts +++ b/api/src/extensions/channels/offline/base-web-channel.ts @@ -13,6 +13,7 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { Request, Response } from 'express'; import multer, { diskStorage } from 'multer'; +import sanitize from 'sanitize-filename'; import { Socket } from 'socket.io'; import { v4 as uuidv4 } from 'uuid'; @@ -684,9 +685,9 @@ export default class BaseWebChannelHandler< // Store file as attachment const dirPath = path.join(config.parameters.uploadDir); - const filename = `${req.session.offline.profile.id}_${+new Date()}_${ - upload.name - }`; + const filename = sanitize( + `${req.session.offline.profile.id}_${+new Date()}_${upload.name}`, + ); if ('isSocket' in req && req.isSocket) { // @TODO : test this try { From 1da6e9e5e05ac64eb564df334997345a1a060ef9 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 18:03:57 +0100 Subject: [PATCH 3/7] fix: sanitize uploaded filename --- .../extensions/channels/offline/base-web-channel.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/api/src/extensions/channels/offline/base-web-channel.ts b/api/src/extensions/channels/offline/base-web-channel.ts index 4c5a9c85..591672c6 100644 --- a/api/src/extensions/channels/offline/base-web-channel.ts +++ b/api/src/extensions/channels/offline/base-web-channel.ts @@ -685,14 +685,20 @@ export default class BaseWebChannelHandler< // Store file as attachment const dirPath = path.join(config.parameters.uploadDir); - const filename = sanitize( + const sanitizedFilename = sanitize( `${req.session.offline.profile.id}_${+new Date()}_${upload.name}`, ); + const filePath = path.resolve(dirPath, sanitizedFilename); + + if (!filePath.startsWith(dirPath)) { + return next(new Error('Invalid file path!'), false); + } + if ('isSocket' in req && req.isSocket) { // @TODO : test this try { - await fsPromises.writeFile(path.join(dirPath, filename), upload.file); - this.storeAttachment(upload, filename, next); + await fsPromises.writeFile(filePath, upload.file); + this.storeAttachment(upload, sanitizedFilename, next); } catch (err) { this.logger.error( 'Offline Channel Handler : Unable to write uploaded file', From 6792b3c534430585827b0738baa72a3ed25cdcf1 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 18:05:56 +0100 Subject: [PATCH 4/7] fix: filename --- api/src/extensions/channels/offline/base-web-channel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/extensions/channels/offline/base-web-channel.ts b/api/src/extensions/channels/offline/base-web-channel.ts index 591672c6..91423944 100644 --- a/api/src/extensions/channels/offline/base-web-channel.ts +++ b/api/src/extensions/channels/offline/base-web-channel.ts @@ -711,7 +711,7 @@ export default class BaseWebChannelHandler< storage: diskStorage({ destination: dirPath, // Set the destination directory for file storage filename: (_req, _file, cb) => { - cb(null, filename); // Set the file name + cb(null, sanitizedFilename); // Set the file name }, }), }).single('file'); // 'file' is the field name in the form From 21a6cb329440be344226c898f523607c5a5d89e7 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 18:31:13 +0100 Subject: [PATCH 5/7] fix: eslint --- api/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tsconfig.json b/api/tsconfig.json index 2839d486..b1b6499d 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -23,5 +23,5 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.json"] + "include": ["src/**/*.ts", "src/**/*.json", "test/**/*.ts"] } From a8030378ca898302742c0df8c4d07252890cc9d5 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 18 Oct 2024 18:46:32 +0100 Subject: [PATCH 6/7] fix: channel event typing --- api/src/extensions/channels/live-chat-tester/index.d.ts | 4 ++-- api/src/extensions/channels/offline/index.d.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/extensions/channels/live-chat-tester/index.d.ts b/api/src/extensions/channels/live-chat-tester/index.d.ts index 9e5d4f76..0d1c6a6e 100644 --- a/api/src/extensions/channels/live-chat-tester/index.d.ts +++ b/api/src/extensions/channels/live-chat-tester/index.d.ts @@ -1,6 +1,6 @@ import { DEFAULT_LIVE_CHAT_TEST_SETTINGS, - LIVE_CHAT_TEST_CHANNEL_NAME, + LIVE_CHAT_TEST_GROUP_NAME, } from './settings'; declare global { @@ -10,7 +10,7 @@ declare global { declare module '@nestjs/event-emitter' { interface IHookSettingsGroupLabelOperationMap { - [name: HyphenToUnderscore]: TDefinition< + [LIVE_CHAT_TEST_GROUP_NAME]: TDefinition< object, SettingObject >; diff --git a/api/src/extensions/channels/offline/index.d.ts b/api/src/extensions/channels/offline/index.d.ts index b1a5006d..f87bebc5 100644 --- a/api/src/extensions/channels/offline/index.d.ts +++ b/api/src/extensions/channels/offline/index.d.ts @@ -1,4 +1,4 @@ -import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_CHANNEL_NAME } from './settings'; +import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_GROUP_NAME } from './settings'; declare global { interface Settings extends SettingTree {} @@ -6,7 +6,7 @@ declare global { declare module '@nestjs/event-emitter' { interface IHookSettingsGroupLabelOperationMap { - [key: HyphenToUnderscore]: TDefinition< + [OFFLINE_GROUP_NAME]: TDefinition< object, SettingObject >; From 8d846186ccd1170e3a44affd44439000cdcd028d Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Sat, 19 Oct 2024 09:17:51 +0100 Subject: [PATCH 7/7] feat: enhance typing + remove unused translations --- api/src/channel/lib/Handler.ts | 7 +- api/src/eventemitter.d.ts | 13 +-- .../channels/live-chat-tester/index.d.ts | 4 +- .../channels/live-chat-tester/settings.ts | 2 +- .../channels/offline/base-web-channel.ts | 89 +++++++++---------- .../extensions/channels/offline/index.d.ts | 4 +- api/src/setting/index.d.ts | 7 ++ api/src/setting/schemas/types.ts | 34 +++++++ frontend/public/locales/en/translation.json | 70 --------------- frontend/public/locales/fr/translation.json | 70 --------------- 10 files changed, 95 insertions(+), 205 deletions(-) diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index c14c1b03..1532dc85 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -56,7 +56,7 @@ export default abstract class ChannelHandler { } protected getGroup() { - return this.getChannel().replaceAll('-', '_'); + return this.getChannel().replaceAll('-', '_') as ChannelSetting['group']; } async setup() { @@ -92,9 +92,10 @@ export default abstract class ChannelHandler { * Returns the channel's settings * @returns Channel's settings */ - async getSettings() { + async getSettings>() { const settings = await this.settingService.getSettings(); - return settings[this.getGroup()]; + // @ts-expect-error workaround typing + return settings[this.getGroup() as keyof Settings] as Settings[S]; } /** diff --git a/api/src/eventemitter.d.ts b/api/src/eventemitter.d.ts index ba286f44..a77ef929 100644 --- a/api/src/eventemitter.d.ts +++ b/api/src/eventemitter.d.ts @@ -28,7 +28,6 @@ import type { NlpValueDocument, } from '@/nlp/schemas/nlp-value.schema'; import { type Setting } from '@/setting/schemas/setting.schema'; -import type { CheckboxSetting, TextSetting } from '@/setting/schemas/types'; import { type Invitation } from '@/user/schemas/invitation.schema'; import { type Model } from '@/user/schemas/model.schema'; import { type Permission } from '@/user/schemas/permission.schema'; @@ -48,17 +47,7 @@ declare module '@nestjs/event-emitter' { operations: O; } - interface IHookExtensionsOperationMap { - messenger: TDefinition< - object, - { - get_started_button: Setting; - access_token: Setting; - composer_input_disabled: CheckboxSetting; - greeting_text: TextSetting; - } - >; - } + interface IHookExtensionsOperationMap {} interface IHookSettingsGroupLabelOperationMap { chatbot_settings: TDefinition< diff --git a/api/src/extensions/channels/live-chat-tester/index.d.ts b/api/src/extensions/channels/live-chat-tester/index.d.ts index 0d1c6a6e..27efb2d0 100644 --- a/api/src/extensions/channels/live-chat-tester/index.d.ts +++ b/api/src/extensions/channels/live-chat-tester/index.d.ts @@ -9,10 +9,10 @@ declare global { } declare module '@nestjs/event-emitter' { - interface IHookSettingsGroupLabelOperationMap { + interface IHookExtensionsOperationMap { [LIVE_CHAT_TEST_GROUP_NAME]: TDefinition< object, - SettingObject + SettingMapByType >; } } diff --git a/api/src/extensions/channels/live-chat-tester/settings.ts b/api/src/extensions/channels/live-chat-tester/settings.ts index fe9e657e..e8afc9ae 100644 --- a/api/src/extensions/channels/live-chat-tester/settings.ts +++ b/api/src/extensions/channels/live-chat-tester/settings.ts @@ -91,4 +91,4 @@ export const DEFAULT_LIVE_CHAT_TEST_SETTINGS = [ '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, }, -] as const satisfies ChannelSetting[]; +] 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 index 91423944..81561705 100644 --- a/api/src/extensions/channels/offline/base-web-channel.ts +++ b/api/src/extensions/channels/offline/base-web-channel.ts @@ -59,6 +59,7 @@ import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; +import { OFFLINE_GROUP_NAME } from './settings'; import { Offline } from './types'; import OfflineEventWrapper from './wrapper'; @@ -90,7 +91,7 @@ export default class BaseWebChannelHandler< * @returns - */ init(): void { - this.logger.debug('Offline Channel Handler : initialization ...'); + this.logger.debug('Web Channel Handler : initialization ...'); } /** @@ -111,21 +112,21 @@ export default class BaseWebChannelHandler< await this.verifyToken(verification_token.toString()); try { this.logger.debug( - 'Offline Channel Handler : WS connected .. sending settings', + 'Web 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 ', + 'Web 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 ...', + 'Web Channel Handler : Unable to verify token, disconnecting ...', err, ); client.disconnect(); @@ -133,7 +134,7 @@ export default class BaseWebChannelHandler< } } catch (err) { this.logger.error( - 'Offline Channel Handler : Unable to initiate websocket connection', + 'Web Channel Handler : Unable to initiate websocket connection', err, ); } @@ -300,7 +301,8 @@ export default class BaseWebChannelHandler< * @param verificationToken - Verification Token */ private async verifyToken(verificationToken: string) { - const settings = await this.getSettings(); + const settings = + (await this.getSettings()) as Settings[typeof OFFLINE_GROUP_NAME]; const verifyToken = settings.verification_token; if (!verifyToken) { @@ -313,7 +315,7 @@ export default class BaseWebChannelHandler< throw new Error('Make sure the validation tokens match.'); } this.logger.log( - 'Offline Channel Handler : Token has been verified successfully!', + 'Web Channel Handler : Token has been verified successfully!', ); } @@ -327,7 +329,7 @@ export default class BaseWebChannelHandler< req: Request | SocketRequest, res: Response | SocketResponse, ) { - const settings = await this.getSettings(); + const settings = await this.getSettings(); // If we have an origin header... if (req.headers && req.headers.origin) { // Get the allowed origins @@ -344,7 +346,7 @@ export default class BaseWebChannelHandler< // interpret as, 'no way Jose.' res.set('Access-Control-Allow-Origin', ''); this.logger.debug( - 'Offline Channel Handler : No origin found ', + 'Web Channel Handler : No origin found ', req.headers.origin, ); throw new Error('CORS - Domain not allowed!'); @@ -362,7 +364,7 @@ export default class BaseWebChannelHandler< } return; } - this.logger.debug('Offline Channel Handler : No origin ', req.headers); + this.logger.debug('Web Channel Handler : No origin ', req.headers); throw new Error('CORS - No origin provided!'); } @@ -379,23 +381,23 @@ export default class BaseWebChannelHandler< ) { if (!req.session?.offline?.profile?.id) { this.logger.warn( - 'Offline Channel Handler : No session ID to be found!', + 'Web Channel Handler : No session ID to be found!', req.session, ); return res .status(403) - .json({ err: 'Offline Channel Handler : Unauthorized!' }); + .json({ err: 'Web 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!', + 'Web Channel Handler : Mixed channel request or invalid session data!', req.session, ); return res .status(403) - .json({ err: 'Offline Channel Handler : Unauthorized!' }); + .json({ err: 'Web Channel Handler : Unauthorized!' }); } next(req.session?.offline?.profile); } @@ -417,15 +419,12 @@ export default class BaseWebChannelHandler< '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, - ); + this.logger.warn('Web 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', + 'Web Channel Handler : Attempt to access from an unauthorized origin', err, ); throw new Error('Unauthorized, invalid origin !'); @@ -504,7 +503,7 @@ export default class BaseWebChannelHandler< // Polling not authorized when using websockets if ('isSocket' in req && req.isSocket) { this.logger.warn( - 'Offline Channel Handler : Polling not authorized when using websockets', + 'Web Channel Handler : Polling not authorized when using websockets', ); return res .status(403) @@ -515,7 +514,7 @@ export default class BaseWebChannelHandler< !(req.session && req.session.offline && req.session.offline.profile.id) ) { this.logger.warn( - 'Offline Channel Handler : Must be connected to poll messages', + 'Web Channel Handler : Must be connected to poll messages', ); return res .status(403) @@ -525,7 +524,7 @@ export default class BaseWebChannelHandler< // 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', + 'Web Channel Handler : Poll rejected ... already requested', ); return res .status(403) @@ -548,7 +547,7 @@ export default class BaseWebChannelHandler< return res.status(200).json(messages.map((msg) => ['message', msg])); } else { this.logger.error( - 'Offline Channel Handler : Polling failed .. no session data', + 'Web Channel Handler : Polling failed .. no session data', ); return res.status(500).json({ err: 'No session data' }); } @@ -556,7 +555,7 @@ export default class BaseWebChannelHandler< if (req.session.offline) { req.session.offline.polling = false; } - this.logger.error('Offline Channel Handler : Polling failed', err); + this.logger.error('Web Channel Handler : Polling failed', err); return res.status(500).json({ err: 'Polling failed' }); } }; @@ -574,7 +573,7 @@ export default class BaseWebChannelHandler< res: Response | SocketResponse, ) { this.logger.debug( - 'Offline Channel Handler : subscribe (isSocket=' + + 'Web Channel Handler : subscribe (isSocket=' + ('isSocket' in req && !!req.isSocket) + ')', ); @@ -586,7 +585,7 @@ export default class BaseWebChannelHandler< await req.socket.join(profile.foreign_id); } catch (err) { this.logger.error( - 'Offline Channel Handler : Unable to subscribe via websocket', + 'Web Channel Handler : Unable to subscribe via websocket', err, ); } @@ -600,7 +599,7 @@ export default class BaseWebChannelHandler< return res.status(200).json({ profile, messages }); }); } catch (err) { - this.logger.warn('Offline Channel Handler : Unable to subscribe ', err); + this.logger.warn('Web Channel Handler : Unable to subscribe ', err); return res.status(500).json({ err: 'Unable to subscribe' }); } } @@ -620,7 +619,7 @@ export default class BaseWebChannelHandler< ) => void, ): Promise { try { - this.logger.debug('Offline Channel Handler : Successfully uploaded file'); + this.logger.debug('Web Channel Handler : Successfully uploaded file'); const attachment = await this.attachmentService.create({ name: upload.name || '', @@ -631,7 +630,7 @@ export default class BaseWebChannelHandler< }); this.logger.debug( - 'Offline Channel Handler : Successfully stored file as attachment', + 'Web Channel Handler : Successfully stored file as attachment', ); next(null, { @@ -640,7 +639,7 @@ export default class BaseWebChannelHandler< }); } catch (err) { this.logger.error( - 'Offline Channel Handler : Unable to store uploaded file as attachment', + 'Web Channel Handler : Unable to store uploaded file as attachment', err, ); next(err, false); @@ -664,12 +663,12 @@ export default class BaseWebChannelHandler< 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'); + this.logger.debug('Web 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'); + this.logger.debug('Web Channel Handler : No files provided'); return next(null, false); } @@ -701,7 +700,7 @@ export default class BaseWebChannelHandler< this.storeAttachment(upload, sanitizedFilename, next); } catch (err) { this.logger.error( - 'Offline Channel Handler : Unable to write uploaded file', + 'Web Channel Handler : Unable to write uploaded file', err, ); return next(new Error('Unable to upload file!'), false); @@ -719,7 +718,7 @@ export default class BaseWebChannelHandler< upload(req as Request, res as Response, (err) => { if (err) { this.logger.error( - 'Offline Channel Handler : Unable to write uploaded file', + 'Web Channel Handler : Unable to write uploaded file', err, ); return next(new Error('Unable to upload file!'), false); @@ -794,12 +793,12 @@ export default class BaseWebChannelHandler< (err: Error, upload: Offline.IncomingMessageData) => { if (err) { this.logger.warn( - 'Offline Channel Handler : Unable to upload file ', + 'Web Channel Handler : Unable to upload file ', err, ); return res .status(403) - .json({ err: 'Offline Channel Handler : File upload failed!' }); + .json({ err: 'Web Channel Handler : File upload failed!' }); } // Set data in file upload case if (upload) { @@ -837,7 +836,7 @@ export default class BaseWebChannelHandler< this.eventEmitter.emit(`hook:chatbot:${type}`, event); } else { this.logger.error( - 'Offline Channel Handler : Webhook received unknown event ', + 'Web Channel Handler : Webhook received unknown event ', event, ); } @@ -863,7 +862,7 @@ export default class BaseWebChannelHandler< switch (req.query._get) { case 'settings': this.logger.debug( - 'Offline Channel Handler : connected .. sending settings', + 'Web Channel Handler : connected .. sending settings', ); try { const menu = await this.menuService.getTree(); @@ -874,7 +873,7 @@ export default class BaseWebChannelHandler< }); } catch (err) { this.logger.warn( - 'Offline Channel Handler : Unable to retrieve menu ', + 'Web Channel Handler : Unable to retrieve menu ', err, ); return res.status(500).json({ err: 'Unable to retrieve menu' }); @@ -884,7 +883,7 @@ export default class BaseWebChannelHandler< return this.getMessageQueue(req, res as Response); default: this.logger.error( - 'Offline Channel Handler : Webhook received unknown command', + 'Web Channel Handler : Webhook received unknown command', ); return res .status(500) @@ -902,10 +901,10 @@ export default class BaseWebChannelHandler< return this._handleEvent(req, res); } } catch (err) { - this.logger.warn('Offline Channel Handler : Request check failed', err); + this.logger.warn('Web Channel Handler : Request check failed', err); return res .status(403) - .json({ err: 'Offline Channel Handler : Unauthorized!' }); + .json({ err: 'Web Channel Handler : Unauthorized!' }); } } @@ -1103,7 +1102,7 @@ export default class BaseWebChannelHandler< // Items count min check if (!data.length) { this.logger.error( - 'Offline Channel Handler : Unsufficient content count (must be >= 0 for list)', + 'Web Channel Handler : Unsufficient content count (must be >= 0 for list)', ); throw new Error('Unsufficient content count (list >= 0)'); } @@ -1152,7 +1151,7 @@ export default class BaseWebChannelHandler< // Items count min check if (data.length === 0) { this.logger.error( - 'Offline Channel Handler : Unsufficient content count (must be > 0 for carousel)', + 'Web Channel Handler : Unsufficient content count (must be > 0 for carousel)', ); throw new Error('Unsufficient content count (carousel > 0)'); } @@ -1263,7 +1262,7 @@ export default class BaseWebChannelHandler< return next(); } catch (err) { this.logger.error( - 'Offline Channel Handler : Failed in sending typing indicator ', + 'Web Channel Handler : Failed in sending typing indicator ', err, ); } diff --git a/api/src/extensions/channels/offline/index.d.ts b/api/src/extensions/channels/offline/index.d.ts index f87bebc5..39a1b26a 100644 --- a/api/src/extensions/channels/offline/index.d.ts +++ b/api/src/extensions/channels/offline/index.d.ts @@ -5,10 +5,10 @@ declare global { } declare module '@nestjs/event-emitter' { - interface IHookSettingsGroupLabelOperationMap { + interface IHookExtensionsOperationMap { [OFFLINE_GROUP_NAME]: TDefinition< object, - SettingObject + SettingMapByType >; } } diff --git a/api/src/setting/index.d.ts b/api/src/setting/index.d.ts index a8c058f4..90ea4a8a 100644 --- a/api/src/setting/index.d.ts +++ b/api/src/setting/index.d.ts @@ -1,3 +1,4 @@ +import { SettingByType } from './schemas/types'; import { DEFAULT_SETTINGS } from './seeds/setting.seed-model'; declare global { @@ -19,6 +20,12 @@ declare global { [K in T[number] as K['label']]: TNativeType; }; + type SettingMapByType< + T extends Omit[], + > = { + [K in T[number] as K['label']]: SettingByType; + }; + type SettingTree< T extends Omit[], > = { diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index 988c0e81..fbf30493 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -38,6 +38,20 @@ export interface TextSetting extends Setting { config: never; } +export interface TextareaSetting extends Setting { + type: SettingType.textarea; + value: string; + options: never; + config: never; +} + +export interface SecretSetting extends Setting { + type: SettingType.secret; + value: string; + options: never; + config: never; +} + export interface MultiTextSetting extends Setting { type: SettingType.multiple_text; value: string[]; @@ -84,6 +98,26 @@ export interface MultipleAttachmentSetting extends Setting { config: never; } +export type SettingByType = T extends SettingType.text + ? TextSetting + : T extends SettingType.textarea + ? TextareaSetting + : T extends SettingType.secret + ? SecretSetting + : T extends SettingType.multiple_text + ? MultiTextSetting + : T extends SettingType.checkbox + ? CheckboxSetting + : T extends SettingType.select + ? SelectSetting + : T extends SettingType.number + ? NumberSetting + : T extends SettingType.attachment + ? AttachmentSetting + : T extends SettingType.multiple_attachment + ? MultipleAttachmentSetting + : never; + export type AnySetting = | TextSetting | MultiTextSetting diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 0716140f..5e55364e 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -444,51 +444,9 @@ "delete": "Delete", "role": "Role", "owner": "Owner", - "app_secret": "Facebook App Secret", - "access_token": "Facebook Page Access Token", - "verify_token": "Webhook Verification Token", - "client_id": "Client ID", - "client_secret": "Client Secret", - "app_id": "ID of the Facebook Application", - "page_id": "ID of the Facebook Page", - "user_fields": "User fields to be retrieved (comma seperated)", - "mode": "Mode", - "mode_options": { - "emulator": "Emulator", - "live": "Live" - }, - "get_started_button": "Enable `Get Started` button", - "composer_input_disabled": "Disable composer input", - "greeting_text": "Greeting Text", "provider": "Provider", - "provider_options": { - "wit": "Wit.ai", - "rasa": "Rasa NLU" - }, "languages": "Available Languages", "default_lang": "Default Language", - "secret": "Secret", - "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 seperated)", "channel": "Channel", "entry": "Entry content", "thumbnail": "Thumbnail", @@ -619,41 +577,13 @@ "reset": "Forgot your password?" }, "help": { - "verify_token": "Token that you will returned to Facebook as part of the verification of the Webhook URL.", - "composer_input_disabled": "This means your bot can only be interacted with via the persistent menu, postbacks, buttons, and webviews.", - "get_started_button": "A bot's welcome screen can display a Get Started button. When this button is tapped, the Messenger Platform will send a postback event to your webhook (Payload = `GET_STARTED`).", - "greeting_text": "The greeting property of your bot's Messenger profile allows you to specify the greeting message people will see on the welcome screen of your bot. The welcome screen is displayed for people interacting with your bot for the first time.", "fallback_message": "If no fallback block is selected, then one of these messages will be sent.", - "app_id": "Mandatory only if you intend to use Facebook Analytics", - "page_id": "Mandatory only if you intend to use Facebook Analytics", "nlp_train": "You can train your chatbot by adding more examples", "hit_enter_to_create": "Hit `enter` to create new", "nlp_precision": "Given an entity, precision is likelihood to make the right prediction.", "nlp_recall": "Recall is the likelihood to predict an entity out of all entities.", "nlp_f1score": "The F1 score can be interpreted as a weighted average of the precision and recall", "nlp_accuracy": "Accuracy score is the proportion of the correctly classified samples.", - "message_tag_shipping_update": "The shipping_update tag may only be used to provide a shipping status notification for a product that has already been purchased. For example, when the product is shipped, in-transit, delivered, or delayed. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_reservation_update": "The reservation_update tag may only be used to confirm updates to an existing reservation. For example, when there is a change in itinerary, location, or a cancellation (such as when a hotel booking is canceled, a car rental pick-up time changes, or a room upgrade is confirmed). This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_issue_resolution": "The issue_resolution tag may only be used to respond to a customer service issue surfaced in a Messenger conversation after a transaction has taken place. This tag is intended for use cases where the business requires more than 24 hours to resolve an issue and needs to give someone a status update and/or gather additional information. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements, nor can businesses use the tag to proactively message people to solicit feedback).", - "message_tag_appointment_update": "The appointment_update tag may only be used to provide updates about an existing appointment. For example, when there is a change in time, a location update or a cancellation (such as when a spa treatment is canceled, a real estate agent needs to meet you at a new location or a dental office proposes a new appointment time). This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_game_event": "The game_event tag may only be used to provide an update on user progression, a global event in a game or a live sporting event. For example, when a person\u2019s crops are ready to be collected, their building is finished, their daily tournament is about to start or their favorite soccer team is about to play. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_transportation_update": "The transportation_update tag may only be used to confirm updates to an existing reservation. For example, when there is a change in status of any flight, train or ferry reservation (such as \u201cride canceled\u201d, \u201ctrip started\u201d or \u201cferry arrived\u201d). This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_feature_functionality_update": "The feature_functionality_update tag may only be used to provide an update on new features or functionality that become available in a bot. For example, announcing the ability to talk to a live agent in a bot, or that the bot has a new skill. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_ticket_update": "The ticket_update tag may only be used to notify the message recipient of updates or reminders pertaining to an event for which the person has already confirmed attendance. For example, when you want to send a message about a change in time, a location update, a cancellation or a reminder for an upcoming event (such as when a concert is canceled, the venue has changed or a refund opportunity is available). This tag cannot be used for use cases beyond those listed above or for promotional content (ex: daily deals, coupons and discounts, or sale announcements).", - "message_tag_account_update": "The ACCOUNT_UPDATE tag may only be used to confirm updates to a user's account setting. For example, when there is a change in account settings and preferences of a user profile, notification of a password change, or membership expiration. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: promotion for signups, new account creations, or deals to extend subscriptions).", - "message_tag_payment_update": "The PAYMENT_UPDATE tag may be used to provide payment updates to existing transactions. For example, it can be used to send a receipt, out-of-stock, auction ended, refund, or a status change in an existing purchase transaction. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: any cross-sell / up-sell promotions, coupons, or deals to extend subscriptions).", - "message_tag_personal_finance_update": "The PERSONAL_FINANCE_UPDATE tag may be used to confirm a user's financial activity. For example, it can be used to send notifications on bill pay reminders, scheduled payments, receipts of payment, transfer of funds, or other transactional activities in financial services. This tag cannot be used for use cases beyond those listed above or for promotional content (ex: promotion for signups, or offers such as free trials for any financial products).", - "message_tag_pairing_update": "The pairing_update tag can be used to notify the message recipient that a pairing has been identified based on the recipient's prior request. Examples: Match has been confirmed in a dating app. User has confirmed an open parking spot for someone who previously requested one.", - "message_tag_application_update": "The application_update tag can be used to notify the message recipient of an update on the status of their application. Examples: Application is being reviewed. Application has been rejected.", - "message_tag_confirmed_event_reminder": "The confirmed_event_reminder tag can be used to send the message recipient reminders of a scheduled event for which a person is going to attend. Examples: Upcoming classes or events that a person has signed up for. Confirmation of attendance to an accepted event or appointment.", - "message_tag_community_alert": "The community_alert tag can be used to notify the message recipient of utility alerts, or safety checks in your community. Examples: Request a safety check. Notify of an emergency or utility alerts.", - "message_tag_non_promotional_subscription": "The non_promotional_subscription tag can be used to send non-promotional messages under the News, Productivity, and Personal Trackers categories described in the Messenger Platform's subscription messaging policy. You can apply for access to use this tag under the Page Settings > Messenger Platform. Use Cases: News. Productivity. Personal Trackers.", - "supported_message_type_non_promotional_subscription": "All message types and templates are supported, as long as the message adheres to the Messenger Platform's subscription messaging policy.", - "supported_message_type_issue_resolution": "Generic template and text messages are supported.", - "supported_message_type_others": "Only generic template messages are supported.", - "notification_type_regular": "Sound/Vibration", - "notification_type_silent_push": "On-screen notification only", - "notification_type_no_push": "No notification", "permanent": "When enabled, the variable value will be stored in the subscriber's profile and retained for future conversations." }, "charts": { diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index b7077c71..e44ac4c0 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -444,52 +444,10 @@ "delete": "Suppression", "role": "Rôle", "owner": "Propriétaire", - "app_secret": "Clé secrète Facebook", - "access_token": "Jeton d’accès de la Page Facebook", - "verify_token": "Jeton de Verification de l'URL de rappel", - "client_id": "ID Client", - "client_secret": "Clé secrète Client", - "app_id": "ID de l'application Facebook", - "page_id": "ID de la page Facebook", - "user_fields": "Champs utilisateur à récupérer (séparé par des virgules)", - "mode": "Mode", - "mode_options": { - "emulator": "Emulateur", - "live": "Production" - }, - "get_started_button": "Activer le bouton `Démarrer`", - "composer_input_disabled": "Désactiver la saisie du compositeur", - "greeting_text": "Texte de bienvenue", "provider": "Fournisseur", - "provider_options": { - "wit": "Wit.ai", - "rasa": "Rasa NLU" - }, "languages": "Langues disponibles", "default_lang": "Langue", "global_fallback": "Activer le message de secours global?", - "secret": "Mot de passe", - "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)", "channel": "Canal", "entry": "Contenu de l'entrée", "thumbnail": "Aperçu", @@ -620,41 +578,13 @@ "reset": "Mot de passe oublié?" }, "help": { - "verify_token": "Token que vous renverra Facebook dans le cadre de la vérification de l’URL de rappel.", - "composer_input_disabled": "Cela signifie que votre robot ne peut interagir qu'avec le menu persistant, les valeurs de retours, les boutons et les Webviews.", - "get_started_button": "L'écran de bienvenue d'un chatbot peut afficher un bouton `Démarrer`. Lorsque ce bouton est appuyé, la plate-forme Messenger envoie un événement à votre webhook (Payload = `Démarrer`).", - "greeting_text": "Le texte de bienvenue de votre chatbot vous permet de spécifier le message d'accueil que les utilisateurs verront sur l'écran d'accueil de votre chatbot. L'écran de bienvenue s'affiche pour les personnes qui interagissent avec votre chatbot pour la première fois.", "fallback_message": "Si aucun bloc de secours n'est spécifié, alors de ces messages sera envoyé.", - "app_id": "Obligatoire seulement si vous comptez utiliser Facebook Analytics", - "page_id": "Obligatoire seulement si vous comptez utiliser Facebook Analytics", "nlp_train": "Vous pouvez entraîner votre chatbot en ajoutant plusieurs exemples", "hit_enter_to_create": "Appuyez sur `Entrer` pour créer un nouveau", "nlp_precision": "Pour une entité, la précision est la probabilité de faire la bonne prédiction.", "nlp_recall": "Le rappel est la probabilité de reconnaître une entité parmi toutes les entités.", "nlp_f1score": "Le score F1 peut être interprété comme une moyenne pondérée de la précision et du rappel", "nlp_accuracy": "Le score de précision est la proportion des échantillons correctement classés.", - "message_tag_shipping_update": "Le tag shipping_update ne peut être utilisé que pour fournir une notification d'état d'expédition pour un produit déjà acheté. Par exemple, lorsque le produit est expédié, en transit, livré ou retardé. Cette étiquette ne peut pas être utilisée pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente).", - "message_tag_reservation_update": "Le tag reservation_update ne peut être utilisé que pour confirmer les mises à jour d'une réservation existante. Par exemple, lorsqu'il y a un changement d'itinéraire, d'emplacement ou d'annulation (par exemple, lorsqu'une réservation d'hôtel est annulée, l'heure de prise en charge d'une location de voiture change ou une surclassement de chambre est confirmée). Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente).", - "message_tag_issue_resolution": "Le tag issue_resolution ne peut être utilisé que pour répondre à un problème de service client apparu dans une conversation Messenger après qu'une transaction a eu lieu. Cette balise est destinée aux cas d'utilisation où l'entreprise a besoin de plus de 24 heures pour résoudre un problème et doit donner à quelqu'un une mise à jour du statut et / ou recueillir des informations supplémentaires. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux mentionnés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente, les entreprises ne peuvent pas utiliser le tag pour envoyer des commentaires proactifs).", - "message_tag_appointment_update": "Le tag appointment_update ne peut être utilisée que pour fournir des mises à jour sur un rendez-vous existant. Par exemple, lorsqu'il y a un changement dans le temps, une mise à jour ou une annulation (comme lorsqu'un spa est annulé, un agent immobilier doit vous rencontrer dans un nouvel endroit ou un cabinet dentaire propose une nouvelle heure de rendez-vous). Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente).", - "message_tag_game_event": "Le tag game_event ne peut être utilisé que pour fournir une mise à jour sur la progression de l'utilisateur, un événement global dans un jeu ou un événement sportif en direct. Par exemple, quand les récoltes d'une personne sont prêtes à être récoltées, leur construction est terminée, leur tournoi quotidien est sur le point de commencer ou leur équipe de football préférée est sur le point de jouer. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente).", - "message_tag_transportation_update": "Le tag transportation_update ne peut être utilisé que pour confirmer les mises à jour d'une réservation existante. Par exemple, en cas de changement de statut d'un vol, d'une réservation de train. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente).", - "message_tag_feature_functionality_update": "Le tag feature_functionality_update ne peut être utilisé que pour fournir une mise à jour sur les nouvelles fonctionnalités ou fonctionnalités qui deviennent disponibles dans un bot. Par exemple, annoncer la possibilité de parler à un agent en direct dans un bot, ou que le bot a une nouvelle compétence. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente).", - "message_tag_ticket_update": "Le tag ticket_update ne peut être utilisé que pour informer le destinataire du message des mises à jour ou des rappels relatifs à un événement pour lequel la personne a déjà confirmé sa présence. Par exemple, lorsque vous souhaitez envoyer un message concernant un changement d'heure, une mise à jour d'emplacement, une annulation ou un rappel pour un événement à venir (par exemple, lorsqu'un concert est annulé, le lieu a changé ou une possibilité de remboursement est disponible). Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: offres quotidiennes, coupons et remises, ou annonces de vente)", - "message_tag_account_update": "Le tag account_update peut uniquement être utilisé pour confirmer les mises à jour du paramètre de compte d'un utilisateur. Par exemple, en cas de modification des paramètres et des préférences d'un profil utilisateur, de la notification d'un changement de mot de passe ou de l'expiration de l'adhésion. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: promotion pour les inscriptions, créations de nouveaux comptes ou offres pour étendre les abonnements).", - "message_tag_payment_update": "Le tag payment_update peut être utilisé pour fournir des mises à jour de paiement à des transactions existantes. Par exemple, il peut être utilisé pour envoyer un reçu, un rupture de stock, une enchère terminée, un remboursement ou un changement de statut dans une transaction d'achat existante. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: toute promotion de vente croisée / incitative, coupons, ou offres d'extension d'abonnements).", - "message_tag_personal_finance_update": "Le tag PERSONAL_FINANCE_UPDATE peut être utilisé pour confirmer l'activité financière d'un utilisateur. Par exemple, il peut être utilisé pour envoyer des notifications sur les rappels de paiement de factures, les paiements programmés, les reçus de paiement, les transferts de fonds ou d'autres activités transactionnelles dans les services financiers. Ce tag ne peut pas être utilisé pour des cas d'utilisation autres que ceux énumérés ci-dessus ou pour du contenu promotionnel (ex: promotion pour les inscriptions, ou des offres telles que des essais gratuits pour tout produit financier).", - "message_tag_pairing_update": "Le tag pairing_update peut être utilisé pour notifier au destinataire du message qu'un jumelage a été identifié en fonction de la demande préalable du destinataire. Exemples: Le jumelage a été confirmé dans une application de rencontres. L'utilisateur a confirmé une place de parking ouverte pour quelqu'un qui en avait précédemment demandé une.", - "message_tag_application_update": "Le tag application_update peut être utilisé pour informer le destinataire du message d'une mise à jour sur l'état de son application. Exemples: L'application est en cours de révision. La demande a été rejetée.", - "message_tag_confirmed_event_reminder": "Le tag confirmed_event_reminder peut être utilisé pour envoyer aux destinataires du message des rappels d'un événement planifié auquel une personne va participer. Exemples: Cours ou événements à venir pour lesquels une personne s'est inscrite. Confirmation de participation à un événement ou à un rendez-vous accepté.", - "message_tag_community_alert": "Le tag community_alert peut être utilisé pour informer le destinataire du message des alertes utilitaires ou des vérifications de sécurité dans votre communauté. Exemples: Demander un contrôle de sécurité. Avertir d'une urgence ou d'alertes d'utilité.", - "message_tag_non_promotional_subscription": "Le tag non_promotion_subscription peut être utilisé pour envoyer des messages non promotionnels dans les catégories Actualités, Productivité et Suivi personnel décrites dans la politique d'abonnement à la messagerie de la plate-forme Messenger. Vous pouvez demander l'accès pour utiliser ce tag, allez dans les paramètres de la page Plate-forme Messenger. Cas d'utilisation: Nouvelles. Productivité. Traqueurs personnels.", - "supported_message_type_non_promotional_subscription": "Tous les types de messages et modèles sont pris en charge, à condition que le message respecte la politique d'abonnement à la messagerie de la plate-forme Messenger.", - "supported_message_type_issue_resolution": "Le modèle générique et les messages texte sont supportés.", - "supported_message_type_others": "Seuls les messages de modèle générique sont pris en charge.", - "notification_type_regular": "Son/Vibration", - "notification_type_silent_push": "Notification à l'écran uniquement", - "notification_type_no_push": "Aucune notification", "permanent": "Lorsqu'elle est activée, cette variable sera stockée dans le profil de l'abonné(e) et conservée pour les futures conversations." }, "charts": {