mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
refactor: handle ongoing conversation message
This commit is contained in:
parent
629a07fef8
commit
d88959cea2
@ -88,6 +88,7 @@ import { SubscriberService } from './subscriber.service';
|
|||||||
describe('BotService', () => {
|
describe('BotService', () => {
|
||||||
let blockService: BlockService;
|
let blockService: BlockService;
|
||||||
let subscriberService: SubscriberService;
|
let subscriberService: SubscriberService;
|
||||||
|
let conversationService: ConversationService;
|
||||||
let botService: BotService;
|
let botService: BotService;
|
||||||
let handler: WebChannelHandler;
|
let handler: WebChannelHandler;
|
||||||
let eventEmitter: EventEmitter2;
|
let eventEmitter: EventEmitter2;
|
||||||
@ -192,14 +193,21 @@ describe('BotService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
[subscriberService, botService, blockService, eventEmitter, handler] =
|
[
|
||||||
await getMocks([
|
subscriberService,
|
||||||
SubscriberService,
|
conversationService,
|
||||||
BotService,
|
botService,
|
||||||
BlockService,
|
blockService,
|
||||||
EventEmitter2,
|
eventEmitter,
|
||||||
WebChannelHandler,
|
handler,
|
||||||
]);
|
] = await getMocks([
|
||||||
|
SubscriberService,
|
||||||
|
ConversationService,
|
||||||
|
BotService,
|
||||||
|
BlockService,
|
||||||
|
EventEmitter2,
|
||||||
|
WebChannelHandler,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(jest.clearAllMocks);
|
afterEach(jest.clearAllMocks);
|
||||||
@ -351,4 +359,102 @@ describe('BotService', () => {
|
|||||||
expect(captured).toBe(false);
|
expect(captured).toBe(false);
|
||||||
expect(triggeredEvents).toEqual([]);
|
expect(triggeredEvents).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('proceedToNextBlock', () => {
|
||||||
|
it('should emit stats and call triggerBlock, returning true on success and reset attempt if not fallback', async () => {
|
||||||
|
const convo = {
|
||||||
|
id: 'convo1',
|
||||||
|
context: { attempt: 2 },
|
||||||
|
next: [],
|
||||||
|
sender: 'user1',
|
||||||
|
active: true,
|
||||||
|
} as unknown as ConversationFull;
|
||||||
|
const next = { id: 'block1', name: 'Block 1' } as BlockFull;
|
||||||
|
const event = {} as any;
|
||||||
|
const fallback = false;
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(conversationService, 'storeContextData')
|
||||||
|
.mockImplementation((convo, _next, _event, _captureVars) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
...convo,
|
||||||
|
} as Conversation);
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(botService, 'triggerBlock').mockResolvedValue(undefined);
|
||||||
|
const emitSpy = jest.spyOn(eventEmitter, 'emit');
|
||||||
|
const result = await botService.proceedToNextBlock(
|
||||||
|
convo,
|
||||||
|
next,
|
||||||
|
event,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(
|
||||||
|
'hook:stats:entry',
|
||||||
|
'popular',
|
||||||
|
next.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(botService.triggerBlock).toHaveBeenCalledWith(
|
||||||
|
event,
|
||||||
|
expect.objectContaining({ id: 'convo1' }),
|
||||||
|
next,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(convo.context.attempt).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment attempt if fallback is true', async () => {
|
||||||
|
const convo = {
|
||||||
|
id: 'convo2',
|
||||||
|
context: { attempt: 1 },
|
||||||
|
next: [],
|
||||||
|
sender: 'user2',
|
||||||
|
active: true,
|
||||||
|
} as any;
|
||||||
|
const next = { id: 'block2', name: 'Block 2' } as any;
|
||||||
|
const event = {} as any;
|
||||||
|
const fallback = true;
|
||||||
|
|
||||||
|
const result = await botService.proceedToNextBlock(
|
||||||
|
convo,
|
||||||
|
next,
|
||||||
|
event,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(convo.context.attempt).toBe(2);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors and emit conversation:end, returning false', async () => {
|
||||||
|
const convo = {
|
||||||
|
id: 'convo3',
|
||||||
|
context: { attempt: 1 },
|
||||||
|
next: [],
|
||||||
|
sender: 'user3',
|
||||||
|
active: true,
|
||||||
|
} as any;
|
||||||
|
const next = { id: 'block3', name: 'Block 3' } as any;
|
||||||
|
const event = {} as any;
|
||||||
|
const fallback = false;
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(conversationService, 'storeContextData')
|
||||||
|
.mockRejectedValue(new Error('fail'));
|
||||||
|
|
||||||
|
const emitSpy = jest.spyOn(eventEmitter, 'emit');
|
||||||
|
const result = await botService.proceedToNextBlock(
|
||||||
|
convo,
|
||||||
|
next,
|
||||||
|
event,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', convo);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
|
|
||||||
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
|
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
|
||||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||||
|
import { HelperService } from '@/helper/helper.service';
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
import { LoggerService } from '@/logger/logger.service';
|
||||||
import { SettingService } from '@/setting/services/setting.service';
|
import { SettingService } from '@/setting/services/setting.service';
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ export class BotService {
|
|||||||
private readonly conversationService: ConversationService,
|
private readonly conversationService: ConversationService,
|
||||||
private readonly subscriberService: SubscriberService,
|
private readonly subscriberService: SubscriberService,
|
||||||
private readonly settingService: SettingService,
|
private readonly settingService: SettingService,
|
||||||
|
private readonly helperService: HelperService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -243,6 +245,49 @@ export class BotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles advancing the conversation to the specified *next* block.
|
||||||
|
*
|
||||||
|
* 1. Updates “popular blocks” stats.
|
||||||
|
* 2. Persists the updated conversation context.
|
||||||
|
* 3. Triggers the next block.
|
||||||
|
* 4. Ends the conversation if an unrecoverable error occurs.
|
||||||
|
*/
|
||||||
|
async proceedToNextBlock(
|
||||||
|
convo: ConversationFull,
|
||||||
|
next: BlockFull,
|
||||||
|
event: EventWrapper<any, any>,
|
||||||
|
fallback: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Increment stats about popular blocks
|
||||||
|
this.eventEmitter.emit('hook:stats:entry', BotStatsType.popular, next.name);
|
||||||
|
this.logger.debug(
|
||||||
|
'Proceeding to next block ',
|
||||||
|
next.id,
|
||||||
|
' for conversation ',
|
||||||
|
convo.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
convo.context.attempt = fallback ? convo.context.attempt + 1 : 0;
|
||||||
|
const updatedConversation =
|
||||||
|
await this.conversationService.storeContextData(
|
||||||
|
convo,
|
||||||
|
next,
|
||||||
|
event,
|
||||||
|
// If this is a local fallback then we don’t capture vars.
|
||||||
|
!fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.triggerBlock(event, updatedConversation, next, fallback);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Unable to proceed to the next block!', err);
|
||||||
|
this.eventEmitter.emit('hook:conversation:end', convo);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes and responds to an incoming message within an ongoing conversation flow.
|
* Processes and responds to an incoming message within an ongoing conversation flow.
|
||||||
* Determines the next block in the conversation, attempts to match the message with available blocks,
|
* Determines the next block in the conversation, attempts to match the message with available blocks,
|
||||||
@ -283,7 +328,7 @@ export class BotService {
|
|||||||
);
|
);
|
||||||
// If there is no match in next block then loopback (current fallback)
|
// If there is no match in next block then loopback (current fallback)
|
||||||
// This applies only to text messages + there's a max attempt to be specified
|
// This applies only to text messages + there's a max attempt to be specified
|
||||||
let fallbackBlock: BlockFull | undefined;
|
let fallbackBlock: BlockFull | undefined = undefined;
|
||||||
if (
|
if (
|
||||||
!matchedBlock &&
|
!matchedBlock &&
|
||||||
event.getMessageType() === IncomingMessageType.message &&
|
event.getMessageType() === IncomingMessageType.message &&
|
||||||
@ -303,11 +348,7 @@ export class BotService {
|
|||||||
category: null,
|
category: null,
|
||||||
previousBlocks: [],
|
previousBlocks: [],
|
||||||
};
|
};
|
||||||
convo.context.attempt++;
|
|
||||||
fallback = true;
|
fallback = true;
|
||||||
} else {
|
|
||||||
convo.context.attempt = 0;
|
|
||||||
fallbackBlock = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = matchedBlock || fallbackBlock;
|
const next = matchedBlock || fallbackBlock;
|
||||||
@ -315,30 +356,8 @@ export class BotService {
|
|||||||
this.logger.debug('Responding ...', convo.id);
|
this.logger.debug('Responding ...', convo.id);
|
||||||
|
|
||||||
if (next) {
|
if (next) {
|
||||||
// Increment stats about popular blocks
|
// Proceed to the execution of the next block
|
||||||
this.eventEmitter.emit(
|
return await this.proceedToNextBlock(convo, next, event, fallback);
|
||||||
'hook:stats:entry',
|
|
||||||
BotStatsType.popular,
|
|
||||||
next.name,
|
|
||||||
);
|
|
||||||
// Go next!
|
|
||||||
this.logger.debug('Respond to nested conversion! Go next ', next.id);
|
|
||||||
try {
|
|
||||||
const updatedConversation =
|
|
||||||
await this.conversationService.storeContextData(
|
|
||||||
convo,
|
|
||||||
next,
|
|
||||||
event,
|
|
||||||
// If this is a local fallback then we don't capture vars
|
|
||||||
// Otherwise, old captured const value may be replaced by another const value
|
|
||||||
!fallback,
|
|
||||||
);
|
|
||||||
await this.triggerBlock(event, updatedConversation, next, fallback);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error('Unable to store context data!', err);
|
|
||||||
return this.eventEmitter.emit('hook:conversation:end', convo);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} else {
|
} else {
|
||||||
// Conversation is still active, but there's no matching block to call next
|
// Conversation is still active, but there's no matching block to call next
|
||||||
// We'll end the conversation but this message is probably lost in time and space.
|
// We'll end the conversation but this message is probably lost in time and space.
|
||||||
|
Loading…
Reference in New Issue
Block a user