Merge pull request #433 from Hexastack/fix/channel-data-inference-type-issue

fix: channels data type inference
This commit is contained in:
Med Marrouchi 2024-12-10 07:45:24 +01:00 committed by GitHub
commit 1f61e43f58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 148 additions and 80 deletions

View File

@ -11,6 +11,7 @@ import {
AttachmentForeignKey, AttachmentForeignKey,
AttachmentPayload, AttachmentPayload,
} from '@/chat/schemas/types/attachment'; } from '@/chat/schemas/types/attachment';
import { SubscriberChannelData } from '@/chat/schemas/types/channel';
import { import {
IncomingMessageType, IncomingMessageType,
StdEventType, StdEventType,
@ -19,16 +20,22 @@ import {
import { Payload } from '@/chat/schemas/types/quick-reply'; import { Payload } from '@/chat/schemas/types/quick-reply';
import { NLU } from '@/helper/types'; import { NLU } from '@/helper/types';
import ChannelHandler from './Handler'; import ChannelHandler, { ChannelNameOf } from './Handler';
export interface ChannelEvent {} export interface ChannelEvent {}
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier
export default abstract class EventWrapper<A, E, C extends ChannelHandler = ChannelHandler> { export default abstract class EventWrapper<
A,
E,
C extends ChannelHandler = ChannelHandler,
> {
_adapter: A = {} as A; _adapter: A = {} as A;
_handler: C; _handler: C;
channelAttrs: SubscriberChannelDict[ChannelNameOf<C>];
_profile!: Subscriber; _profile!: Subscriber;
_nlp!: NLU.ParseEntities; _nlp!: NLU.ParseEntities;
@ -39,14 +46,18 @@ export default abstract class EventWrapper<A, E, C extends ChannelHandler = Chan
* *
* Any method declared in this class should be extended and overridden in any given channel's * Any method declared in this class should be extended and overridden in any given channel's
* event wrapper if needed. * event wrapper if needed.
* @param handler - The channel's handler * @param handler - The channel's handler
* @param event - The message event received * @param event - The message event received
* @param channelData - Channel's specific data * @param channelAttrs - Channel's specific data
*/ */
constructor(handler: C, event: E, channelData: any = {}) { constructor(
handler: C,
event: E,
channelAttrs: SubscriberChannelDict[ChannelNameOf<C>] = {},
) {
this._handler = handler; this._handler = handler;
this._init(event); this._init(event);
this.set('channelData', channelData); this.channelAttrs = channelAttrs;
} }
toString() { toString() {
@ -95,8 +106,11 @@ export default abstract class EventWrapper<A, E, C extends ChannelHandler = Chan
* *
* @returns Returns any channel related data. * @returns Returns any channel related data.
*/ */
getChannelData(): any { getChannelData(): SubscriberChannelData<ChannelNameOf<C>> {
return this.get('channelData', {}); return {
name: this._handler.getName(),
...this.channelAttrs,
} as SubscriberChannelData<ChannelNameOf<C>>;
} }
/** /**
@ -282,15 +296,6 @@ export class GenericEventWrapper extends EventWrapper<
this._adapter.raw = event; 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 * Returns the message id
* *

View File

@ -28,6 +28,8 @@ import { ChannelName, ChannelSetting } from '../types';
import EventWrapper from './EventWrapper'; import EventWrapper from './EventWrapper';
export type ChannelNameOf<C> = C extends ChannelHandler<infer N> ? N : never;
@Injectable() @Injectable()
export default abstract class ChannelHandler< export default abstract class ChannelHandler<
N extends ChannelName = ChannelName, N extends ChannelName = ChannelName,
@ -48,10 +50,14 @@ export default abstract class ChannelHandler<
this.settings = require(path.join(this.getPath(), 'settings')).default; this.settings = require(path.join(this.getPath(), 'settings')).default;
} }
getName() {
return this.name as N;
}
async onModuleInit() { async onModuleInit() {
await super.onModuleInit(); await super.onModuleInit();
this.channelService.setChannel( this.channelService.setChannel(
this.getName() as ChannelName, this.getName(),
this as unknown as ChannelHandler<N>, this as unknown as ChannelHandler<N>,
); );
this.setup(); this.setup();

View File

@ -41,6 +41,7 @@ import {
MessagePopulate, MessagePopulate,
MessageStub, MessageStub,
} from '../schemas/message.schema'; } from '../schemas/message.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { import {
AnyMessage, AnyMessage,
OutgoingMessage, 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`); throw new BadRequestException(`Subscriber channel not found`);
} }
@ -146,7 +149,7 @@ export class MessageController extends BaseController<
message: messageDto.message as StdOutgoingTextMessage, message: messageDto.message as StdOutgoingTextMessage,
}; };
const channelHandler = this.channelService.getChannelHandler( const channelHandler = this.channelService.getChannelHandler(
subscriber.channel.name, channelData.name,
); );
const event = new GenericEventWrapper(channelHandler, { const event = new GenericEventWrapper(channelHandler, {
senderId: subscriber.foreign_id, senderId: subscriber.foreign_id,

View File

@ -9,16 +9,17 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { import {
IsArray, IsArray,
IsDate,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsString,
IsOptional, IsOptional,
IsDate, IsString,
} from 'class-validator'; } from 'class-validator';
import { ChannelName } from '@/channel/types';
import { IsObjectId } from '@/utils/validation-rules/is-object-id'; 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'; import { IsChannelData } from '../validation-rules/is-channel-data';
export class SubscriberCreateDto { export class SubscriberCreateDto {
@ -85,7 +86,7 @@ export class SubscriberCreateDto {
}) })
@IsOptional() @IsOptional()
@IsDate() @IsDate()
assignedAt: Date | null; assignedAt?: Date | null;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Subscriber last visit', description: 'Subscriber last visit',
@ -93,7 +94,7 @@ export class SubscriberCreateDto {
}) })
@IsOptional() @IsOptional()
@IsDate() @IsDate()
lastvisit: Date; lastvisit?: Date;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Subscriber retained from', description: 'Subscriber retained from',
@ -101,7 +102,7 @@ export class SubscriberCreateDto {
}) })
@IsOptional() @IsOptional()
@IsDate() @IsDate()
retainedFrom: Date; retainedFrom?: Date;
@ApiProperty({ @ApiProperty({
description: 'Subscriber channel', description: 'Subscriber channel',
@ -109,7 +110,7 @@ export class SubscriberCreateDto {
}) })
@IsNotEmpty() @IsNotEmpty()
@IsChannelData() @IsChannelData()
channel: ChannelData; channel: SubscriberChannelData<ChannelName>;
} }
export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {} export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {}

14
api/src/chat/index.d.ts vendored Normal file
View File

@ -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<ChannelName | string, Record<string, any>> {}
}

View File

@ -11,6 +11,7 @@ import { Transform, Type } from 'class-transformer';
import { Schema as MongooseSchema } from 'mongoose'; import { Schema as MongooseSchema } from 'mongoose';
import { Attachment } from '@/attachment/schemas/attachment.schema'; import { Attachment } from '@/attachment/schemas/attachment.schema';
import { ChannelName } from '@/channel/types';
import { User } from '@/user/schemas/user.schema'; import { User } from '@/user/schemas/user.schema';
import { BaseSchema } from '@/utils/generics/base-schema'; import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@ -20,7 +21,7 @@ import {
} from '@/utils/types/filter.types'; } from '@/utils/types/filter.types';
import { Label } from './label.schema'; import { Label } from './label.schema';
import { ChannelData } from './types/channel'; import { SubscriberChannelData } from './types/channel';
import { SubscriberContext } from './types/subscriberContext'; import { SubscriberContext } from './types/subscriberContext';
@Schema({ timestamps: true }) @Schema({ timestamps: true })
@ -102,7 +103,7 @@ export class SubscriberStub extends BaseSchema {
@Prop({ @Prop({
type: Object, type: Object,
}) })
channel: ChannelData; channel: SubscriberChannelData;
@Prop({ @Prop({
type: MongooseSchema.Types.ObjectId, type: MongooseSchema.Types.ObjectId,
@ -116,6 +117,13 @@ export class SubscriberStub extends BaseSchema {
default: { vars: {} }, default: { vars: {} },
}) })
context?: SubscriberContext; context?: SubscriberContext;
static getChannelData<
C extends ChannelName,
S extends SubscriberStub = Subscriber,
>(subscriber: S) {
return subscriber.channel as SubscriberChannelData<C>;
}
} }
@Schema({ timestamps: true }) @Schema({ timestamps: true })

View File

@ -8,10 +8,14 @@
import { ChannelName } from '@/channel/types'; import { ChannelName } from '@/channel/types';
interface BaseChannelData { export type SubscriberChannelData<
name: ChannelName; // channel name C extends ChannelName = null,
isSocket?: boolean; K extends keyof SubscriberChannelDict[C] = keyof SubscriberChannelDict[C],
type?: any; //TODO: type has to be checked > = C extends null
} ? { name: ChannelName }
: {
export type ChannelData = BaseChannelData; name: C;
} & {
// Channel's specific attributes
[P in keyof SubscriberChannelDict[C]]: SubscriberChannelDict[C][K];
};

View File

@ -236,7 +236,11 @@ describe('BlockService', () => {
text: 'Hello', text: 'Hello',
}, },
}, },
{}, {
isSocket: true,
ipAddress: '1.1.1.1',
agent: 'Chromium',
},
); );
const webEventGetStarted = new WebEventWrapper( const webEventGetStarted = new WebEventWrapper(
handlerMock, handlerMock,
@ -247,7 +251,11 @@ describe('BlockService', () => {
payload: 'GET_STARTED', payload: 'GET_STARTED',
}, },
}, },
{}, {
isSocket: true,
ipAddress: '1.1.1.1',
agent: 'Chromium',
},
); );
it('should return undefined when no blocks are provided', async () => { it('should return undefined when no blocks are provided', async () => {

View File

@ -186,6 +186,7 @@ describe('BlockService', () => {
const event = new WebEventWrapper(handler, webEventText, { const event = new WebEventWrapper(handler, webEventText, {
isSocket: false, isSocket: false,
ipAddress: '1.1.1.1', ipAddress: '1.1.1.1',
agent: 'Chromium',
}); });
const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] }); const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] });
@ -254,6 +255,7 @@ describe('BlockService', () => {
const event = new WebEventWrapper(handler, webEventText, { const event = new WebEventWrapper(handler, webEventText, {
isSocket: false, isSocket: false,
ipAddress: '1.1.1.1', ipAddress: '1.1.1.1',
agent: 'Chromium',
}); });
const webSubscriber = await subscriberService.findOne({ const webSubscriber = await subscriberService.findOne({
foreign_id: 'foreign-id-web-1', foreign_id: 'foreign-id-web-1',
@ -307,6 +309,7 @@ describe('BlockService', () => {
const event = new WebEventWrapper(handler, webEventText, { const event = new WebEventWrapper(handler, webEventText, {
isSocket: false, isSocket: false,
ipAddress: '1.1.1.1', ipAddress: '1.1.1.1',
agent: 'Chromium',
}); });
const webSubscriber = await subscriberService.findOne({ const webSubscriber = await subscriberService.findOne({
foreign_id: 'foreign-id-web-2', foreign_id: 'foreign-id-web-2',

View File

@ -244,10 +244,7 @@ export class ChatService {
if (!subscriber) { if (!subscriber) {
const subscriberData = await handler.getUserData(event); const subscriberData = await handler.getUserData(event);
this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users'); this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users');
subscriberData.channel = { subscriberData.channel = event.getChannelData();
...event.getChannelData(),
name: handler.getName(),
};
subscriber = await this.subscriberService.create(subscriberData); subscriber = await this.subscriberService.create(subscriberData);
} else { } else {
// Already existing user profile // Already existing user profile

View File

@ -9,7 +9,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import EventWrapper from '@/channel/lib/EventWrapper'; import EventWrapper from '@/channel/lib/EventWrapper';
import { ChannelName } from '@/channel/types';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service'; import { BaseService } from '@/utils/generics/base-service';
@ -70,7 +69,7 @@ export class ConversationService extends BaseService<
const msgType = event.getMessageType(); const msgType = event.getMessageType();
const profile = event.getSender(); const profile = event.getSender();
// Capture channel specific context data // 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.text = event.getText();
convo.context.payload = event.getPayload(); convo.context.payload = event.getPayload();
convo.context.nlp = event.getNLP(); convo.context.nlp = event.getNLP();

View File

@ -12,6 +12,13 @@ import CONSOLE_CHANNEL_SETTINGS, {
declare global { declare global {
interface Settings extends SettingTree<typeof CONSOLE_CHANNEL_SETTINGS> {} interface Settings extends SettingTree<typeof CONSOLE_CHANNEL_SETTINGS> {}
interface SubscriberChannelDict {
[CONSOLE_CHANNEL_NAME]: {
isSocket: boolean;
ipAddress: string;
agent: string;
};
}
} }
declare module '@nestjs/event-emitter' { declare module '@nestjs/event-emitter' {

@ -0,0 +1 @@
Subproject commit cf7004ef6adac1b5e033d06987f493a9b00e01d2

View File

@ -37,6 +37,7 @@ import { SocketEventDispatcherService } from '@/websocket/services/socket-event-
import { WebsocketGateway } from '@/websocket/websocket.gateway'; import { WebsocketGateway } from '@/websocket/websocket.gateway';
import WebChannelHandler from '../index.channel'; import WebChannelHandler from '../index.channel';
import { WEB_CHANNEL_NAME } from '../settings';
import WebEventWrapper from '../wrapper'; import WebEventWrapper from '../wrapper';
import { webEvents } from './events.mock'; import { webEvents } from './events.mock';
@ -119,7 +120,10 @@ describe(`Web event wrapper`, () => {
e, e,
expected.channelData, 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.getId()).toEqual(expected.id);
expect(event.getEventType()).toEqual(expected.eventType); expect(event.getEventType()).toEqual(expected.eventType);
expect(event.getMessageType()).toEqual(expected.messageType); expect(event.getMessageType()).toEqual(expected.messageType);

View File

@ -59,7 +59,7 @@ import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response'; import { SocketResponse } from '@/websocket/utils/socket-response';
import { WebsocketGateway } from '@/websocket/websocket.gateway'; 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 { Web } from './types';
import WebEventWrapper from './wrapper'; import WebEventWrapper from './wrapper';
@ -433,7 +433,6 @@ export default abstract class BaseWebChannelHandler<
return subscriber; return subscriber;
} }
const channelData = this.getChannelData(req);
const newProfile: SubscriberCreateDto = { const newProfile: SubscriberCreateDto = {
foreign_id: this.generateId(), foreign_id: this.generateId(),
first_name: data.first_name ? data.first_name.toString() : 'Anon.', first_name: data.first_name ? data.first_name.toString() : 'Anon.',
@ -443,8 +442,8 @@ export default abstract class BaseWebChannelHandler<
lastvisit: new Date(), lastvisit: new Date(),
retainedFrom: new Date(), retainedFrom: new Date(),
channel: { channel: {
...channelData, name: this.getName(),
name: this.getName() as ChannelName, ...this.getChannelAttributes(req),
}, },
language: '', language: '',
locale: '', locale: '',
@ -736,13 +735,15 @@ export default abstract class BaseWebChannelHandler<
} }
/** /**
* Handle channel event (probably a message) * Return subscriber channel specific attributes
* *
* @param req * @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 { return {
isSocket: 'isSocket' in req && !!req.isSocket, isSocket: 'isSocket' in req && !!req.isSocket,
ipAddress: this.getIpAddress(req), ipAddress: this.getIpAddress(req),
@ -780,11 +781,11 @@ export default abstract class BaseWebChannelHandler<
if (upload) { if (upload) {
data.data = upload; data.data = upload;
} }
const channelData = this.getChannelData(req); const channelAttrs = this.getChannelAttributes(req);
const event: WebEventWrapper = new WebEventWrapper( const event: WebEventWrapper = new WebEventWrapper(
this, this,
data, data,
channelData, channelAttrs,
); );
if (event.getEventType() === 'message') { if (event.getEventType() === 'message') {
// Handler sync message sent by chabbot // Handler sync message sent by chabbot
@ -1185,7 +1186,9 @@ export default abstract class BaseWebChannelHandler<
type: StdEventType, type: StdEventType,
content: any, content: any,
): void { ): void {
if (subscriber.channel.isSocket) { const channelData =
Subscriber.getChannelData<typeof WEB_CHANNEL_NAME>(subscriber);
if (channelData.isSocket) {
this.websocketGateway.broadcast(subscriber, type, content); this.websocketGateway.broadcast(subscriber, type, content);
} else { } else {
// Do nothing, messages will be retrieved via polling // 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 * @returns The web's response, otherwise an error
*/ */
async getUserData(event: WebEventWrapper): Promise<SubscriberCreateDto> { async getUserData(event: WebEventWrapper): Promise<SubscriberCreateDto> {
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;
} }
} }

View File

@ -7,11 +7,20 @@
*/ */
import DEFAULT_WEB_CHANNEL_SETTINGS, { import DEFAULT_WEB_CHANNEL_SETTINGS, {
WEB_CHANNEL_NAME,
WEB_CHANNEL_NAMESPACE, WEB_CHANNEL_NAMESPACE,
} from './settings'; } from './settings';
declare global { declare global {
interface Settings extends SettingTree<typeof DEFAULT_WEB_CHANNEL_SETTINGS> {} interface Settings extends SettingTree<typeof DEFAULT_WEB_CHANNEL_SETTINGS> {}
interface SubscriberChannelDict {
[WEB_CHANNEL_NAME]: {
isSocket: boolean;
ipAddress: string;
agent: string;
};
}
} }
declare module '@nestjs/event-emitter' { declare module '@nestjs/event-emitter' {

View File

@ -30,12 +30,6 @@ export namespace Web {
export type Settings = Record<SettingLabel, any>; export type Settings = Record<SettingLabel, any>;
export type ChannelData = {
isSocket: boolean;
ipAddress: string;
agent: string;
};
export type RequestSession = { export type RequestSession = {
web?: { web?: {
profile: SubscriberFull; profile: SubscriberFull;

View File

@ -21,6 +21,7 @@ import {
import { Payload } from '@/chat/schemas/types/quick-reply'; import { Payload } from '@/chat/schemas/types/quick-reply';
import BaseWebChannelHandler from './base-web-channel'; import BaseWebChannelHandler from './base-web-channel';
import { WEB_CHANNEL_NAME } from './settings';
import { Web } from './types'; import { Web } from './types';
type WebEventAdapter = type WebEventAdapter =
@ -76,10 +77,14 @@ export default class WebEventWrapper<
* *
* @param handler - The channel's handler * @param handler - The channel's handler
* @param event - The message event received * @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) { constructor(
super(handler, event, channelData); 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; 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 * Returns the message id
* *