mirror of
https://github.com/hexastack/hexabot
synced 2025-05-31 10:57:06 +00:00
579 lines
18 KiB
TypeScript
579 lines
18 KiB
TypeScript
/*
|
|
* 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).
|
|
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
|
*/
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
|
import { AttachmentService } from '@/attachment/services/attachment.service';
|
|
import EventWrapper from '@/channel/lib/EventWrapper';
|
|
import { ContentService } from '@/cms/services/content.service';
|
|
import { I18nService } from '@/i18n/services/i18n.service';
|
|
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 { BlockRepository } from '../repositories/block.repository';
|
|
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
|
|
import { WithUrl } from '../schemas/types/attachment';
|
|
import { Context } from '../schemas/types/context';
|
|
import {
|
|
BlockMessage,
|
|
OutgoingMessageFormat,
|
|
StdOutgoingEnvelope,
|
|
} from '../schemas/types/message';
|
|
import { NlpPattern, Pattern, PayloadPattern } from '../schemas/types/pattern';
|
|
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
|
|
|
|
@Injectable()
|
|
export class BlockService extends BaseService<Block, BlockPopulate, BlockFull> {
|
|
constructor(
|
|
readonly repository: BlockRepository,
|
|
private readonly contentService: ContentService,
|
|
private readonly attachmentService: AttachmentService,
|
|
private readonly settingService: SettingService,
|
|
private readonly pluginService: PluginService,
|
|
private readonly logger: LoggerService,
|
|
protected readonly i18n: I18nService,
|
|
) {
|
|
super(repository);
|
|
}
|
|
|
|
/**
|
|
* Find a block whose patterns matches the received event
|
|
*
|
|
* @param blocks blocks Starting/Next blocks in the conversation flow
|
|
* @param event Received channel's message
|
|
*
|
|
* @returns The block that matches
|
|
*/
|
|
async match(
|
|
blocks: BlockFull[],
|
|
event: EventWrapper<any, any>,
|
|
): Promise<BlockFull | undefined> {
|
|
// Search for block matching a given event
|
|
let block: BlockFull | undefined = undefined;
|
|
const payload = event.getPayload();
|
|
|
|
// Perform a filter on the specific channels
|
|
const channel = event.getHandler().getChannel();
|
|
blocks = blocks.filter((b) => {
|
|
return (
|
|
!b.trigger_channels ||
|
|
b.trigger_channels.length === 0 ||
|
|
b.trigger_channels.includes(channel)
|
|
);
|
|
});
|
|
|
|
// Perform a filter on trigger labels
|
|
let userLabels: string[] = [];
|
|
const profile = event.getSender();
|
|
if (profile && Array.isArray(profile.labels)) {
|
|
userLabels = profile.labels.map((l) => l);
|
|
}
|
|
|
|
blocks = blocks
|
|
.filter((b) => {
|
|
const trigger_labels = b.trigger_labels.map(({ id }) => id);
|
|
return (
|
|
trigger_labels.length === 0 ||
|
|
trigger_labels.some((l) => userLabels.includes(l))
|
|
);
|
|
})
|
|
// Priority goes to block who target users with labels
|
|
.sort((a, b) => b.trigger_labels.length - a.trigger_labels.length);
|
|
|
|
// Perform a payload match & pick last createdAt
|
|
if (payload) {
|
|
block = blocks
|
|
.filter((b) => {
|
|
return this.matchPayload(payload, b);
|
|
})
|
|
.shift();
|
|
}
|
|
|
|
if (!block) {
|
|
// Perform a text match (Text or Quick reply)
|
|
const text = event.getText().trim();
|
|
|
|
// Check & catch user language through NLP
|
|
const nlp = event.getNLP();
|
|
if (nlp) {
|
|
const settings = await this.settingService.getSettings();
|
|
const lang = nlp.entities.find((e) => e.entity === 'language');
|
|
if (
|
|
lang &&
|
|
settings.nlp_settings.languages.indexOf(lang.value) !== -1
|
|
) {
|
|
const profile = event.getSender();
|
|
profile.language = lang.value;
|
|
event.setSender(profile);
|
|
}
|
|
}
|
|
|
|
// Perform a text pattern match
|
|
block = blocks
|
|
.filter((b) => {
|
|
return this.matchText(text, b);
|
|
})
|
|
.shift();
|
|
|
|
// Perform an NLP Match
|
|
if (!block && nlp) {
|
|
// Find block pattern having the best match of nlp entities
|
|
let nlpBest = 0;
|
|
blocks.forEach((b, index, self) => {
|
|
const nlpPattern = this.matchNLP(nlp, b);
|
|
if (nlpPattern && nlpPattern.length > nlpBest) {
|
|
nlpBest = nlpPattern.length;
|
|
block = self[index];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
// Uknown event type => return false;
|
|
// this.logger.error('Unable to recognize event type while matching', event);
|
|
return block;
|
|
}
|
|
|
|
/**
|
|
* Performs a payload pattern match for the provided block
|
|
*
|
|
* @param payload - The payload
|
|
* @param block - The block
|
|
*
|
|
* @returns The payload pattern if there's a match
|
|
*/
|
|
matchPayload(
|
|
payload: string | Payload,
|
|
block: BlockFull | Block,
|
|
): PayloadPattern | undefined {
|
|
const payloadPatterns = block.patterns.filter(
|
|
(p) => typeof p === 'object' && 'label' in p,
|
|
) as PayloadPattern[];
|
|
|
|
return payloadPatterns.find((pt: PayloadPattern) => {
|
|
// Either button postback payload Or content payload (ex. BTN_TITLE:CONTENT_PAYLOAD)
|
|
return (
|
|
(typeof payload === 'string' &&
|
|
pt.value &&
|
|
(pt.value === payload || payload.startsWith(pt.value + ':'))) ||
|
|
// Or attachment postback (ex. Like location quick reply for example)
|
|
(typeof payload === 'object' && pt.type && pt.type === payload.type)
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if the block has matching text/regex patterns
|
|
*
|
|
* @param text - The received text message
|
|
* @param block - The block to check against
|
|
*
|
|
* @returns False if no match, string/regex capture else
|
|
*/
|
|
matchText(
|
|
text: string,
|
|
block: Block | BlockFull,
|
|
): (RegExpMatchArray | string)[] | false {
|
|
// Filter text patterns & Instanciate Regex patterns
|
|
const patterns: (string | RegExp | Pattern)[] = block.patterns.map(
|
|
(pattern) => {
|
|
if (
|
|
typeof pattern === 'string' &&
|
|
pattern.endsWith('/') &&
|
|
pattern.startsWith('/')
|
|
) {
|
|
return new RegExp(pattern.slice(1, -1), 'i');
|
|
}
|
|
return pattern;
|
|
},
|
|
);
|
|
|
|
// Return first match
|
|
for (let i = 0; i < patterns.length; i++) {
|
|
const pattern = patterns[i];
|
|
if (pattern instanceof RegExp) {
|
|
if (pattern.test(text)) {
|
|
const matches = text.match(pattern);
|
|
if (matches) {
|
|
if (matches.length >= 2) {
|
|
// Remove global match if needed
|
|
matches.shift();
|
|
}
|
|
return matches;
|
|
}
|
|
}
|
|
continue;
|
|
} else if (
|
|
typeof pattern === 'object' &&
|
|
'label' in pattern &&
|
|
text.trim().toLowerCase() === pattern.label.toLowerCase()
|
|
) {
|
|
// Payload (quick reply)
|
|
return [text];
|
|
} else if (
|
|
typeof pattern === 'string' &&
|
|
text.trim().toLowerCase() === pattern.toLowerCase()
|
|
) {
|
|
// Equals
|
|
return [text];
|
|
}
|
|
// @deprecated
|
|
// else if (
|
|
// typeof pattern === 'string' &&
|
|
// Soundex(text) === Soundex(pattern)
|
|
// ) {
|
|
// // Sound like
|
|
// return [text];
|
|
// }
|
|
}
|
|
// No match
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Performs an NLP pattern match based on the best guessed entities and/or values
|
|
*
|
|
* @param nlp - Parsed NLP entities
|
|
* @param block - The block to test
|
|
*
|
|
* @returns The NLP patterns that matches
|
|
*/
|
|
matchNLP(
|
|
nlp: Nlp.ParseEntities,
|
|
block: Block | BlockFull,
|
|
): NlpPattern[] | undefined {
|
|
// No nlp entities to check against
|
|
if (nlp.entities.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const nlpPatterns = block.patterns.filter((p) => {
|
|
return Array.isArray(p);
|
|
}) as NlpPattern[][];
|
|
|
|
// No nlp patterns found
|
|
if (nlpPatterns.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
// Find NLP pattern match based on best guessed entities
|
|
return nlpPatterns.find((entities: NlpPattern[]) => {
|
|
return entities.every((ev: NlpPattern) => {
|
|
if (ev.match === 'value') {
|
|
return nlp.entities.find((e) => {
|
|
return e.entity === ev.entity && e.value === ev.value;
|
|
});
|
|
} else if (ev.match === 'entity') {
|
|
return nlp.entities.find((e) => {
|
|
return e.entity === ev.entity;
|
|
});
|
|
} else {
|
|
this.logger.warn('Block Service : Unknown NLP match type', ev);
|
|
return false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replaces tokens with their context variables values in the provided text message
|
|
*
|
|
* `You phone number is {context.vars.phone}`
|
|
* Becomes
|
|
* `You phone number is 6354-543-534`
|
|
*
|
|
* @param text - Text message
|
|
* @param context - Variable holding context values relative to the subscriber
|
|
*
|
|
* @returns Text message with the tokens being replaced
|
|
*/
|
|
processTokenReplacements(
|
|
text: string,
|
|
context: Context,
|
|
settings: Settings,
|
|
): string {
|
|
// Replace context tokens with their values
|
|
Object.keys(context.vars || {}).forEach((key) => {
|
|
if (
|
|
typeof context.vars[key] === 'string' &&
|
|
context.vars[key].indexOf(':') !== -1
|
|
) {
|
|
const tmp = context.vars[key].split(':');
|
|
context.vars[key] = tmp[1];
|
|
}
|
|
text = text.replace(
|
|
'{context.vars.' + key + '}',
|
|
typeof context.vars[key] === 'string'
|
|
? context.vars[key]
|
|
: JSON.stringify(context.vars[key]),
|
|
);
|
|
});
|
|
|
|
// Replace context tokens about user location
|
|
if (context.user_location) {
|
|
if (context.user_location.address) {
|
|
const userAddress = context.user_location.address;
|
|
Object.keys(userAddress).forEach((key) => {
|
|
text = text.replace(
|
|
'{context.user_location.address.' + key + '}',
|
|
typeof userAddress[key] === 'string'
|
|
? userAddress[key]
|
|
: JSON.stringify(userAddress[key]),
|
|
);
|
|
});
|
|
}
|
|
text = text.replace(
|
|
'{context.user_location.lat}',
|
|
context.user_location.lat.toString(),
|
|
);
|
|
text = text.replace(
|
|
'{context.user_location.lon}',
|
|
context.user_location.lon.toString(),
|
|
);
|
|
}
|
|
|
|
// Replace tokens for user infos
|
|
Object.keys(context.user).forEach((key) => {
|
|
const userAttr = (context.user as any)[key];
|
|
text = text.replace(
|
|
'{context.user.' + key + '}',
|
|
typeof userAttr === 'string' ? userAttr : JSON.stringify(userAttr),
|
|
);
|
|
});
|
|
|
|
// Replace contact infos tokens with their values
|
|
Object.keys(settings.contact).forEach((key) => {
|
|
text = text.replace('{contact.' + key + '}', settings.contact[key]);
|
|
});
|
|
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Translates and replaces tokens with context variables values
|
|
*
|
|
* @param text - Text to process
|
|
* @param context - The context object
|
|
*
|
|
* @returns The text message translated and tokens being replaces with values
|
|
*/
|
|
processText(text: string, context: Context, settings: Settings): string {
|
|
const lang =
|
|
context && context.user && context.user.language
|
|
? context.user.language
|
|
: settings.nlp_settings.default_lang;
|
|
// Translate
|
|
text = this.i18n.t(text, { lang, defaultValue: text });
|
|
// Replace context tokens
|
|
text = this.processTokenReplacements(text, context, settings);
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Return a randomly picked item of the array
|
|
*
|
|
* @param array - Array of any type
|
|
*
|
|
* @returns A random item from the array
|
|
*/
|
|
getRandom<T>(array: T[]): T {
|
|
return Array.isArray(array)
|
|
? array[Math.floor(Math.random() * array.length)]
|
|
: array;
|
|
}
|
|
|
|
/**
|
|
* Logs a warning message
|
|
*/
|
|
checkDeprecatedAttachmentUrl(block: Block | BlockFull) {
|
|
if (
|
|
block.message &&
|
|
'attachment' in block.message &&
|
|
'url' in block.message.attachment.payload
|
|
) {
|
|
this.logger.error(
|
|
'Attachment Model : `url` payload has been deprecated in favor of `attachment_id`',
|
|
block.id,
|
|
block.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes a block message based on the format.
|
|
*
|
|
* @param block - The block holding the message to process
|
|
* @param context - Context object
|
|
* @param fallback - Whenever to process main message or local fallback message
|
|
* @param conversationId - The conversation ID
|
|
*
|
|
* @returns - Envelope containing message format and content following {format, message} object structure
|
|
*/
|
|
async processMessage(
|
|
block: Block | BlockFull,
|
|
context: Context,
|
|
fallback = false,
|
|
conversationId?: string,
|
|
): Promise<StdOutgoingEnvelope> {
|
|
const settings = await this.settingService.getSettings();
|
|
const blockMessage: BlockMessage =
|
|
fallback && block.options.fallback
|
|
? [...block.options.fallback.message]
|
|
: Array.isArray(block.message)
|
|
? [...block.message]
|
|
: { ...block.message };
|
|
|
|
if (Array.isArray(blockMessage)) {
|
|
// Text Message
|
|
// Get random message from array
|
|
const text = this.processText(
|
|
this.getRandom(blockMessage),
|
|
context,
|
|
settings,
|
|
);
|
|
const envelope: StdOutgoingEnvelope = {
|
|
format: OutgoingMessageFormat.text,
|
|
message: { text },
|
|
};
|
|
return envelope;
|
|
} else if (blockMessage && 'text' in blockMessage) {
|
|
if (
|
|
'quickReplies' in blockMessage &&
|
|
Array.isArray(blockMessage.quickReplies) &&
|
|
blockMessage.quickReplies.length > 0
|
|
) {
|
|
const envelope: StdOutgoingEnvelope = {
|
|
format: OutgoingMessageFormat.quickReplies,
|
|
message: {
|
|
text: this.processText(blockMessage.text, context, settings),
|
|
quickReplies: blockMessage.quickReplies.map((qr: StdQuickReply) => {
|
|
return qr.title
|
|
? {
|
|
...qr,
|
|
title: this.processText(qr.title, context, settings),
|
|
}
|
|
: qr;
|
|
}),
|
|
},
|
|
};
|
|
return envelope;
|
|
} else if (
|
|
'buttons' in blockMessage &&
|
|
Array.isArray(blockMessage.buttons) &&
|
|
blockMessage.buttons.length > 0
|
|
) {
|
|
const envelope: StdOutgoingEnvelope = {
|
|
format: OutgoingMessageFormat.buttons,
|
|
message: {
|
|
text: this.processText(blockMessage.text, context, settings),
|
|
buttons: blockMessage.buttons.map((btn) => {
|
|
return btn.title
|
|
? {
|
|
...btn,
|
|
title: this.processText(btn.title, context, settings),
|
|
}
|
|
: btn;
|
|
}),
|
|
},
|
|
};
|
|
return envelope;
|
|
}
|
|
} else if (blockMessage && 'attachment' in blockMessage) {
|
|
const attachmentPayload = blockMessage.attachment.payload;
|
|
if (!attachmentPayload.attachment_id) {
|
|
this.checkDeprecatedAttachmentUrl(block);
|
|
throw new Error('Remote attachments are no longer supported!');
|
|
}
|
|
|
|
const attachment = await this.attachmentService.findOne(
|
|
attachmentPayload.attachment_id,
|
|
);
|
|
|
|
if (!attachment) {
|
|
this.logger.debug(
|
|
'Unable to locate the attachment for the given block',
|
|
block,
|
|
);
|
|
throw new Error('Unable to find attachment.');
|
|
}
|
|
|
|
const envelope: StdOutgoingEnvelope = {
|
|
format: OutgoingMessageFormat.attachment,
|
|
message: {
|
|
attachment: {
|
|
type: blockMessage.attachment.type,
|
|
payload: attachment as WithUrl<Attachment>,
|
|
},
|
|
quickReplies: blockMessage.quickReplies
|
|
? [...blockMessage.quickReplies]
|
|
: undefined,
|
|
},
|
|
};
|
|
return envelope;
|
|
} else if (
|
|
blockMessage &&
|
|
'elements' in blockMessage &&
|
|
block.options.content
|
|
) {
|
|
const contentBlockOptions = block.options.content;
|
|
// Hadnle pagination for list/carousel
|
|
let skip = 0;
|
|
if (
|
|
contentBlockOptions.display === OutgoingMessageFormat.list ||
|
|
contentBlockOptions.display === OutgoingMessageFormat.carousel
|
|
) {
|
|
skip =
|
|
context.skip && context.skip[block.id] ? context.skip[block.id] : 0;
|
|
}
|
|
// Populate list with content
|
|
try {
|
|
const results = await this.contentService.getContent(
|
|
contentBlockOptions,
|
|
skip,
|
|
);
|
|
|
|
const envelope: StdOutgoingEnvelope = {
|
|
format: contentBlockOptions.display,
|
|
message: {
|
|
...results,
|
|
options: contentBlockOptions,
|
|
},
|
|
};
|
|
|
|
return envelope;
|
|
} catch (err) {
|
|
this.logger.error(
|
|
'Unable to retrieve content for list template process',
|
|
err,
|
|
);
|
|
throw err;
|
|
}
|
|
} else if (blockMessage && 'plugin' in blockMessage) {
|
|
const plugin = this.pluginService.findPlugin(
|
|
PluginType.block,
|
|
blockMessage.plugin,
|
|
);
|
|
// Process custom plugin block
|
|
try {
|
|
return await plugin.process(block, context, conversationId);
|
|
} catch (e) {
|
|
this.logger.error('Plugin was unable to load/process ', e);
|
|
throw new Error(`Unknown plugin - ${JSON.stringify(blockMessage)}`);
|
|
}
|
|
} else {
|
|
throw new Error('Invalid message format.');
|
|
}
|
|
}
|
|
}
|