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,13 +1,13 @@
import type { FeedbackStore } from './stores/feedback'; import type { FeedbackStore } from './stores/feedback';
import type { SessionStore } from './stores/session'; import type { SessionStore } from './stores/session';
import { Telegraf } from 'telegraf'; import { Telegraf } from 'telegraf';
import { onCallback, onStart } from './telegram/handlers'; import { onMessage, onStart } from './telegram/handlers';
export function createBot(token: string, rootDir: string, sessions: SessionStore, feedback: FeedbackStore, signal: AbortSignal): Telegraf { export function createBot(token: string, rootDir: string, sessions: SessionStore, feedback: FeedbackStore, signal: AbortSignal): Telegraf {
const bot = new Telegraf(token, { handlerTimeout: 10_000 }); const bot = new Telegraf(token, { handlerTimeout: 10_000 });
bot.start(onStart(rootDir, sessions)); bot.start(onStart(rootDir));
bot.on('callback_query', onCallback(rootDir, sessions, feedback)); bot.on('text', onMessage(rootDir, sessions, feedback));
// Graceful shutdown // Graceful shutdown
signal.addEventListener('abort', () => { signal.addEventListener('abort', () => {

View File

@@ -29,29 +29,31 @@ export class QuestAdapter {
getTextAndChoices(): Render { getTextAndChoices(): Render {
const node = this.interpreter.getCurrentNode(); const node = this.interpreter.getCurrentNode();
const info = this.interpreter.getQuestInfo(); // const info = this.interpreter.getQuestInfo();
const header = `☕️ ${info.name}`;
if (!node) if (!node)
return { text: `${header}\nОшибка: текущий узел не найден`, choices: [] }; return { text: `Ошибка: текущий узел не найден`, choices: [] };
if (node.nodeType === 'действие') { if (node.nodeType === 'действие') {
const choices = this.interpreter.getAvailableChoices().map((c: OptionChoice, i: number) => ({ idx: i, text: c.text })); const choices = this.interpreter.getAvailableChoices().map((c: OptionChoice, i: number) => ({ idx: i, text: c.text }));
return { text: `${header}\n\n${node.description}`, choices }; return { text: `${node.description}`, choices };
} }
if (node.nodeType === 'концовка') { if (node.nodeType === 'концовка') {
const endingNode = node as EndingNode; const endingNode = node as EndingNode;
return { text: `${header}\n\n🏁 ${endingNode.title}\n${endingNode.description}`, choices: [] }; return { text: `🏁 ${endingNode.title}\n${endingNode.description}`, choices: [] };
} }
return { text: `${header}\n\n${node.description}`, choices: [] }; return { text: `${node.description}`, choices: [] };
} }
choose(index: number): { done: boolean } { choose(index: number): { done: boolean } {
const result = this.interpreter.executeChoice(index); const result = this.interpreter.executeChoice(index);
if (!result.success) if (!result.success)
throw new Error(result.error || 'Выбор не выполнен'); throw new Error(result.error || 'Выбор не выполнен');
return { done: this.interpreter.getState().isComplete }; const state = this.interpreter.getState();
const node = this.interpreter.getCurrentNode();
const finished = state.isComplete || node?.nodeType === 'концовка';
return { done: finished };
} }
moveTo(nodeId: string): void { moveTo(nodeId: string): void {

View File

@@ -17,10 +17,18 @@ export function openDB(sqlitePath: string) {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
chat_id TEXT PRIMARY KEY, chat_id TEXT PRIMARY KEY,
entry_file TEXT NOT NULL DEFAULT 'feedback.ql',
current_node TEXT NOT NULL, current_node TEXT NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
`); `);
// migrate existing DBs to add entry_file if missing
try {
const cols = db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'entry_file'))
db.exec('ALTER TABLE sessions ADD COLUMN entry_file TEXT NOT NULL DEFAULT \'feedback.ql\'');
}
catch {}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -2,6 +2,7 @@ import type { DB } from './db';
export interface Session { export interface Session {
chatId: string; chatId: string;
entryFile: string;
currentNode: string; currentNode: string;
updatedAt: number; updatedAt: number;
} }
@@ -10,18 +11,18 @@ export class SessionStore {
constructor(private db: DB) {} constructor(private db: DB) {}
get(chatId: string): Session | null { get(chatId: string): Session | null {
const stmt = this.db.db.prepare('SELECT chat_id, current_node, updated_at FROM sessions WHERE chat_id = ?'); const stmt = this.db.db.prepare('SELECT chat_id, entry_file, current_node, updated_at FROM sessions WHERE chat_id = ?');
const row = stmt.get(chatId) as any | undefined; const row = stmt.get(chatId) as any | undefined;
if (!row) if (!row)
return null; return null;
return { chatId: row.chat_id, currentNode: row.current_node, updatedAt: row.updated_at }; return { chatId: row.chat_id, entryFile: row.entry_file, currentNode: row.current_node, updatedAt: row.updated_at };
} }
upsert(session: Session): void { upsert(session: Session): void {
const stmt = this.db.db.prepare( const stmt = this.db.db.prepare(
'INSERT INTO sessions(chat_id, current_node, updated_at) VALUES (?, ?, ?) ON CONFLICT(chat_id) DO UPDATE SET current_node = excluded.current_node, updated_at = excluded.updated_at', 'INSERT INTO sessions(chat_id, entry_file, current_node, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(chat_id) DO UPDATE SET entry_file = excluded.entry_file, current_node = excluded.current_node, updated_at = excluded.updated_at',
); );
stmt.run(session.chatId, session.currentNode, session.updatedAt); stmt.run(session.chatId, session.entryFile, session.currentNode, session.updatedAt);
} }
delete(chatId: string): void { delete(chatId: string): void {

View File

@@ -1,82 +1,97 @@
import type { Context } from 'telegraf'; import type { Context } from 'telegraf';
import type { Message } from 'telegraf/types';
import type { FeedbackStore } from '../stores/feedback'; import type { FeedbackStore } from '../stores/feedback';
import type { SessionStore } from '../stores/session'; import type { SessionStore } from '../stores/session';
import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { QuestAdapter } from '../quest/adapter'; 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) => { return async (ctx: Context) => {
const chatId = String(ctx.chat?.id ?? ''); const chatId = String(ctx.chat?.id ?? '');
if (!chatId || !('data' in (ctx.callbackQuery ?? {}))) const text = 'text' in (ctx.message ?? {}) ? (ctx.message as Message.TextMessage).text : '';
return; if (!chatId || !text)
const data = (ctx.callbackQuery as any).data as string;
if (!data?.startsWith('q:'))
return; return;
const idx = Number(data.split(':')[1] || '-1'); const scenarios = getScenarios(rootDir);
if (Number.isNaN(idx) || idx < 0)
return;
// restore adapter at current node
const adapter = new QuestAdapter(path.join(rootDir, 'scenarios'));
const sess = sessions.get(chatId); const sess = sessions.get(chatId);
if (sess) const scenariosRoot = path.join(rootDir, 'scenarios');
adapter.moveTo(sess.currentNode);
// capture selected choice text for analytics // Start new scenario
const before = adapter.getTextAndChoices(); if (!sess) {
const selected = before.choices.find(c => c.idx === idx)?.text || ''; const normalized = text.trim().toLowerCase();
const fromNode = adapter.getCurrentNodeId(); const chosen = normalized === 'обратная связь'
adapter.choose(idx); ? { file: 'feedback.ql' }
: scenarios.find(s => s.label.trim().toLowerCase() === normalized || path.basename(s.file, '.ql').trim().toLowerCase() === normalized);
// render if (!chosen) {
const render = adapter.getTextAndChoices(); await ctx.reply('Привет!', buildMainMenuKeyboard(scenarios.map(s => s.label)));
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 {
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; return;
}
const adapter = new QuestAdapter(path.join(rootDir, 'scenarios')); 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);
return;
}
// 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(); const render = adapter.getTextAndChoices();
await ctx.reply(render.text, render.choices.length ? buildChoicesKeyboard(render.choices) : undefined); feedback?.addEvent(chatId, fromNode, text);
// persist session await ctx.reply(
sessions.upsert({ chatId, currentNode: adapter.getCurrentNodeId(), updatedAt: Date.now() }); 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)));
}; };
} }

View File

@@ -1,9 +1,16 @@
import { Markup } from 'telegraf'; import { Markup } from 'telegraf';
export function buildChoicesKeyboard(choices: { idx: number; text: string }[]): ReturnType<typeof Markup.inlineKeyboard> { export function buildChoicesKeyboard(choices: { idx: number; text: string }[]): ReturnType<typeof Markup.keyboard> {
const buttons = choices.map(c => Markup.button.callback(c.text, `q:${c.idx}`)); const buttons = choices.map(c => Markup.button.text(c.text));
// chunk into rows of 2 // chunk into rows of 2
const rows: ReturnType<typeof Markup.button.callback>[][] = []; const rows: ReturnType<typeof Markup.button.text>[][] = [];
for (let i = 0; i < buttons.length; i += 2) rows.push(buttons.slice(i, i + 2)); for (let i = 0; i < buttons.length; i += 2) rows.push(buttons.slice(i, i + 2));
return Markup.inlineKeyboard(rows); return Markup.keyboard(rows).resize();
}
export function buildMainMenuKeyboard(items: string[]): ReturnType<typeof Markup.keyboard> {
const buttons = items.map(t => Markup.button.text(t));
const rows: ReturnType<typeof Markup.button.text>[][] = [];
for (let i = 0; i < buttons.length; i += 2) rows.push(buttons.slice(i, i + 2));
return Markup.keyboard(rows).resize();
} }