feat: update bot handlers and session management for improved scenario interaction
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import type { FeedbackStore } from './stores/feedback';
|
||||
import type { SessionStore } from './stores/session';
|
||||
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 {
|
||||
const bot = new Telegraf(token, { handlerTimeout: 10_000 });
|
||||
|
||||
bot.start(onStart(rootDir, sessions));
|
||||
bot.on('callback_query', onCallback(rootDir, sessions, feedback));
|
||||
bot.start(onStart(rootDir));
|
||||
bot.on('text', onMessage(rootDir, sessions, feedback));
|
||||
|
||||
// Graceful shutdown
|
||||
signal.addEventListener('abort', () => {
|
||||
|
||||
@@ -29,29 +29,31 @@ export class QuestAdapter {
|
||||
|
||||
getTextAndChoices(): Render {
|
||||
const node = this.interpreter.getCurrentNode();
|
||||
const info = this.interpreter.getQuestInfo();
|
||||
const header = `☕️ ${info.name}`;
|
||||
// const info = this.interpreter.getQuestInfo();
|
||||
if (!node)
|
||||
return { text: `${header}\nОшибка: текущий узел не найден`, choices: [] };
|
||||
return { text: `Ошибка: текущий узел не найден`, choices: [] };
|
||||
|
||||
if (node.nodeType === 'действие') {
|
||||
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 === 'концовка') {
|
||||
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 } {
|
||||
const result = this.interpreter.executeChoice(index);
|
||||
if (!result.success)
|
||||
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 {
|
||||
|
||||
@@ -17,10 +17,18 @@ export function openDB(sqlitePath: string) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
entry_file TEXT NOT NULL DEFAULT 'feedback.ql',
|
||||
current_node TEXT 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(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DB } from './db';
|
||||
|
||||
export interface Session {
|
||||
chatId: string;
|
||||
entryFile: string;
|
||||
currentNode: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -10,18 +11,18 @@ export class SessionStore {
|
||||
constructor(private db: DB) {}
|
||||
|
||||
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;
|
||||
if (!row)
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
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)
|
||||
if (!chosen) {
|
||||
await ctx.reply('Привет!', buildMainMenuKeyboard(scenarios.map(s => s.label)));
|
||||
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();
|
||||
|
||||
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)));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Markup } from 'telegraf';
|
||||
|
||||
export function buildChoicesKeyboard(choices: { idx: number; text: string }[]): ReturnType<typeof Markup.inlineKeyboard> {
|
||||
const buttons = choices.map(c => Markup.button.callback(c.text, `q:${c.idx}`));
|
||||
export function buildChoicesKeyboard(choices: { idx: number; text: string }[]): ReturnType<typeof Markup.keyboard> {
|
||||
const buttons = choices.map(c => Markup.button.text(c.text));
|
||||
// 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));
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user