diff --git a/src/bot.ts b/src/bot.ts index f731e43..4ef7ece 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -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', () => { diff --git a/src/quest/adapter.ts b/src/quest/adapter.ts index 993c5d4..9e17a39 100644 --- a/src/quest/adapter.ts +++ b/src/quest/adapter.ts @@ -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 { diff --git a/src/stores/db.ts b/src/stores/db.ts index 61763b8..0b0cb36 100644 --- a/src/stores/db.ts +++ b/src/stores/db.ts @@ -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, diff --git a/src/stores/session.ts b/src/stores/session.ts index b654be9..828f255 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -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 { diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 36c4138..34852fe 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -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 = { '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))); }; } diff --git a/src/telegram/keyboards.ts b/src/telegram/keyboards.ts index 281fb92..7e3a7c3 100644 --- a/src/telegram/keyboards.ts +++ b/src/telegram/keyboards.ts @@ -1,9 +1,16 @@ import { Markup } from 'telegraf'; -export function buildChoicesKeyboard(choices: { idx: number; text: string }[]): ReturnType { - const buttons = choices.map(c => Markup.button.callback(c.text, `q:${c.idx}`)); +export function buildChoicesKeyboard(choices: { idx: number; text: string }[]): ReturnType { + const buttons = choices.map(c => Markup.button.text(c.text)); // chunk into rows of 2 - const rows: ReturnType[][] = []; + const rows: ReturnType[][] = []; 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 { + const buttons = items.map(t => Markup.button.text(t)); + const rows: ReturnType[][] = []; + for (let i = 0; i < buttons.length; i += 2) rows.push(buttons.slice(i, i + 2)); + return Markup.keyboard(rows).resize(); }