mirror of
https://github.com/hexastack/hexabot
synced 2025-04-01 08:15:07 +00:00
Merge pull request #807 from Hexastack/feat/block-outcome
Feat/block outcome
This commit is contained in:
commit
69a935314d
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2024 Hexastack. All rights reserved.
|
* Copyright © 2025 Hexastack. All rights reserved.
|
||||||
*
|
*
|
||||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -251,6 +251,7 @@ describe('BlockController', () => {
|
|||||||
name: 'block with nextBlocks',
|
name: 'block with nextBlocks',
|
||||||
nextBlocks: [hasNextBlocks.id],
|
nextBlocks: [hasNextBlocks.id],
|
||||||
patterns: ['Hi'],
|
patterns: ['Hi'],
|
||||||
|
outcomes: [],
|
||||||
trigger_labels: [],
|
trigger_labels: [],
|
||||||
assign_labels: [],
|
assign_labels: [],
|
||||||
trigger_channels: [],
|
trigger_channels: [],
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2024 Hexastack. All rights reserved.
|
* Copyright © 2025 Hexastack. All rights reserved.
|
||||||
*
|
*
|
||||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -45,6 +45,14 @@ export class BlockCreateDto {
|
|||||||
@IsPatternList({ message: 'Patterns list is invalid' })
|
@IsPatternList({ message: 'Patterns list is invalid' })
|
||||||
patterns?: Pattern[] = [];
|
patterns?: Pattern[] = [];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Block's outcomes",
|
||||||
|
type: Array,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'Outcomes are invalid' })
|
||||||
|
outcomes?: string[] = [];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
|
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ -120,6 +128,7 @@ export class BlockCreateDto {
|
|||||||
export class BlockUpdateDto extends PartialType(
|
export class BlockUpdateDto extends PartialType(
|
||||||
OmitType(BlockCreateDto, [
|
OmitType(BlockCreateDto, [
|
||||||
'patterns',
|
'patterns',
|
||||||
|
'outcomes',
|
||||||
'trigger_labels',
|
'trigger_labels',
|
||||||
'assign_labels',
|
'assign_labels',
|
||||||
'trigger_channels',
|
'trigger_channels',
|
||||||
@ -130,6 +139,14 @@ export class BlockUpdateDto extends PartialType(
|
|||||||
@IsPatternList({ message: 'Patterns list is invalid' })
|
@IsPatternList({ message: 'Patterns list is invalid' })
|
||||||
patterns?: Pattern[];
|
patterns?: Pattern[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Block's outcomes",
|
||||||
|
type: Array,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'Outcomes are invalid' })
|
||||||
|
outcomes?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
|
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
@ -42,6 +42,12 @@ export class BlockStub extends BaseSchema {
|
|||||||
})
|
})
|
||||||
patterns: Pattern[];
|
patterns: Pattern[];
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: Object,
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
outcomes: string[];
|
||||||
|
|
||||||
@Prop([
|
@Prop([
|
||||||
{
|
{
|
||||||
type: MongooseSchema.Types.ObjectId,
|
type: MongooseSchema.Types.ObjectId,
|
||||||
|
@ -40,4 +40,5 @@ export enum PayloadType {
|
|||||||
attachments = 'attachments',
|
attachments = 'attachments',
|
||||||
quick_reply = 'quick_reply',
|
quick_reply = 'quick_reply',
|
||||||
button = 'button',
|
button = 'button',
|
||||||
|
outcome = 'outcome',
|
||||||
}
|
}
|
||||||
|
@ -6,14 +6,6 @@
|
|||||||
* 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).
|
* 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).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright © 2025 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 { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { PluginName } from '@/plugins/types';
|
import { PluginName } from '@/plugins/types';
|
||||||
@ -62,6 +54,7 @@ export enum OutgoingMessageFormat {
|
|||||||
attachment = 'attachment',
|
attachment = 'attachment',
|
||||||
list = 'list',
|
list = 'list',
|
||||||
carousel = 'carousel',
|
carousel = 'carousel',
|
||||||
|
system = 'system',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const outgoingMessageFormatSchema = z.nativeEnum(OutgoingMessageFormat);
|
export const outgoingMessageFormatSchema = z.nativeEnum(OutgoingMessageFormat);
|
||||||
@ -147,6 +140,15 @@ export type StdOutgoingAttachmentMessage = z.infer<
|
|||||||
typeof stdOutgoingAttachmentMessageSchema
|
typeof stdOutgoingAttachmentMessageSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export const stdOutgoingSystemMessageSchema = z.object({
|
||||||
|
outcome: z.string().optional(), // "any" or any other string (in snake case)
|
||||||
|
data: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StdOutgoingSystemMessage = z.infer<
|
||||||
|
typeof stdOutgoingSystemMessageSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const pluginNameSchema = z
|
export const pluginNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(/-plugin$/) as z.ZodType<PluginName>;
|
.regex(/-plugin$/) as z.ZodType<PluginName>;
|
||||||
@ -290,7 +292,16 @@ export type StdOutgoingAttachmentEnvelope = z.infer<
|
|||||||
typeof stdOutgoingAttachmentEnvelopeSchema
|
typeof stdOutgoingAttachmentEnvelopeSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const stdOutgoingEnvelopeSchema = z.union([
|
export const stdOutgoingSystemEnvelopeSchema = z.object({
|
||||||
|
format: z.literal(OutgoingMessageFormat.system),
|
||||||
|
message: stdOutgoingSystemMessageSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StdOutgoingSystemEnvelope = z.infer<
|
||||||
|
typeof stdOutgoingSystemEnvelopeSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const stdOutgoingMessageEnvelopeSchema = z.union([
|
||||||
stdOutgoingTextEnvelopeSchema,
|
stdOutgoingTextEnvelopeSchema,
|
||||||
stdOutgoingQuickRepliesEnvelopeSchema,
|
stdOutgoingQuickRepliesEnvelopeSchema,
|
||||||
stdOutgoingButtonsEnvelopeSchema,
|
stdOutgoingButtonsEnvelopeSchema,
|
||||||
@ -298,6 +309,15 @@ export const stdOutgoingEnvelopeSchema = z.union([
|
|||||||
stdOutgoingAttachmentEnvelopeSchema,
|
stdOutgoingAttachmentEnvelopeSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export type StdOutgoingMessageEnvelope = z.infer<
|
||||||
|
typeof stdOutgoingMessageEnvelopeSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const stdOutgoingEnvelopeSchema = z.union([
|
||||||
|
stdOutgoingMessageEnvelopeSchema,
|
||||||
|
stdOutgoingSystemEnvelopeSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export type StdOutgoingEnvelope = z.infer<typeof stdOutgoingEnvelopeSchema>;
|
export type StdOutgoingEnvelope = z.infer<typeof stdOutgoingEnvelopeSchema>;
|
||||||
|
|
||||||
// is-valid-message-text validation
|
// is-valid-message-text validation
|
||||||
|
@ -11,6 +11,7 @@ import { OnEvent } from '@nestjs/event-emitter';
|
|||||||
|
|
||||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||||
|
import { ChannelName } from '@/channel/types';
|
||||||
import { ContentService } from '@/cms/services/content.service';
|
import { ContentService } from '@/cms/services/content.service';
|
||||||
import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings';
|
import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings';
|
||||||
import { NLU } from '@/helper/types';
|
import { NLU } from '@/helper/types';
|
||||||
@ -27,11 +28,13 @@ import { BlockDto } from '../dto/block.dto';
|
|||||||
import { BlockRepository } from '../repositories/block.repository';
|
import { BlockRepository } from '../repositories/block.repository';
|
||||||
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
|
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
|
||||||
import { Label } from '../schemas/label.schema';
|
import { Label } from '../schemas/label.schema';
|
||||||
|
import { Subscriber } from '../schemas/subscriber.schema';
|
||||||
import { Context } from '../schemas/types/context';
|
import { Context } from '../schemas/types/context';
|
||||||
import {
|
import {
|
||||||
BlockMessage,
|
BlockMessage,
|
||||||
OutgoingMessageFormat,
|
OutgoingMessageFormat,
|
||||||
StdOutgoingEnvelope,
|
StdOutgoingEnvelope,
|
||||||
|
StdOutgoingSystemEnvelope,
|
||||||
} from '../schemas/types/message';
|
} from '../schemas/types/message';
|
||||||
import { NlpPattern, PayloadPattern } from '../schemas/types/pattern';
|
import { NlpPattern, PayloadPattern } from '../schemas/types/pattern';
|
||||||
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
|
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
|
||||||
@ -57,10 +60,75 @@ export class BlockService extends BaseService<
|
|||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters an array of blocks based on the specified channel.
|
||||||
|
*
|
||||||
|
* This function ensures that only blocks that are either:
|
||||||
|
* - Not restricted to specific trigger channels (`trigger_channels` is undefined or empty), or
|
||||||
|
* - Explicitly allow the given channel (or the console channel)
|
||||||
|
*
|
||||||
|
* are included in the returned array.
|
||||||
|
*
|
||||||
|
* @param blocks - The list of blocks to be filtered.
|
||||||
|
* @param channel - The name of the channel to filter blocks by.
|
||||||
|
*
|
||||||
|
* @returns The filtered array of blocks that are allowed for the given channel.
|
||||||
|
*/
|
||||||
|
filterBlocksByChannel<B extends Block | BlockFull>(
|
||||||
|
blocks: B[],
|
||||||
|
channel: ChannelName,
|
||||||
|
) {
|
||||||
|
return blocks.filter((b) => {
|
||||||
|
return (
|
||||||
|
!b.trigger_channels ||
|
||||||
|
b.trigger_channels.length === 0 ||
|
||||||
|
[...b.trigger_channels, CONSOLE_CHANNEL_NAME].includes(channel)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters an array of blocks based on subscriber labels.
|
||||||
|
*
|
||||||
|
* This function selects blocks that either:
|
||||||
|
* - Have no trigger labels (making them applicable to all subscribers), or
|
||||||
|
* - Contain at least one trigger label that matches a label from the provided list.
|
||||||
|
*
|
||||||
|
* The filtered blocks are then **sorted** in descending order by the number of trigger labels,
|
||||||
|
* ensuring that blocks with more specific targeting (more trigger labels) are prioritized.
|
||||||
|
*
|
||||||
|
* @param blocks - The list of blocks to be filtered.
|
||||||
|
* @param labels - The list of subscriber labels to match against.
|
||||||
|
* @returns The filtered and sorted list of blocks.
|
||||||
|
*/
|
||||||
|
filterBlocksBySubscriberLabels<B extends Block | BlockFull>(
|
||||||
|
blocks: B[],
|
||||||
|
profile?: Subscriber,
|
||||||
|
) {
|
||||||
|
if (!profile) {
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
blocks
|
||||||
|
.filter((b) => {
|
||||||
|
const triggerLabels = b.trigger_labels.map((l) =>
|
||||||
|
typeof l === 'string' ? l : l.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
triggerLabels.length === 0 ||
|
||||||
|
triggerLabels.some((l) => profile.labels.includes(l))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
// Priority goes to block who target users with labels
|
||||||
|
.sort((a, b) => b.trigger_labels.length - a.trigger_labels.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a block whose patterns matches the received event
|
* Find a block whose patterns matches the received event
|
||||||
*
|
*
|
||||||
* @param blocks blocks Starting/Next blocks in the conversation flow
|
* @param filteredBlocks blocks Starting/Next blocks in the conversation flow
|
||||||
* @param event Received channel's message
|
* @param event Received channel's message
|
||||||
*
|
*
|
||||||
* @returns The block that matches
|
* @returns The block that matches
|
||||||
@ -77,37 +145,15 @@ export class BlockService extends BaseService<
|
|||||||
let block: BlockFull | undefined = undefined;
|
let block: BlockFull | undefined = undefined;
|
||||||
const payload = event.getPayload();
|
const payload = event.getPayload();
|
||||||
|
|
||||||
// Perform a filter on the specific channels
|
// Perform a filter to get the candidates blocks
|
||||||
const channel = event.getHandler().getName();
|
const filteredBlocks = this.filterBlocksBySubscriberLabels(
|
||||||
blocks = blocks.filter((b) => {
|
this.filterBlocksByChannel(blocks, event.getHandler().getName()),
|
||||||
return (
|
event.getSender(),
|
||||||
!b.trigger_channels ||
|
);
|
||||||
b.trigger_channels.length === 0 ||
|
|
||||||
[...b.trigger_channels, CONSOLE_CHANNEL_NAME].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
|
// Perform a payload match & pick last createdAt
|
||||||
if (payload) {
|
if (payload) {
|
||||||
block = blocks
|
block = filteredBlocks
|
||||||
.filter((b) => {
|
.filter((b) => {
|
||||||
return this.matchPayload(payload, b);
|
return this.matchPayload(payload, b);
|
||||||
})
|
})
|
||||||
@ -131,7 +177,7 @@ export class BlockService extends BaseService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform a text pattern match
|
// Perform a text pattern match
|
||||||
block = blocks
|
block = filteredBlocks
|
||||||
.filter((b) => {
|
.filter((b) => {
|
||||||
return this.matchText(text, b);
|
return this.matchText(text, b);
|
||||||
})
|
})
|
||||||
@ -141,7 +187,7 @@ export class BlockService extends BaseService<
|
|||||||
if (!block && nlp) {
|
if (!block && nlp) {
|
||||||
// Find block pattern having the best match of nlp entities
|
// Find block pattern having the best match of nlp entities
|
||||||
let nlpBest = 0;
|
let nlpBest = 0;
|
||||||
blocks.forEach((b, index, self) => {
|
filteredBlocks.forEach((b, index, self) => {
|
||||||
const nlpPattern = this.matchNLP(nlp, b);
|
const nlpPattern = this.matchNLP(nlp, b);
|
||||||
if (nlpPattern && nlpPattern.length > nlpBest) {
|
if (nlpPattern && nlpPattern.length > nlpBest) {
|
||||||
nlpBest = nlpPattern.length;
|
nlpBest = nlpPattern.length;
|
||||||
@ -295,6 +341,36 @@ export class BlockService extends BaseService<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches an outcome-based block from a list of available blocks
|
||||||
|
* based on the outcome of a system message.
|
||||||
|
*
|
||||||
|
* @param blocks - An array of blocks to search for a matching outcome.
|
||||||
|
* @param envelope - The system message envelope containing the outcome to match.
|
||||||
|
*
|
||||||
|
* @returns - Returns the first matching block if found, otherwise returns `undefined`.
|
||||||
|
*/
|
||||||
|
matchOutcome(
|
||||||
|
blocks: Block[],
|
||||||
|
event: EventWrapper<any, any>,
|
||||||
|
envelope: StdOutgoingSystemEnvelope,
|
||||||
|
) {
|
||||||
|
// Perform a filter to get the candidates blocks
|
||||||
|
const filteredBlocks = this.filterBlocksBySubscriberLabels(
|
||||||
|
this.filterBlocksByChannel(blocks, event.getHandler().getName()),
|
||||||
|
event.getSender(),
|
||||||
|
);
|
||||||
|
return filteredBlocks.find((b) => {
|
||||||
|
return b.patterns
|
||||||
|
.filter(
|
||||||
|
(p) => typeof p === 'object' && 'type' in p && p.type === 'outcome',
|
||||||
|
)
|
||||||
|
.some((p: PayloadPattern) =>
|
||||||
|
['any', envelope.message.outcome].includes(p.value),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces tokens with their context variables values in the provided text message
|
* Replaces tokens with their context variables values in the provided text message
|
||||||
*
|
*
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2024 Hexastack. All rights reserved.
|
* Copyright © 2025 Hexastack. All rights reserved.
|
||||||
*
|
*
|
||||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -201,7 +201,7 @@ describe('BlockService', () => {
|
|||||||
|
|
||||||
let hasBotSpoken = false;
|
let hasBotSpoken = false;
|
||||||
const clearMock = jest
|
const clearMock = jest
|
||||||
.spyOn(botService, 'findBlockAndSendReply')
|
.spyOn(botService, 'triggerBlock')
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
(
|
(
|
||||||
actualEvent: WebEventWrapper<typeof WEB_CHANNEL_NAME>,
|
actualEvent: WebEventWrapper<typeof WEB_CHANNEL_NAME>,
|
||||||
|
@ -21,8 +21,11 @@ import {
|
|||||||
getDefaultConversationContext,
|
getDefaultConversationContext,
|
||||||
} from '../schemas/conversation.schema';
|
} from '../schemas/conversation.schema';
|
||||||
import { Context } from '../schemas/types/context';
|
import { Context } from '../schemas/types/context';
|
||||||
import { IncomingMessageType } from '../schemas/types/message';
|
import {
|
||||||
import { SubscriberContext } from '../schemas/types/subscriberContext';
|
IncomingMessageType,
|
||||||
|
OutgoingMessageFormat,
|
||||||
|
StdOutgoingMessageEnvelope,
|
||||||
|
} from '../schemas/types/message';
|
||||||
|
|
||||||
import { BlockService } from './block.service';
|
import { BlockService } from './block.service';
|
||||||
import { ConversationService } from './conversation.service';
|
import { ConversationService } from './conversation.service';
|
||||||
@ -40,40 +43,24 @@ export class BotService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a processed message to the user based on a specified content block.
|
* Sends a message to the subscriber via the appropriate messaging channel and handles related events.
|
||||||
* Replaces tokens within the block with context data, handles fallback scenarios,
|
|
||||||
* and assigns relevant labels to the user.
|
|
||||||
*
|
*
|
||||||
* @param event - The incoming message or action that triggered the bot's response.
|
* @param envelope - The outgoing message envelope containing the bot's response.
|
||||||
* @param block - The content block containing the message and options to be sent.
|
* @param block - The content block containing the message and options to be sent.
|
||||||
* @param context - Optional. The conversation context object, containing relevant data for personalization.
|
* @param context - Optional. The conversation context object, containing relevant data for personalization.
|
||||||
* @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found.
|
* @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found.
|
||||||
* @param conversationId - Optional. The conversation ID to link the message to a specific conversation thread.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves with the message response, including the message ID.
|
|
||||||
*/
|
*/
|
||||||
async sendMessageToSubscriber(
|
async sendMessageToSubscriber(
|
||||||
|
envelope: StdOutgoingMessageEnvelope,
|
||||||
event: EventWrapper<any, any>,
|
event: EventWrapper<any, any>,
|
||||||
block: BlockFull,
|
block: BlockFull,
|
||||||
context?: Context,
|
context?: Context,
|
||||||
fallback?: boolean,
|
fallback?: boolean,
|
||||||
conservationId?: string,
|
|
||||||
) {
|
) {
|
||||||
context = context || getDefaultConversationContext();
|
|
||||||
fallback = typeof fallback !== 'undefined' ? fallback : false;
|
|
||||||
const options = block.options;
|
const options = block.options;
|
||||||
this.logger.debug('Sending message ... ', event.getSenderForeignId());
|
|
||||||
// Process message : Replace tokens with context data and then send the message
|
|
||||||
const recipient = event.getSender();
|
const recipient = event.getSender();
|
||||||
const envelope = await this.blockService.processMessage(
|
|
||||||
block,
|
|
||||||
context,
|
|
||||||
recipient?.context as SubscriberContext,
|
|
||||||
fallback,
|
|
||||||
conservationId,
|
|
||||||
);
|
|
||||||
// Send message through the right channel
|
// Send message through the right channel
|
||||||
|
this.logger.debug('Sending message ... ', event.getSenderForeignId());
|
||||||
const response = await event
|
const response = await event
|
||||||
.getHandler()
|
.getHandler()
|
||||||
.sendMessage(event, envelope, options, context);
|
.sendMessage(event, envelope, options, context);
|
||||||
@ -114,35 +101,56 @@ export class BotService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug('Assigned labels ', blockLabels);
|
this.logger.debug('Assigned labels ', blockLabels);
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds an appropriate reply block and sends it to the user.
|
* Processes and executes a block, handling its associated messages and flow logic.
|
||||||
* If there are additional blocks or attached blocks, it continues the conversation flow.
|
*
|
||||||
* Ends the conversation if no further blocks are available.
|
* The function performs the following steps:
|
||||||
|
* 1. Retrieves the conversation context and recipient information.
|
||||||
|
* 2. Generates an outgoing message envelope from the block.
|
||||||
|
* 3. Sends the message to the subscriber unless it's a system message.
|
||||||
|
* 4. Handles block chaining:
|
||||||
|
* - If the block has an attached block, it recursively triggers the attached block.
|
||||||
|
* - If the block has multiple possible next blocks, it determines the next block based on the outcome of the system message.
|
||||||
|
* - If there are next blocks but no outcome-based matching, it updates the conversation state for the next steps.
|
||||||
|
* 5. If no further blocks exist, it ends the flow execution.
|
||||||
*
|
*
|
||||||
* @param event - The incoming message or action that initiated this response.
|
* @param event - The incoming message or action that initiated this response.
|
||||||
* @param convo - The current conversation context and flow.
|
* @param convo - The current conversation context and flow.
|
||||||
* @param block - The content block to be processed and sent.
|
* @param block - The content block to be processed and sent.
|
||||||
* @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found.
|
* @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found.
|
||||||
*
|
*
|
||||||
* @returns A promise that continues or ends the conversation based on available blocks.
|
* @returns A promise that either continues or ends the flow execution based on the available blocks.
|
||||||
*/
|
*/
|
||||||
async findBlockAndSendReply(
|
async triggerBlock(
|
||||||
event: EventWrapper<any, any>,
|
event: EventWrapper<any, any>,
|
||||||
convo: Conversation,
|
convo: Conversation,
|
||||||
block: BlockFull,
|
block: BlockFull,
|
||||||
fallback: boolean,
|
fallback: boolean = false,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await this.sendMessageToSubscriber(
|
const context = convo.context || getDefaultConversationContext();
|
||||||
event,
|
const recipient = event.getSender();
|
||||||
|
|
||||||
|
const envelope = await this.blockService.processMessage(
|
||||||
block,
|
block,
|
||||||
convo.context,
|
context,
|
||||||
|
recipient?.context,
|
||||||
fallback,
|
fallback,
|
||||||
convo.id,
|
convo.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (envelope.format !== OutgoingMessageFormat.system) {
|
||||||
|
await this.sendMessageToSubscriber(
|
||||||
|
envelope,
|
||||||
|
event,
|
||||||
|
block,
|
||||||
|
context,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (block.attachedBlock) {
|
if (block.attachedBlock) {
|
||||||
// Sequential messaging ?
|
// Sequential messaging ?
|
||||||
try {
|
try {
|
||||||
@ -154,12 +162,7 @@ export class BotService {
|
|||||||
'No attached block to be found with id ' + block.attachedBlock,
|
'No attached block to be found with id ' + block.attachedBlock,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await this.findBlockAndSendReply(
|
return await this.triggerBlock(event, convo, attachedBlock, fallback);
|
||||||
event,
|
|
||||||
convo,
|
|
||||||
attachedBlock,
|
|
||||||
fallback,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Unable to retrieve attached block', err);
|
this.logger.error('Unable to retrieve attached block', err);
|
||||||
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||||
@ -168,20 +171,47 @@ export class BotService {
|
|||||||
Array.isArray(block.nextBlocks) &&
|
Array.isArray(block.nextBlocks) &&
|
||||||
block.nextBlocks.length > 0
|
block.nextBlocks.length > 0
|
||||||
) {
|
) {
|
||||||
// Conversation continues : Go forward to next blocks
|
|
||||||
this.logger.debug('Conversation continues ...', convo.id);
|
|
||||||
const nextIds = block.nextBlocks.map(({ id }) => id);
|
|
||||||
try {
|
try {
|
||||||
await this.conversationService.updateOne(convo.id, {
|
if (envelope.format === OutgoingMessageFormat.system) {
|
||||||
current: block.id,
|
// System message: Trigger the next block based on the outcome
|
||||||
next: nextIds,
|
this.logger.debug(
|
||||||
});
|
'Matching the outcome against the next blocks ...',
|
||||||
|
convo.id,
|
||||||
|
);
|
||||||
|
const match = this.blockService.matchOutcome(
|
||||||
|
block.nextBlocks,
|
||||||
|
event,
|
||||||
|
envelope,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const nextBlock = await this.blockService.findOneAndPopulate(
|
||||||
|
match.id,
|
||||||
|
);
|
||||||
|
if (!nextBlock) {
|
||||||
|
throw new Error(
|
||||||
|
'No attached block to be found with id ' +
|
||||||
|
block.attachedBlock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await this.triggerBlock(event, convo, nextBlock, fallback);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
'Block outcome did not match any of the next blocks',
|
||||||
|
convo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Conversation continues : Go forward to next blocks
|
||||||
|
this.logger.debug('Conversation continues ...', convo.id);
|
||||||
|
const nextIds = block.nextBlocks.map(({ id }) => id);
|
||||||
|
await this.conversationService.updateOne(convo.id, {
|
||||||
|
current: block.id,
|
||||||
|
next: nextIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error('Unable to continue the flow', convo, err);
|
||||||
'Unable to update conversation when going next',
|
|
||||||
convo,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -275,12 +305,7 @@ export class BotService {
|
|||||||
// Otherwise, old captured const value may be replaced by another const value
|
// Otherwise, old captured const value may be replaced by another const value
|
||||||
!fallback,
|
!fallback,
|
||||||
);
|
);
|
||||||
await this.findBlockAndSendReply(
|
await this.triggerBlock(event, updatedConversation, next, fallback);
|
||||||
event,
|
|
||||||
updatedConversation,
|
|
||||||
next,
|
|
||||||
fallback,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Unable to store context data!', err);
|
this.logger.error('Unable to store context data!', err);
|
||||||
return this.eventEmitter.emit('hook:conversation:end', convo, true);
|
return this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||||
@ -376,12 +401,7 @@ export class BotService {
|
|||||||
subscriber.id,
|
subscriber.id,
|
||||||
block.name,
|
block.name,
|
||||||
);
|
);
|
||||||
return this.findBlockAndSendReply(
|
return this.triggerBlock(event, updatedConversation, block, false);
|
||||||
event,
|
|
||||||
updatedConversation,
|
|
||||||
block,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Unable to store context data!', err);
|
this.logger.error('Unable to store context data!', err);
|
||||||
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||||
@ -459,7 +479,7 @@ export class BotService {
|
|||||||
'No global fallback block defined, sending a message ...',
|
'No global fallback block defined, sending a message ...',
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
this.sendMessageToSubscriber(event, {
|
const globalFallbackBlock = {
|
||||||
id: 'global-fallback',
|
id: 'global-fallback',
|
||||||
name: 'Global Fallback',
|
name: 'Global Fallback',
|
||||||
message: settings.chatbot_settings.fallback_message,
|
message: settings.chatbot_settings.fallback_message,
|
||||||
@ -473,7 +493,19 @@ export class BotService {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
attachedBlock: null,
|
attachedBlock: null,
|
||||||
} as any as BlockFull);
|
} as any as BlockFull;
|
||||||
|
|
||||||
|
const envelope = await this.blockService.processMessage(
|
||||||
|
globalFallbackBlock,
|
||||||
|
getDefaultConversationContext(),
|
||||||
|
{ vars: {} }, // @TODO: use subscriber ctx
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.sendMessageToSubscriber(
|
||||||
|
envelope as StdOutgoingMessageEnvelope,
|
||||||
|
event,
|
||||||
|
globalFallbackBlock,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Do nothing ...
|
// Do nothing ...
|
||||||
|
@ -136,6 +136,7 @@ describe('TranslationService', () => {
|
|||||||
const block: Block = {
|
const block: Block = {
|
||||||
name: 'Ollama Plugin',
|
name: 'Ollama Plugin',
|
||||||
patterns: [],
|
patterns: [],
|
||||||
|
outcomes: [],
|
||||||
assign_labels: [],
|
assign_labels: [],
|
||||||
trigger_channels: [],
|
trigger_channels: [],
|
||||||
trigger_labels: [],
|
trigger_labels: [],
|
||||||
|
5
api/src/utils/test/fixtures/block.ts
vendored
5
api/src/utils/test/fixtures/block.ts
vendored
@ -35,6 +35,7 @@ export const blocks: TBlockFixtures['values'][] = [
|
|||||||
{
|
{
|
||||||
name: 'hasNextBlocks',
|
name: 'hasNextBlocks',
|
||||||
patterns: ['Hi'],
|
patterns: ['Hi'],
|
||||||
|
outcomes: [],
|
||||||
category: null,
|
category: null,
|
||||||
options: {
|
options: {
|
||||||
typing: 0,
|
typing: 0,
|
||||||
@ -53,6 +54,7 @@ export const blocks: TBlockFixtures['values'][] = [
|
|||||||
{
|
{
|
||||||
name: 'hasPreviousBlocks',
|
name: 'hasPreviousBlocks',
|
||||||
patterns: ['colors'],
|
patterns: ['colors'],
|
||||||
|
outcomes: [],
|
||||||
category: null,
|
category: null,
|
||||||
options: {
|
options: {
|
||||||
typing: 0,
|
typing: 0,
|
||||||
@ -90,6 +92,7 @@ export const blocks: TBlockFixtures['values'][] = [
|
|||||||
{
|
{
|
||||||
name: 'buttons',
|
name: 'buttons',
|
||||||
patterns: ['about'],
|
patterns: ['about'],
|
||||||
|
outcomes: [],
|
||||||
category: null,
|
category: null,
|
||||||
options: {
|
options: {
|
||||||
typing: 0,
|
typing: 0,
|
||||||
@ -127,6 +130,7 @@ export const blocks: TBlockFixtures['values'][] = [
|
|||||||
{
|
{
|
||||||
name: 'attachment',
|
name: 'attachment',
|
||||||
patterns: ['image'],
|
patterns: ['image'],
|
||||||
|
outcomes: [],
|
||||||
category: null,
|
category: null,
|
||||||
options: {
|
options: {
|
||||||
typing: 0,
|
typing: 0,
|
||||||
@ -153,6 +157,7 @@ export const blocks: TBlockFixtures['values'][] = [
|
|||||||
{
|
{
|
||||||
name: 'test',
|
name: 'test',
|
||||||
patterns: ['yes'],
|
patterns: ['yes'],
|
||||||
|
outcomes: [],
|
||||||
category: null,
|
category: null,
|
||||||
//to be verified
|
//to be verified
|
||||||
options: {
|
options: {
|
||||||
|
@ -247,6 +247,13 @@
|
|||||||
"triggers": "Triggers",
|
"triggers": "Triggers",
|
||||||
"payloads": "Payloads",
|
"payloads": "Payloads",
|
||||||
"general_payloads": "General Payloads",
|
"general_payloads": "General Payloads",
|
||||||
|
"exact_match": "Exact Match",
|
||||||
|
"pattern_match": "Pattern Match",
|
||||||
|
"intent_match": "Intent Match",
|
||||||
|
"interaction": "Interaction",
|
||||||
|
"outcome_match": "Outcome Match",
|
||||||
|
"outcome": "Outcome",
|
||||||
|
"any_outcome": "Any Outcome",
|
||||||
"capture": "Capture?",
|
"capture": "Capture?",
|
||||||
"context_var": "Context Var",
|
"context_var": "Context Var",
|
||||||
"text_message": "Text message",
|
"text_message": "Text message",
|
||||||
|
@ -247,6 +247,13 @@
|
|||||||
"triggers": "Déclencheurs",
|
"triggers": "Déclencheurs",
|
||||||
"payloads": "Payloads",
|
"payloads": "Payloads",
|
||||||
"general_payloads": "Payloads généraux",
|
"general_payloads": "Payloads généraux",
|
||||||
|
"exact_match": "Comparaison Exacte",
|
||||||
|
"pattern_match": "Expression Régulière",
|
||||||
|
"intent_match": "Intention",
|
||||||
|
"interaction": "Interaction",
|
||||||
|
"outcome": "Résultat",
|
||||||
|
"outcome_match": "Résultat",
|
||||||
|
"any_outcome": "N'importe quel résultat",
|
||||||
"capture": "Capturer?",
|
"capture": "Capturer?",
|
||||||
"context_var": "Variable contextuelle",
|
"context_var": "Variable contextuelle",
|
||||||
"text_message": "Message texte",
|
"text_message": "Message texte",
|
||||||
|
@ -59,6 +59,7 @@ export const BlockEditForm: FC<ComponentFormProps<IBlock>> = ({
|
|||||||
const DEFAULT_VALUES = {
|
const DEFAULT_VALUES = {
|
||||||
name: block?.name || "",
|
name: block?.name || "",
|
||||||
patterns: block?.patterns || [],
|
patterns: block?.patterns || [],
|
||||||
|
outcomes: block?.outcomes || [],
|
||||||
trigger_labels: block?.trigger_labels || [],
|
trigger_labels: block?.trigger_labels || [],
|
||||||
trigger_channels: block?.trigger_channels || [],
|
trigger_channels: block?.trigger_channels || [],
|
||||||
options: block?.options || {
|
options: block?.options || {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2024 Hexastack. All rights reserved.
|
* Copyright © 2025 Hexastack. All rights reserved.
|
||||||
*
|
*
|
||||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -16,6 +16,7 @@ import { useFind } from "@/hooks/crud/useFind";
|
|||||||
import { EntityType } from "@/services/types";
|
import { EntityType } from "@/services/types";
|
||||||
import { IBlockAttributes } from "@/types/block.types";
|
import { IBlockAttributes } from "@/types/block.types";
|
||||||
import { StdPluginMessage } from "@/types/message.types";
|
import { StdPluginMessage } from "@/types/message.types";
|
||||||
|
import { getNamespace } from "@/utils/string";
|
||||||
|
|
||||||
import { useBlock } from "./BlockFormProvider";
|
import { useBlock } from "./BlockFormProvider";
|
||||||
|
|
||||||
@ -63,8 +64,7 @@ const PluginMessageForm = () => {
|
|||||||
<SettingInput
|
<SettingInput
|
||||||
setting={setting}
|
setting={setting}
|
||||||
field={field}
|
field={field}
|
||||||
// @TODO : clean this later
|
ns={getNamespace(message.plugin)}
|
||||||
ns={message.plugin.replaceAll("-", "_")}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2025 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 {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
InputAdornment,
|
||||||
|
Skeleton,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Input } from "@/app-components/inputs/Input";
|
||||||
|
import { useGetFromCache } from "@/hooks/crud/useGet";
|
||||||
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
import { theme } from "@/layout/themes/theme";
|
||||||
|
import { EntityType } from "@/services/types";
|
||||||
|
import { IBlock, PayloadPattern } from "@/types/block.types";
|
||||||
|
import { PayloadType } from "@/types/message.types";
|
||||||
|
import { getNamespace } from "@/utils/string";
|
||||||
|
|
||||||
|
import { useBlock } from "../../BlockFormProvider";
|
||||||
|
|
||||||
|
type PayloadOption = PayloadPattern & {
|
||||||
|
group: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutcomeInputProps = {
|
||||||
|
defaultValue: PayloadPattern;
|
||||||
|
onChange: (pattern: PayloadPattern) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OutcomeInput = ({ defaultValue, onChange }: OutcomeInputProps) => {
|
||||||
|
const block = useBlock();
|
||||||
|
const [selectedValue, setSelectedValue] = useState(defaultValue);
|
||||||
|
const getBlockFromCache = useGetFromCache(EntityType.BLOCK);
|
||||||
|
const { t } = useTranslate();
|
||||||
|
// Gather previous blocks outcomes
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
(block?.previousBlocks || [])
|
||||||
|
.map((b) => getBlockFromCache(b))
|
||||||
|
.filter((b) => b && Array.isArray(b.outcomes) && b.outcomes.length > 0)
|
||||||
|
.map((b) => b as IBlock)
|
||||||
|
.reduce(
|
||||||
|
(acc, b) => {
|
||||||
|
const outcomes = (b.outcomes || []).map((outcome) => ({
|
||||||
|
label: t(`label.${outcome}` as any, {
|
||||||
|
defaultValue: outcome,
|
||||||
|
ns:
|
||||||
|
"plugin" in b.message
|
||||||
|
? getNamespace(b.message.plugin)
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
value: outcome,
|
||||||
|
group: b.name,
|
||||||
|
type: PayloadType.outcome,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return acc.concat(outcomes);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: t("label.any_outcome"),
|
||||||
|
value: "any",
|
||||||
|
type: PayloadType.outcome,
|
||||||
|
group: "general",
|
||||||
|
},
|
||||||
|
] as PayloadOption[],
|
||||||
|
),
|
||||||
|
[block?.previousBlocks, getBlockFromCache],
|
||||||
|
);
|
||||||
|
const isOptionsReady =
|
||||||
|
!defaultValue || options.find((o) => o.value === defaultValue.value);
|
||||||
|
|
||||||
|
if (!isOptionsReady) {
|
||||||
|
return (
|
||||||
|
<Skeleton animation="wave" variant="rounded" width="100%" height={40} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = defaultValue
|
||||||
|
? options.find((o) => o.value === defaultValue.value)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
defaultValue={selected || undefined}
|
||||||
|
value={selected}
|
||||||
|
options={options}
|
||||||
|
multiple={false}
|
||||||
|
disableClearable
|
||||||
|
onChange={(_e, value) => {
|
||||||
|
setSelectedValue(value);
|
||||||
|
const { group: _g, ...payloadPattern } = value;
|
||||||
|
|
||||||
|
onChange(payloadPattern);
|
||||||
|
}}
|
||||||
|
groupBy={({ group }) => group ?? t("label.other")}
|
||||||
|
getOptionLabel={({ label }) => label}
|
||||||
|
isOptionEqualToValue={(option, value) => option.value === value.value}
|
||||||
|
renderGroup={({ key, group, children }) => (
|
||||||
|
<li key={key}>
|
||||||
|
<Typography component="h4" p={2} fontWeight={700} color="primary">
|
||||||
|
{t(`label.${group}`, { defaultValue: group })}
|
||||||
|
</Typography>
|
||||||
|
<Box>{children}</Box>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
renderInput={(props) => (
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
label={t("label.outcome")}
|
||||||
|
InputProps={{
|
||||||
|
...props.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Chip
|
||||||
|
sx={{
|
||||||
|
left: "8px",
|
||||||
|
height: "25px",
|
||||||
|
fontSize: "12px",
|
||||||
|
minWidth: "75px",
|
||||||
|
position: "relative",
|
||||||
|
maxHeight: "30px",
|
||||||
|
borderRadius: "16px",
|
||||||
|
borderColor: theme.palette.grey[400],
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
label={t(
|
||||||
|
`label.${selectedValue?.type || "outcome"}`,
|
||||||
|
).toLocaleLowerCase()}
|
||||||
|
variant="role"
|
||||||
|
/>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -23,6 +23,7 @@ import {
|
|||||||
PayloadPattern,
|
PayloadPattern,
|
||||||
} from "@/types/block.types";
|
} from "@/types/block.types";
|
||||||
|
|
||||||
|
import { OutcomeInput } from "./OutcomeInput";
|
||||||
import { PostbackInput } from "./PostbackInput";
|
import { PostbackInput } from "./PostbackInput";
|
||||||
|
|
||||||
const isRegex = (str: Pattern) => {
|
const isRegex = (str: Pattern) => {
|
||||||
@ -38,6 +39,8 @@ const getType = (pattern: Pattern): PatternType => {
|
|||||||
return "menu";
|
return "menu";
|
||||||
} else if (pattern?.type === "content") {
|
} else if (pattern?.type === "content") {
|
||||||
return "content";
|
return "content";
|
||||||
|
} else if (pattern?.type === "outcome") {
|
||||||
|
return "outcome";
|
||||||
} else {
|
} else {
|
||||||
return "payload";
|
return "payload";
|
||||||
}
|
}
|
||||||
@ -67,7 +70,6 @@ const PatternInput: FC<PatternInputProps> = ({
|
|||||||
} = useFormContext<IBlockAttributes>();
|
} = useFormContext<IBlockAttributes>();
|
||||||
const [pattern, setPattern] = useState<Pattern>(value);
|
const [pattern, setPattern] = useState<Pattern>(value);
|
||||||
const patternType = getType(value);
|
const patternType = getType(value);
|
||||||
const isPostbackType = ["payload", "content", "menu"].includes(patternType);
|
|
||||||
const registerInput = (
|
const registerInput = (
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
idx: number,
|
idx: number,
|
||||||
@ -100,15 +102,22 @@ const PatternInput: FC<PatternInputProps> = ({
|
|||||||
onChange={setPattern}
|
onChange={setPattern}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{["payload", "content", "menu"].includes(patternType) ? (
|
||||||
{isPostbackType ? (
|
<PostbackInput
|
||||||
<PostbackInput
|
onChange={(payload) => {
|
||||||
onChange={(payload) => {
|
payload && setPattern(payload);
|
||||||
payload && setPattern(payload);
|
}}
|
||||||
}}
|
defaultValue={pattern as PayloadPattern}
|
||||||
defaultValue={pattern as PayloadPattern}
|
/>
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
{patternType === "outcome" ? (
|
||||||
|
<OutcomeInput
|
||||||
|
onChange={(payload) => {
|
||||||
|
payload && setPattern(payload);
|
||||||
|
}}
|
||||||
|
defaultValue={pattern as PayloadPattern}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{typeof value === "string" && patternType === "regex" ? (
|
{typeof value === "string" && patternType === "regex" ? (
|
||||||
<RegexInput
|
<RegexInput
|
||||||
{...registerInput(t("message.regex_is_empty"), idx, {
|
{...registerInput(t("message.regex_is_empty"), idx, {
|
||||||
|
@ -6,14 +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).
|
* 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 {
|
import AbcIcon from "@mui/icons-material/Abc";
|
||||||
Abc,
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
Add,
|
import MediationIcon from "@mui/icons-material/Mediation";
|
||||||
Mouse,
|
import MouseIcon from "@mui/icons-material/Mouse";
|
||||||
PsychologyAlt,
|
import PsychologyAltIcon from "@mui/icons-material/PsychologyAlt";
|
||||||
RemoveCircleOutline,
|
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
||||||
Spellcheck,
|
import SpellcheckIcon from "@mui/icons-material/Spellcheck";
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { Box, Chip, IconButton, styled, useTheme } from "@mui/material";
|
import { Box, Chip, IconButton, styled, useTheme } from "@mui/material";
|
||||||
import { FC, useEffect, useMemo, useState } from "react";
|
import { FC, useEffect, useMemo, useState } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
@ -79,12 +78,20 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
|
|||||||
|
|
||||||
const actions: DropdownButtonAction[] = useMemo(
|
const actions: DropdownButtonAction[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ icon: <Spellcheck />, name: "Exact Match", defaultValue: "" },
|
|
||||||
{ icon: <Abc />, name: "Pattern Match", defaultValue: "//" },
|
|
||||||
{ icon: <PsychologyAlt />, name: "Intent Match", defaultValue: [] },
|
|
||||||
{
|
{
|
||||||
icon: <Mouse />,
|
icon: <SpellcheckIcon />,
|
||||||
name: "Interaction",
|
name: t("label.exact_match"),
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
|
{ icon: <AbcIcon />, name: t("label.pattern_match"), defaultValue: "//" },
|
||||||
|
{
|
||||||
|
icon: <PsychologyAltIcon />,
|
||||||
|
name: t("label.intent_match"),
|
||||||
|
defaultValue: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MouseIcon />,
|
||||||
|
name: t("label.interaction"),
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
label: t("label.get_started"),
|
label: t("label.get_started"),
|
||||||
value: "GET_STARTED",
|
value: "GET_STARTED",
|
||||||
@ -92,6 +99,16 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
|
|||||||
group: "general",
|
group: "general",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <MediationIcon />,
|
||||||
|
name: t("label.outcome_match"),
|
||||||
|
defaultValue: {
|
||||||
|
label: t("label.any_outcome"),
|
||||||
|
value: "any",
|
||||||
|
type: PayloadType.outcome,
|
||||||
|
group: "general",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[],
|
[],
|
||||||
@ -129,7 +146,7 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={() => removeInput(idx)}
|
onClick={() => removeInput(idx)}
|
||||||
>
|
>
|
||||||
<RemoveCircleOutline />
|
<RemoveCircleOutlineIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
@ -140,7 +157,7 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
|
|||||||
label={t("button.add_pattern")}
|
label={t("button.add_pattern")}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onClick={(action) => addInput(action.defaultValue as Pattern)}
|
onClick={(action) => addInput(action.defaultValue as Pattern)}
|
||||||
icon={<Add />}
|
icon={<AddIcon />}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -64,7 +64,7 @@ export interface PayloadPattern {
|
|||||||
value: string;
|
value: string;
|
||||||
// @todo : rename 'attachment' to 'attachments'
|
// @todo : rename 'attachment' to 'attachments'
|
||||||
// @todo: If undefined, that means the payload could be either quick_reply or button
|
// @todo: If undefined, that means the payload could be either quick_reply or button
|
||||||
// We will move soon so that it will be a required attribute
|
// We should update soon so that it will be a required attribute
|
||||||
type?: PayloadType;
|
type?: PayloadType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,12 +81,14 @@ export type PatternType =
|
|||||||
| "nlp"
|
| "nlp"
|
||||||
| "menu"
|
| "menu"
|
||||||
| "content"
|
| "content"
|
||||||
|
| "outcome"
|
||||||
| "payload"
|
| "payload"
|
||||||
| "text";
|
| "text";
|
||||||
|
|
||||||
export interface IBlockAttributes {
|
export interface IBlockAttributes {
|
||||||
name: string;
|
name: string;
|
||||||
patterns?: Pattern[];
|
patterns?: Pattern[];
|
||||||
|
outcomes?: string[];
|
||||||
trigger_labels?: string[];
|
trigger_labels?: string[];
|
||||||
trigger_channels?: string[];
|
trigger_channels?: string[];
|
||||||
assign_labels?: string[];
|
assign_labels?: string[];
|
||||||
|
@ -30,6 +30,7 @@ export enum PayloadType {
|
|||||||
content = "content",
|
content = "content",
|
||||||
quick_reply = "quick_reply",
|
quick_reply = "quick_reply",
|
||||||
button = "button",
|
button = "button",
|
||||||
|
outcome = "outcome",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FileType {
|
export enum FileType {
|
||||||
|
@ -14,3 +14,7 @@ export const slugify = (str: string) => {
|
|||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
.replace(/-+/g, "_");
|
.replace(/-+/g, "_");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNamespace = (extensionName: string) => {
|
||||||
|
return extensionName.replaceAll("-", "_");
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user