feat: update bot handlers and session management for improved scenario interaction

This commit is contained in:
2025-11-16 04:32:37 +07:00
parent 8754c2b460
commit 9509189421
6 changed files with 112 additions and 79 deletions

View File

@@ -1,82 +1,97 @@
import type { Context } from 'telegraf';
import type { Message } from 'telegraf/types';
import type { FeedbackStore } from '../stores/feedback';
import type { SessionStore } from '../stores/session';
import fs from 'node:fs';
import path from 'node:path';
import { QuestAdapter } from '../quest/adapter';
import { buildChoicesKeyboard } from './keyboards';
import { buildChoicesKeyboard, buildMainMenuKeyboard } from './keyboards';
export function onCallback(rootDir: string, sessions: SessionStore, feedback?: FeedbackStore) {
const LABELS: Record<string, string> = { 'feedback.ql': 'Обратная связь' };
function getScenarios(rootDir: string) {
const dir = path.join(rootDir, 'scenarios');
const files = fs.readdirSync(dir, { withFileTypes: true })
.filter(d => d.isFile() && d.name.endsWith('.ql'));
const scenarios = files.map(d => ({
label: LABELS[d.name] ?? path.basename(d.name, '.ql'),
file: d.name,
}));
return scenarios.sort((a, b) =>
a.file === 'feedback.ql' ? -1 : b.file === 'feedback.ql' ? 1 : a.label.localeCompare(b.label, 'ru'),
);
}
function startScenario(chatId: string, file: string, adapter: QuestAdapter, sessions: SessionStore) {
const render = adapter.getTextAndChoices();
if (render.choices.length > 0) {
sessions.upsert({ chatId, entryFile: file, currentNode: adapter.getCurrentNodeId(), updatedAt: Date.now() });
}
return render;
}
export function onMessage(rootDir: string, sessions: SessionStore, feedback?: FeedbackStore) {
return async (ctx: Context) => {
const chatId = String(ctx.chat?.id ?? '');
if (!chatId || !('data' in (ctx.callbackQuery ?? {})))
return;
const data = (ctx.callbackQuery as any).data as string;
if (!data?.startsWith('q:'))
const text = 'text' in (ctx.message ?? {}) ? (ctx.message as Message.TextMessage).text : '';
if (!chatId || !text)
return;
const idx = Number(data.split(':')[1] || '-1');
if (Number.isNaN(idx) || idx < 0)
return;
// restore adapter at current node
const adapter = new QuestAdapter(path.join(rootDir, 'scenarios'));
const scenarios = getScenarios(rootDir);
const sess = sessions.get(chatId);
if (sess)
adapter.moveTo(sess.currentNode);
const scenariosRoot = path.join(rootDir, 'scenarios');
// capture selected choice text for analytics
const before = adapter.getTextAndChoices();
const selected = before.choices.find(c => c.idx === idx)?.text || '';
const fromNode = adapter.getCurrentNodeId();
adapter.choose(idx);
// Start new scenario
if (!sess) {
const normalized = text.trim().toLowerCase();
const chosen = normalized === 'обратная связь'
? { file: 'feedback.ql' }
: scenarios.find(s => s.label.trim().toLowerCase() === normalized || path.basename(s.file, '.ql').trim().toLowerCase() === normalized);
// render
const render = adapter.getTextAndChoices();
if ('message' in (ctx.callbackQuery as any)) {
const msg = (ctx.callbackQuery as any).message;
await ctx.telegram.editMessageText(
msg.chat.id,
msg.message_id,
undefined,
render.text,
render.choices.length ? { ...buildChoicesKeyboard(render.choices) } : undefined,
).catch(async () => {
// fallback if cannot edit
await ctx.reply(render.text, render.choices.length ? buildChoicesKeyboard(render.choices) : undefined);
});
}
else {
if (!chosen) {
await ctx.reply('Привет!', buildMainMenuKeyboard(scenarios.map(s => s.label)));
return;
}
const adapter = new QuestAdapter(scenariosRoot, chosen.file);
const render = startScenario(chatId, chosen.file, adapter, sessions);
await ctx.reply(render.text, render.choices.length ? buildChoicesKeyboard(render.choices) : undefined);
}
// persist session
sessions.upsert({ chatId, currentNode: adapter.getCurrentNodeId(), updatedAt: Date.now() });
// store event
if (feedback && selected)
feedback.addEvent(chatId, fromNode, selected);
try {
await ctx.answerCbQuery();
}
catch {
// ignore
}
};
}
export function onStart(rootDir: string, sessions: SessionStore) {
return async (ctx: Context) => {
const chatId = String(ctx.chat?.id ?? '');
if (!chatId)
return;
}
const adapter = new QuestAdapter(path.join(rootDir, 'scenarios'));
// Continue active scenario
const adapter = new QuestAdapter(scenariosRoot, sess.entryFile);
adapter.moveTo(sess.currentNode);
const current = adapter.getTextAndChoices();
const choiceIdx = current.choices.findIndex(c => c.text === text);
if (choiceIdx < 0) {
await ctx.reply(current.text, current.choices.length ? buildChoicesKeyboard(current.choices) : undefined);
return;
}
const fromNode = adapter.getCurrentNodeId();
const { done } = adapter.choose(choiceIdx);
const render = adapter.getTextAndChoices();
await ctx.reply(render.text, render.choices.length ? buildChoicesKeyboard(render.choices) : undefined);
feedback?.addEvent(chatId, fromNode, text);
// persist session
sessions.upsert({ chatId, currentNode: adapter.getCurrentNodeId(), updatedAt: Date.now() });
await ctx.reply(
render.text,
done ? buildMainMenuKeyboard(scenarios.map(s => s.label)) : (render.choices.length ? buildChoicesKeyboard(render.choices) : undefined),
);
done ? sessions.delete(chatId) : sessions.upsert({ chatId, entryFile: sess.entryFile, currentNode: adapter.getCurrentNodeId(), updatedAt: Date.now() });
};
}
export function onStart(rootDir: string) {
return async (ctx: Context) => {
if (!ctx.chat?.id)
return;
const scenarios = getScenarios(rootDir);
await ctx.reply('Привет!', buildMainMenuKeyboard(scenarios.map(s => s.label)));
};
}