diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index 8af15595..722be1e7 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -11,6 +11,7 @@ import { AttachmentForeignKey, AttachmentPayload, } from '@/chat/schemas/types/attachment'; +import { SubscriberChannelData } from '@/chat/schemas/types/channel'; import { IncomingMessageType, StdEventType, @@ -19,16 +20,22 @@ import { import { Payload } from '@/chat/schemas/types/quick-reply'; import { NLU } from '@/helper/types'; -import ChannelHandler from './Handler'; +import ChannelHandler, { ChannelNameOf } from './Handler'; export interface ChannelEvent {} // eslint-disable-next-line prettier/prettier -export default abstract class EventWrapper { +export default abstract class EventWrapper< + A, + E, + C extends ChannelHandler = ChannelHandler, +> { _adapter: A = {} as A; _handler: C; + channelAttrs: SubscriberChannelDict[ChannelNameOf]; + _profile!: Subscriber; _nlp!: NLU.ParseEntities; @@ -39,14 +46,18 @@ export default abstract class EventWrapper] = {}, + ) { this._handler = handler; this._init(event); - this.set('channelData', channelData); + this.channelAttrs = channelAttrs; } toString() { @@ -95,8 +106,11 @@ export default abstract class EventWrapper> { + return { + name: this._handler.getName(), + ...this.channelAttrs, + } as SubscriberChannelData>; } /** @@ -282,15 +296,6 @@ export class GenericEventWrapper extends EventWrapper< this._adapter.raw = event; } - /** - * Returns channel related data - * - * @returns An object representing the channel specific data - */ - getChannelData(): any { - return this.get('channelData', {}); - } - /** * Returns the message id * diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 08eb6d3f..c23e58d9 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -28,6 +28,8 @@ import { ChannelName, ChannelSetting } from '../types'; import EventWrapper from './EventWrapper'; +export type ChannelNameOf = C extends ChannelHandler ? N : never; + @Injectable() export default abstract class ChannelHandler< N extends ChannelName = ChannelName, @@ -48,10 +50,14 @@ export default abstract class ChannelHandler< this.settings = require(path.join(this.getPath(), 'settings')).default; } + getName() { + return this.name as N; + } + async onModuleInit() { await super.onModuleInit(); this.channelService.setChannel( - this.getName() as ChannelName, + this.getName(), this as unknown as ChannelHandler, ); this.setup(); diff --git a/api/src/chat/controllers/message.controller.ts b/api/src/chat/controllers/message.controller.ts index 8bfc633c..ca34cd55 100644 --- a/api/src/chat/controllers/message.controller.ts +++ b/api/src/chat/controllers/message.controller.ts @@ -41,6 +41,7 @@ import { MessagePopulate, MessageStub, } from '../schemas/message.schema'; +import { Subscriber } from '../schemas/subscriber.schema'; import { AnyMessage, OutgoingMessage, @@ -137,7 +138,9 @@ export class MessageController extends BaseController< ); } - if (!this.channelService.findChannel(subscriber?.channel.name)) { + const channelData = Subscriber.getChannelData(subscriber); + + if (!this.channelService.findChannel(channelData.name)) { throw new BadRequestException(`Subscriber channel not found`); } @@ -146,7 +149,7 @@ export class MessageController extends BaseController< message: messageDto.message as StdOutgoingTextMessage, }; const channelHandler = this.channelService.getChannelHandler( - subscriber.channel.name, + channelData.name, ); const event = new GenericEventWrapper(channelHandler, { senderId: subscriber.foreign_id, diff --git a/api/src/chat/dto/subscriber.dto.ts b/api/src/chat/dto/subscriber.dto.ts index 60cf0f1e..82148f96 100644 --- a/api/src/chat/dto/subscriber.dto.ts +++ b/api/src/chat/dto/subscriber.dto.ts @@ -9,16 +9,17 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { IsArray, + IsDate, IsNotEmpty, IsNumber, - IsString, IsOptional, - IsDate, + IsString, } from 'class-validator'; +import { ChannelName } from '@/channel/types'; import { IsObjectId } from '@/utils/validation-rules/is-object-id'; -import { ChannelData } from '../schemas/types/channel'; +import { SubscriberChannelData } from '../schemas/types/channel'; import { IsChannelData } from '../validation-rules/is-channel-data'; export class SubscriberCreateDto { @@ -85,7 +86,7 @@ export class SubscriberCreateDto { }) @IsOptional() @IsDate() - assignedAt: Date | null; + assignedAt?: Date | null; @ApiPropertyOptional({ description: 'Subscriber last visit', @@ -93,7 +94,7 @@ export class SubscriberCreateDto { }) @IsOptional() @IsDate() - lastvisit: Date; + lastvisit?: Date; @ApiPropertyOptional({ description: 'Subscriber retained from', @@ -101,7 +102,7 @@ export class SubscriberCreateDto { }) @IsOptional() @IsDate() - retainedFrom: Date; + retainedFrom?: Date; @ApiProperty({ description: 'Subscriber channel', @@ -109,7 +110,7 @@ export class SubscriberCreateDto { }) @IsNotEmpty() @IsChannelData() - channel: ChannelData; + channel: SubscriberChannelData; } export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {} diff --git a/api/src/chat/index.d.ts b/api/src/chat/index.d.ts new file mode 100644 index 00000000..9bd66441 --- /dev/null +++ b/api/src/chat/index.d.ts @@ -0,0 +1,14 @@ +/* + * 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 { ChannelName } from '@/channel/types'; + +declare global { + interface SubscriberChannelDict + extends Record> {} +} diff --git a/api/src/chat/schemas/subscriber.schema.ts b/api/src/chat/schemas/subscriber.schema.ts index 87982362..5302df41 100644 --- a/api/src/chat/schemas/subscriber.schema.ts +++ b/api/src/chat/schemas/subscriber.schema.ts @@ -11,6 +11,7 @@ import { Transform, Type } from 'class-transformer'; import { Schema as MongooseSchema } from 'mongoose'; import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { ChannelName } from '@/channel/types'; import { User } from '@/user/schemas/user.schema'; import { BaseSchema } from '@/utils/generics/base-schema'; import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; @@ -20,7 +21,7 @@ import { } from '@/utils/types/filter.types'; import { Label } from './label.schema'; -import { ChannelData } from './types/channel'; +import { SubscriberChannelData } from './types/channel'; import { SubscriberContext } from './types/subscriberContext'; @Schema({ timestamps: true }) @@ -102,7 +103,7 @@ export class SubscriberStub extends BaseSchema { @Prop({ type: Object, }) - channel: ChannelData; + channel: SubscriberChannelData; @Prop({ type: MongooseSchema.Types.ObjectId, @@ -116,6 +117,13 @@ export class SubscriberStub extends BaseSchema { default: { vars: {} }, }) context?: SubscriberContext; + + static getChannelData< + C extends ChannelName, + S extends SubscriberStub = Subscriber, + >(subscriber: S) { + return subscriber.channel as SubscriberChannelData; + } } @Schema({ timestamps: true }) diff --git a/api/src/chat/schemas/types/channel.ts b/api/src/chat/schemas/types/channel.ts index 38a69083..4cab1fef 100644 --- a/api/src/chat/schemas/types/channel.ts +++ b/api/src/chat/schemas/types/channel.ts @@ -8,10 +8,14 @@ import { ChannelName } from '@/channel/types'; -interface BaseChannelData { - name: ChannelName; // channel name - isSocket?: boolean; - type?: any; //TODO: type has to be checked -} - -export type ChannelData = BaseChannelData; +export type SubscriberChannelData< + C extends ChannelName = null, + K extends keyof SubscriberChannelDict[C] = keyof SubscriberChannelDict[C], +> = C extends null + ? { name: ChannelName } + : { + name: C; + } & { + // Channel's specific attributes + [P in keyof SubscriberChannelDict[C]]: SubscriberChannelDict[C][K]; + }; diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index ac4e7b02..e15cbc75 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -236,7 +236,11 @@ describe('BlockService', () => { text: 'Hello', }, }, - {}, + { + isSocket: true, + ipAddress: '1.1.1.1', + agent: 'Chromium', + }, ); const webEventGetStarted = new WebEventWrapper( handlerMock, @@ -247,7 +251,11 @@ describe('BlockService', () => { payload: 'GET_STARTED', }, }, - {}, + { + isSocket: true, + ipAddress: '1.1.1.1', + agent: 'Chromium', + }, ); it('should return undefined when no blocks are provided', async () => { diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index d2acfc85..b9615a0f 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -186,6 +186,7 @@ describe('BlockService', () => { const event = new WebEventWrapper(handler, webEventText, { isSocket: false, ipAddress: '1.1.1.1', + agent: 'Chromium', }); const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] }); @@ -254,6 +255,7 @@ describe('BlockService', () => { const event = new WebEventWrapper(handler, webEventText, { isSocket: false, ipAddress: '1.1.1.1', + agent: 'Chromium', }); const webSubscriber = await subscriberService.findOne({ foreign_id: 'foreign-id-web-1', @@ -307,6 +309,7 @@ describe('BlockService', () => { const event = new WebEventWrapper(handler, webEventText, { isSocket: false, ipAddress: '1.1.1.1', + agent: 'Chromium', }); const webSubscriber = await subscriberService.findOne({ foreign_id: 'foreign-id-web-2', diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 7e0a556a..5ed33bf1 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -244,10 +244,7 @@ export class ChatService { if (!subscriber) { const subscriberData = await handler.getUserData(event); this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users'); - subscriberData.channel = { - ...event.getChannelData(), - name: handler.getName(), - }; + subscriberData.channel = event.getChannelData(); subscriber = await this.subscriberService.create(subscriberData); } else { // Already existing user profile diff --git a/api/src/chat/services/conversation.service.ts b/api/src/chat/services/conversation.service.ts index 1172c21a..6a7e5b31 100644 --- a/api/src/chat/services/conversation.service.ts +++ b/api/src/chat/services/conversation.service.ts @@ -9,7 +9,6 @@ import { Injectable, Logger } from '@nestjs/common'; import EventWrapper from '@/channel/lib/EventWrapper'; -import { ChannelName } from '@/channel/types'; import { LoggerService } from '@/logger/logger.service'; import { BaseService } from '@/utils/generics/base-service'; @@ -70,7 +69,7 @@ export class ConversationService extends BaseService< const msgType = event.getMessageType(); const profile = event.getSender(); // Capture channel specific context data - convo.context.channel = event.getHandler().getName() as ChannelName; + convo.context.channel = event.getHandler().getName(); convo.context.text = event.getText(); convo.context.payload = event.getPayload(); convo.context.nlp = event.getNLP(); diff --git a/api/src/extensions/channels/console/index.d.ts b/api/src/extensions/channels/console/index.d.ts index e7977a40..ec17d286 100644 --- a/api/src/extensions/channels/console/index.d.ts +++ b/api/src/extensions/channels/console/index.d.ts @@ -12,6 +12,13 @@ import CONSOLE_CHANNEL_SETTINGS, { declare global { interface Settings extends SettingTree {} + interface SubscriberChannelDict { + [CONSOLE_CHANNEL_NAME]: { + isSocket: boolean; + ipAddress: string; + agent: string; + }; + } } declare module '@nestjs/event-emitter' { diff --git a/api/src/extensions/channels/hexabot-channel-messenger b/api/src/extensions/channels/hexabot-channel-messenger new file mode 160000 index 00000000..cf7004ef --- /dev/null +++ b/api/src/extensions/channels/hexabot-channel-messenger @@ -0,0 +1 @@ +Subproject commit cf7004ef6adac1b5e033d06987f493a9b00e01d2 diff --git a/api/src/extensions/channels/web/__test__/wrapper.spec.ts b/api/src/extensions/channels/web/__test__/wrapper.spec.ts index c206a6c0..66fae47e 100644 --- a/api/src/extensions/channels/web/__test__/wrapper.spec.ts +++ b/api/src/extensions/channels/web/__test__/wrapper.spec.ts @@ -37,6 +37,7 @@ import { SocketEventDispatcherService } from '@/websocket/services/socket-event- import { WebsocketGateway } from '@/websocket/websocket.gateway'; import WebChannelHandler from '../index.channel'; +import { WEB_CHANNEL_NAME } from '../settings'; import WebEventWrapper from '../wrapper'; import { webEvents } from './events.mock'; @@ -119,7 +120,10 @@ describe(`Web event wrapper`, () => { e, expected.channelData, ); - expect(event.getChannelData()).toEqual(expected.channelData); + expect(event.getChannelData()).toEqual({ + ...expected.channelData, + name: WEB_CHANNEL_NAME, + }); expect(event.getId()).toEqual(expected.id); expect(event.getEventType()).toEqual(expected.eventType); expect(event.getMessageType()).toEqual(expected.messageType); diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index bdf3629f..eb428c20 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -59,7 +59,7 @@ import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; -import { WEB_CHANNEL_NAMESPACE } from './settings'; +import { WEB_CHANNEL_NAME, WEB_CHANNEL_NAMESPACE } from './settings'; import { Web } from './types'; import WebEventWrapper from './wrapper'; @@ -433,7 +433,6 @@ export default abstract class BaseWebChannelHandler< return subscriber; } - const channelData = this.getChannelData(req); const newProfile: SubscriberCreateDto = { foreign_id: this.generateId(), first_name: data.first_name ? data.first_name.toString() : 'Anon.', @@ -443,8 +442,8 @@ export default abstract class BaseWebChannelHandler< lastvisit: new Date(), retainedFrom: new Date(), channel: { - ...channelData, - name: this.getName() as ChannelName, + name: this.getName(), + ...this.getChannelAttributes(req), }, language: '', locale: '', @@ -736,13 +735,15 @@ export default abstract class BaseWebChannelHandler< } /** - * Handle channel event (probably a message) + * Return subscriber channel specific attributes * * @param req * - * @returns The channel's data + * @returns The subscriber channel's attributes */ - protected getChannelData(req: Request | SocketRequest): Web.ChannelData { + getChannelAttributes( + req: Request | SocketRequest, + ): SubscriberChannelDict[typeof WEB_CHANNEL_NAME] { return { isSocket: 'isSocket' in req && !!req.isSocket, ipAddress: this.getIpAddress(req), @@ -780,11 +781,11 @@ export default abstract class BaseWebChannelHandler< if (upload) { data.data = upload; } - const channelData = this.getChannelData(req); + const channelAttrs = this.getChannelAttributes(req); const event: WebEventWrapper = new WebEventWrapper( this, data, - channelData, + channelAttrs, ); if (event.getEventType() === 'message') { // Handler sync message sent by chabbot @@ -1185,7 +1186,9 @@ export default abstract class BaseWebChannelHandler< type: StdEventType, content: any, ): void { - if (subscriber.channel.isSocket) { + const channelData = + Subscriber.getChannelData(subscriber); + if (channelData.isSocket) { this.websocketGateway.broadcast(subscriber, type, content); } else { // Do nothing, messages will be retrieved via polling @@ -1278,6 +1281,17 @@ export default abstract class BaseWebChannelHandler< * @returns The web's response, otherwise an error */ async getUserData(event: WebEventWrapper): Promise { - return event.getSender() as SubscriberCreateDto; + const sender = event.getSender(); + const { + id: _id, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...rest + } = sender; + const subscriber: SubscriberCreateDto = { + ...rest, + channel: Subscriber.getChannelData(sender), + }; + return subscriber; } } diff --git a/api/src/extensions/channels/web/index.d.ts b/api/src/extensions/channels/web/index.d.ts index 4261bea5..a1677fa2 100644 --- a/api/src/extensions/channels/web/index.d.ts +++ b/api/src/extensions/channels/web/index.d.ts @@ -7,11 +7,20 @@ */ import DEFAULT_WEB_CHANNEL_SETTINGS, { + WEB_CHANNEL_NAME, WEB_CHANNEL_NAMESPACE, } from './settings'; declare global { interface Settings extends SettingTree {} + + interface SubscriberChannelDict { + [WEB_CHANNEL_NAME]: { + isSocket: boolean; + ipAddress: string; + agent: string; + }; + } } declare module '@nestjs/event-emitter' { diff --git a/api/src/extensions/channels/web/types.ts b/api/src/extensions/channels/web/types.ts index 5d475728..ec263bf8 100644 --- a/api/src/extensions/channels/web/types.ts +++ b/api/src/extensions/channels/web/types.ts @@ -30,12 +30,6 @@ export namespace Web { export type Settings = Record; - export type ChannelData = { - isSocket: boolean; - ipAddress: string; - agent: string; - }; - export type RequestSession = { web?: { profile: SubscriberFull; diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index e36ff5d9..b1650be7 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -21,6 +21,7 @@ import { import { Payload } from '@/chat/schemas/types/quick-reply'; import BaseWebChannelHandler from './base-web-channel'; +import { WEB_CHANNEL_NAME } from './settings'; import { Web } from './types'; type WebEventAdapter = @@ -76,10 +77,14 @@ export default class WebEventWrapper< * * @param handler - The channel's handler * @param event - The message event received - * @param channelData - Channel's specific extra data {isSocket, ipAddress} + * @param channelAttrs - Channel's specific extra attributes {isSocket, ipAddress} */ - constructor(handler: T, event: Web.Event, channelData: any) { - super(handler, event, channelData); + constructor( + handler: T, + event: Web.Event, + channelAttrs: SubscriberChannelDict[typeof WEB_CHANNEL_NAME], + ) { + super(handler, event, channelAttrs); } /** @@ -129,20 +134,6 @@ export default class WebEventWrapper< this._adapter.raw = event; } - /** - * Returns channel related data - * - * @returns Channel's data - */ - getChannelData(): any { - return this.get('channelData', { - isSocket: true, - ipAddress: '0.0.0.0', - agent: - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', - }); - } - /** * Returns the message id *