feat: implement feedback collection system with quest-based interaction
This commit is contained in:
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
35
package.json
35
package.json
@@ -1,35 +1,26 @@
|
||||
{
|
||||
"name": "maybe-coffee-bot",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Maybe Coffee Telegram bot for collecting feedback about vending coffee machines",
|
||||
"description": "Telegram feedback bot powered by QuestLang scenarios",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"telegram",
|
||||
"bot",
|
||||
"feedback",
|
||||
"coffee"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node --env-file=.env --watch-path=dist dist/index.js",
|
||||
"build": "tsdown",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"lint:check": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grammyjs/conversations": "^2.1.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"grammy": "^1.38.3"
|
||||
"dev": "tsdown --watch",
|
||||
"start": "node --env-file=.env dist/index.mjs",
|
||||
"lint:check": "eslint ./src",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"type:check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.0.0",
|
||||
"@antfu/eslint-config": "^6.2.0",
|
||||
"@robonen/questlang": "^0.0.4",
|
||||
"@robonen/tsconfig": "^0.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.9.1",
|
||||
"eslint": "^9.38.0",
|
||||
"tsdown": "^0.15.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"eslint": "^9.39.1",
|
||||
"telegraf": "^4.16.3",
|
||||
"tsdown": "^0.16.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
1368
pnpm-lock.yaml
generated
1368
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
ignoredBuiltDependencies:
|
||||
- better-sqlite3
|
||||
20
scenarios/feedback.ql
Normal file
20
scenarios/feedback.ql
Normal file
@@ -0,0 +1,20 @@
|
||||
квест ОбратнаяСвязь;
|
||||
цель "Соберите отзыв о покупке кофе";
|
||||
|
||||
импорт Проблемы из "./modules/problems.ql";
|
||||
импорт Позитив из "./modules/positive.ql";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: { тип: начальный; описание: "Здравствуйте! Ответьте на пару вопросов."; переходы: [оценка]; }
|
||||
оценка: { тип: действие; описание: "Оцените опыт от 0 до 10"; варианты: [
|
||||
("0–2 👎", @Проблемы.причина),
|
||||
("3–4 👎", @Проблемы.причина),
|
||||
("5–6 😐", @Проблемы.причина),
|
||||
("7–8 🙂", @Позитив.что_понравилось),
|
||||
("9–10 👍", @Позитив.что_понравилось)
|
||||
]; }
|
||||
}
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
11
scenarios/modules/positive.ql
Normal file
11
scenarios/modules/positive.ql
Normal file
@@ -0,0 +1,11 @@
|
||||
модуль Позитив;
|
||||
узлы {
|
||||
что_понравилось: { тип: действие; описание: "Что вам понравилось больше всего?"; варианты: [
|
||||
("Вкус напитков", @Позитив.финал),
|
||||
("Цена", @Позитив.финал),
|
||||
("Скорость", @Позитив.финал),
|
||||
("Удобство", @Позитив.финал)
|
||||
]; }
|
||||
финал: { тип: концовка; название: "Спасибо!"; описание: "Благодарим за отзыв!"; }
|
||||
}
|
||||
экспорт [что_понравилось, финал];
|
||||
12
scenarios/modules/problems.ql
Normal file
12
scenarios/modules/problems.ql
Normal file
@@ -0,0 +1,12 @@
|
||||
модуль Проблемы;
|
||||
узлы {
|
||||
причина: { тип: действие; описание: "Что пошло не так?"; варианты: [
|
||||
("Проблема с напитком", @Проблемы.финал),
|
||||
("Проблема с аппаратом", @Проблемы.финал),
|
||||
("Проблема с оплатой", @Проблемы.финал),
|
||||
("Ассортимент", @Проблемы.финал),
|
||||
("Другое", @Проблемы.финал)
|
||||
]; }
|
||||
финал: { тип: концовка; название: "Спасибо!"; описание: "Мы передадим вашу жалобу в поддержку."; }
|
||||
}
|
||||
экспорт [причина, финал];
|
||||
18
src/bot.ts
Normal file
18
src/bot.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FeedbackStore } from './stores/feedback';
|
||||
import type { SessionStore } from './stores/session';
|
||||
import { Telegraf } from 'telegraf';
|
||||
import { onCallback, 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));
|
||||
|
||||
// Graceful shutdown
|
||||
signal.addEventListener('abort', () => {
|
||||
bot.stop('AbortController: abort');
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
18
src/config.ts
Normal file
18
src/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import process from 'node:process';
|
||||
|
||||
export interface AppConfig {
|
||||
botToken: string;
|
||||
supportChatId?: string;
|
||||
sqlitePath: string;
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const botToken = process.env.BOT_TOKEN || '';
|
||||
if (!botToken)
|
||||
throw new Error('BOT_TOKEN is required');
|
||||
|
||||
const supportChatId = process.env.SUPPORT_CHAT_ID;
|
||||
const sqlitePath = process.env.SQLITE_DB || './data/feedback.db';
|
||||
|
||||
return { botToken, supportChatId, sqlitePath };
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { MyContext, MyConversation } from './types';
|
||||
import { saveFeedback } from './database';
|
||||
import {
|
||||
drinkProblemKeyboard,
|
||||
frequencyKeyboard,
|
||||
machineProblemKeyboard,
|
||||
npsKeyboard,
|
||||
problemKeyboard,
|
||||
ratingKeyboard,
|
||||
skipKeyboard,
|
||||
} from './keyboards';
|
||||
|
||||
export async function feedbackConversation(conversation: MyConversation, ctx: MyContext) {
|
||||
await ctx.reply(
|
||||
'☕️ Добрый день! Мы постоянно работаем над улучшением нашего кофе и сервиса.\n\n'
|
||||
+ 'Пожалуйста, поделитесь вашим мнением — это займет не более 2-3 минут.',
|
||||
);
|
||||
|
||||
await ctx.reply('📍 В каком городе и по какому адресу находится наш кофейный аппарат?');
|
||||
const locationMsg = await conversation.wait();
|
||||
ctx.session.location = locationMsg.message?.text || 'Не указано';
|
||||
|
||||
await ctx.reply('☕️ Какую позицию вы сегодня пробовали?\n(Например: американо, капучино с сиропом, чай, снек)');
|
||||
const productMsg = await conversation.wait();
|
||||
ctx.session.product = productMsg.message?.text || 'Не указано';
|
||||
|
||||
await ctx.reply('⭐️ Ваша общая оценка визита от 1 до 10?', {
|
||||
reply_markup: ratingKeyboard,
|
||||
});
|
||||
|
||||
const ratingResponse = await conversation.wait();
|
||||
const rating = Number.parseInt(ratingResponse.callbackQuery?.data?.split('_')[1] || '0');
|
||||
ctx.session.rating = rating;
|
||||
await ratingResponse.answerCallbackQuery();
|
||||
|
||||
if (rating < 7) {
|
||||
await ctx.reply(
|
||||
'😔 К сожалению, что-то пошло не так.\n\nОпишите, пожалуйста, основную причину вашей низкой оценки:',
|
||||
{ reply_markup: problemKeyboard },
|
||||
);
|
||||
|
||||
const problemResponse = await conversation.wait();
|
||||
const problemCategory = problemResponse.callbackQuery?.data?.split('_')[1] || 'other';
|
||||
ctx.session.problemCategory = problemCategory;
|
||||
await problemResponse.answerCallbackQuery();
|
||||
|
||||
if (problemCategory === 'drink') {
|
||||
await ctx.reply('Что именно не так с напитком?', {
|
||||
reply_markup: drinkProblemKeyboard,
|
||||
});
|
||||
|
||||
const drinkDetailResponse = await conversation.wait();
|
||||
ctx.session.problemDetails = drinkDetailResponse.callbackQuery?.data || '';
|
||||
await drinkDetailResponse.answerCallbackQuery();
|
||||
}
|
||||
else if (problemCategory === 'machine') {
|
||||
await ctx.reply('Что именно случилось с аппаратом?', {
|
||||
reply_markup: machineProblemKeyboard,
|
||||
});
|
||||
|
||||
const machineDetailResponse = await conversation.wait();
|
||||
ctx.session.problemDetails = machineDetailResponse.callbackQuery?.data || '';
|
||||
await machineDetailResponse.answerCallbackQuery();
|
||||
}
|
||||
|
||||
await ctx.reply('📝 Опишите проблему более подробно, если хотите.', {
|
||||
reply_markup: skipKeyboard,
|
||||
});
|
||||
const descriptionResponse = await conversation.wait();
|
||||
|
||||
if (descriptionResponse.callbackQuery?.data !== 'skip') {
|
||||
const description = descriptionResponse.message?.text;
|
||||
if (description) {
|
||||
ctx.session.problemDescription = description;
|
||||
}
|
||||
}
|
||||
else {
|
||||
await descriptionResponse.answerCallbackQuery();
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.reply('👍 Что вам нравится в нашем кофейном аппарате больше всего?');
|
||||
const likesMsg = await conversation.wait();
|
||||
ctx.session.likes = likesMsg.message?.text || 'Не указано';
|
||||
|
||||
await ctx.reply(
|
||||
'💡 Что бы вы хотели изменить или добавить?\n(Новые напитки, закуски, способы оплаты и т.д.)',
|
||||
);
|
||||
const suggestionsMsg = await conversation.wait();
|
||||
ctx.session.suggestions = suggestionsMsg.message?.text || 'Не указано';
|
||||
|
||||
await ctx.reply('🔄 Как часто вы пользуетесь нашими аппаратами?', {
|
||||
reply_markup: frequencyKeyboard,
|
||||
});
|
||||
|
||||
const frequencyResponse = await conversation.wait();
|
||||
ctx.session.frequency = frequencyResponse.callbackQuery?.data?.split('_')[1] || '';
|
||||
await frequencyResponse.answerCallbackQuery();
|
||||
|
||||
await ctx.reply(
|
||||
'🤝 Порекомендовали бы вы наш кофейный аппарат друзьям или коллегам?\n(От 0 до 10, где 10 — "точно да")',
|
||||
{ reply_markup: npsKeyboard },
|
||||
);
|
||||
|
||||
const npsResponse = await conversation.wait();
|
||||
ctx.session.nps = Number.parseInt(npsResponse.callbackQuery?.data?.split('_')[1] || '0');
|
||||
await npsResponse.answerCallbackQuery();
|
||||
|
||||
await ctx.reply(
|
||||
'📞 Если вы хотите, чтобы мы с вами связались по вашему отзыву, '
|
||||
+ 'оставьте, пожалуйста, ваш контакт.',
|
||||
{ reply_markup: skipKeyboard },
|
||||
);
|
||||
const contactResponse = await conversation.wait();
|
||||
|
||||
if (contactResponse.callbackQuery?.data !== 'skip') {
|
||||
const contact = contactResponse.message?.text;
|
||||
if (contact) {
|
||||
ctx.session.contact = contact;
|
||||
}
|
||||
}
|
||||
else {
|
||||
await contactResponse.answerCallbackQuery();
|
||||
}
|
||||
|
||||
saveFeedback(ctx.session);
|
||||
|
||||
await ctx.reply(
|
||||
'🎉 Искренне благодарим вас за уделенное время и ценные комментарии!\n\n'
|
||||
+ 'Мы их обязательно изучим.\n\n'
|
||||
+ 'Спасибо, что помогаете нам стать лучше! ☕️',
|
||||
);
|
||||
|
||||
ctx.session = {};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { SessionData } from './types';
|
||||
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const db = new Database(join(process.cwd(), 'feedback.db'));
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
location TEXT,
|
||||
product TEXT,
|
||||
rating INTEGER,
|
||||
problem_category TEXT,
|
||||
problem_details TEXT,
|
||||
problem_description TEXT,
|
||||
likes TEXT,
|
||||
suggestions TEXT,
|
||||
frequency TEXT,
|
||||
nps INTEGER,
|
||||
contact TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
export function saveFeedback(data: SessionData): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO feedback (
|
||||
location, product, rating, problem_category, problem_details,
|
||||
problem_description, likes, suggestions, frequency, nps, contact
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
data.location || null,
|
||||
data.product || null,
|
||||
data.rating || null,
|
||||
data.problemCategory || null,
|
||||
data.problemDetails || null,
|
||||
data.problemDescription || null,
|
||||
data.likes || null,
|
||||
data.suggestions || null,
|
||||
data.frequency || null,
|
||||
data.nps || null,
|
||||
data.contact || null,
|
||||
);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error saving feedback to database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
58
src/index.ts
58
src/index.ts
@@ -1,42 +1,34 @@
|
||||
import type { MyContext, SessionData } from './types';
|
||||
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { conversations, createConversation } from '@grammyjs/conversations';
|
||||
import { Bot, session } from 'grammy';
|
||||
import { createBot } from './bot';
|
||||
import { loadConfig } from './config';
|
||||
import { openDB } from './stores/db';
|
||||
import { FeedbackStore } from './stores/feedback';
|
||||
import { SessionStore } from './stores/session';
|
||||
|
||||
import { feedbackConversation } from './conversation';
|
||||
import { mainMenuKeyboard } from './keyboards';
|
||||
async function main() {
|
||||
const cfg = loadConfig();
|
||||
const db = openDB(cfg.sqlitePath);
|
||||
const sessions = new SessionStore(db);
|
||||
const feedback = new FeedbackStore(db);
|
||||
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
if (!BOT_TOKEN)
|
||||
throw new Error('BOT_TOKEN is not defined in environment variables');
|
||||
const rootDir = path.resolve(process.cwd());
|
||||
const bot = createBot(cfg.botToken, rootDir, sessions, feedback, signal);
|
||||
|
||||
const bot = new Bot<MyContext>(BOT_TOKEN);
|
||||
process.on('SIGINT', () => controller.abort());
|
||||
process.on('SIGTERM', () => controller.abort());
|
||||
|
||||
bot.use(session({ initial: (): SessionData => ({}) }));
|
||||
bot.use(conversations());
|
||||
|
||||
bot.use(createConversation(feedbackConversation, 'feedbackConversation'));
|
||||
|
||||
bot.command('start', async (ctx) => {
|
||||
await ctx.reply(
|
||||
'☕️ Добро пожаловать в бот обратной связи!\n\n'
|
||||
+ 'Нажмите кнопку ниже, чтобы начать опрос.',
|
||||
{ reply_markup: mainMenuKeyboard },
|
||||
);
|
||||
await bot.launch();
|
||||
// Keep process alive until abort
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
bot.hears('📝 Оставить отзыв', async (ctx) => {
|
||||
await ctx.conversation.enter('feedbackConversation');
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
bot.on('callback_query:data', async (ctx) => {
|
||||
await ctx.answerCallbackQuery();
|
||||
});
|
||||
|
||||
bot.catch((err) => {
|
||||
console.error('Bot error:', err);
|
||||
});
|
||||
|
||||
bot.start();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { InlineKeyboard, Keyboard } from 'grammy';
|
||||
|
||||
export const mainMenuKeyboard = new Keyboard()
|
||||
.text('📝 Оставить отзыв')
|
||||
.resized();
|
||||
|
||||
export const ratingKeyboard = new InlineKeyboard()
|
||||
.text('1', 'rating_1')
|
||||
.text('2', 'rating_2')
|
||||
.text('3', 'rating_3')
|
||||
.text('4', 'rating_4')
|
||||
.text('5', 'rating_5')
|
||||
.row()
|
||||
.text('6', 'rating_6')
|
||||
.text('7', 'rating_7')
|
||||
.text('8', 'rating_8')
|
||||
.text('9', 'rating_9')
|
||||
.text('10', 'rating_10');
|
||||
|
||||
export const problemKeyboard = new InlineKeyboard()
|
||||
.text('🥤 Проблема с напитком', 'problem_drink')
|
||||
.row()
|
||||
.text('🤖 Проблема с аппаратом', 'problem_machine')
|
||||
.row()
|
||||
.text('💳 Проблема с оплатой', 'problem_payment')
|
||||
.row()
|
||||
.text('🍰 Ассортимент', 'problem_assortment')
|
||||
.row()
|
||||
.text('📝 Другое', 'problem_other');
|
||||
|
||||
export const drinkProblemKeyboard = new InlineKeyboard()
|
||||
.text('👅 Вкус', 'drink_taste')
|
||||
.text('🌡 Температура', 'drink_temp')
|
||||
.row()
|
||||
.text('💪 Крепость', 'drink_strength')
|
||||
.text('📏 Объем', 'drink_volume')
|
||||
.row()
|
||||
.text('🧼 Чистота кружки', 'drink_cup');
|
||||
|
||||
export const machineProblemKeyboard = new InlineKeyboard()
|
||||
.text('📦 Закончились ингредиенты', 'machine_empty')
|
||||
.row()
|
||||
.text('🧹 Аппарат грязный', 'machine_dirty')
|
||||
.row()
|
||||
.text('❌ Аппарат не работал', 'machine_broken')
|
||||
.row()
|
||||
.text('💰 Не выдал сдачу', 'machine_change');
|
||||
|
||||
export const frequencyKeyboard = new InlineKeyboard()
|
||||
.text('🆕 Первый раз', 'freq_first')
|
||||
.row()
|
||||
.text('📅 Несколько раз в неделю', 'freq_several')
|
||||
.row()
|
||||
.text('🗓 Раз в неделю', 'freq_weekly')
|
||||
.row()
|
||||
.text('🌙 Реже', 'freq_rare');
|
||||
|
||||
export const npsKeyboard = new InlineKeyboard()
|
||||
.text('0', 'nps_0')
|
||||
.text('1', 'nps_1')
|
||||
.text('2', 'nps_2')
|
||||
.text('3', 'nps_3')
|
||||
.row()
|
||||
.text('4', 'nps_4')
|
||||
.text('5', 'nps_5')
|
||||
.text('6', 'nps_6')
|
||||
.text('7', 'nps_7')
|
||||
.row()
|
||||
.text('8', 'nps_8')
|
||||
.text('9', 'nps_9')
|
||||
.text('10', 'nps_10');
|
||||
|
||||
export const skipKeyboard = new InlineKeyboard()
|
||||
.text('⏭ Пропустить', 'skip');
|
||||
66
src/quest/adapter.ts
Normal file
66
src/quest/adapter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { EndingNode, InitialNode, OptionChoice, QuestInterpreter } from '@robonen/questlang';
|
||||
import path from 'node:path';
|
||||
import { QuestLang } from '@robonen/questlang';
|
||||
import { NodeFileModuleHost } from './node-host';
|
||||
|
||||
export interface Render {
|
||||
text: string;
|
||||
choices: { idx: number; text: string }[];
|
||||
}
|
||||
|
||||
export class QuestAdapter {
|
||||
private interpreter: QuestInterpreter;
|
||||
private filePath: string;
|
||||
private host: NodeFileModuleHost;
|
||||
|
||||
constructor(scenariosRoot: string, entryFile = 'feedback.ql') {
|
||||
this.filePath = path.resolve(scenariosRoot, entryFile);
|
||||
this.host = new NodeFileModuleHost(scenariosRoot);
|
||||
const source = this.host.readFile(this.filePath);
|
||||
this.interpreter = QuestLang.interpret(source, this.filePath, this.host);
|
||||
// auto-advance if initial node has transition
|
||||
const current = this.interpreter.getCurrentNode();
|
||||
if (current?.nodeType === 'начальный') {
|
||||
const transitions = (current as InitialNode).transitions;
|
||||
if (transitions?.length)
|
||||
this.interpreter.moveToNode(transitions[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
getTextAndChoices(): Render {
|
||||
const node = this.interpreter.getCurrentNode();
|
||||
const info = this.interpreter.getQuestInfo();
|
||||
const header = `☕️ ${info.name}`;
|
||||
if (!node)
|
||||
return { text: `${header}\nОшибка: текущий узел не найден`, 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 };
|
||||
}
|
||||
|
||||
if (node.nodeType === 'концовка') {
|
||||
const endingNode = node as EndingNode;
|
||||
return { text: `${header}\n\n🏁 ${endingNode.title}\n${endingNode.description}`, choices: [] };
|
||||
}
|
||||
|
||||
return { text: `${header}\n\n${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 };
|
||||
}
|
||||
|
||||
moveTo(nodeId: string): void {
|
||||
const r = this.interpreter.moveToNode(nodeId);
|
||||
if (!r.success)
|
||||
throw new Error(r.error || 'Переход не выполнен');
|
||||
}
|
||||
|
||||
getCurrentNodeId(): string {
|
||||
return this.interpreter.getState().currentNode;
|
||||
}
|
||||
}
|
||||
16
src/quest/node-host.ts
Normal file
16
src/quest/node-host.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ModuleHost } from '@robonen/questlang';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export class NodeFileModuleHost implements ModuleHost {
|
||||
constructor(private rootDir: string) {}
|
||||
|
||||
readFile(file: string): string {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
resolve(fromFile: string, specifier: string): string {
|
||||
const base = path.isAbsolute(fromFile) ? fromFile : path.resolve(this.rootDir, fromFile);
|
||||
return path.resolve(path.dirname(base), specifier);
|
||||
}
|
||||
}
|
||||
34
src/stores/db.ts
Normal file
34
src/stores/db.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export type DB = ReturnType<typeof openDB>;
|
||||
|
||||
export function ensureDir(filePath: string): void {
|
||||
const dir = path.dirname(path.resolve(filePath));
|
||||
if (!fs.existsSync(dir))
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function openDB(sqlitePath: string) {
|
||||
ensureDir(sqlitePath);
|
||||
const db = new DatabaseSync(sqlitePath);
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
current_node TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
choice_text TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
return { db };
|
||||
}
|
||||
10
src/stores/feedback.ts
Normal file
10
src/stores/feedback.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { DB } from './db';
|
||||
|
||||
export class FeedbackStore {
|
||||
constructor(private db: DB) {}
|
||||
|
||||
addEvent(chatId: string, nodeId: string, choiceText: string): void {
|
||||
const stmt = this.db.db.prepare('INSERT INTO events(chat_id, node_id, choice_text, created_at) VALUES (?, ?, ?, ?)');
|
||||
stmt.run(chatId, nodeId, choiceText, Date.now());
|
||||
}
|
||||
}
|
||||
31
src/stores/session.ts
Normal file
31
src/stores/session.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { DB } from './db';
|
||||
|
||||
export interface Session {
|
||||
chatId: string;
|
||||
currentNode: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
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 row = stmt.get(chatId) as any | undefined;
|
||||
if (!row)
|
||||
return null;
|
||||
return { chatId: row.chat_id, 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',
|
||||
);
|
||||
stmt.run(session.chatId, session.currentNode, session.updatedAt);
|
||||
}
|
||||
|
||||
delete(chatId: string): void {
|
||||
const stmt = this.db.db.prepare('DELETE FROM sessions WHERE chat_id = ?');
|
||||
stmt.run(chatId);
|
||||
}
|
||||
}
|
||||
82
src/telegram/handlers.ts
Normal file
82
src/telegram/handlers.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Context } from 'telegraf';
|
||||
import type { FeedbackStore } from '../stores/feedback';
|
||||
import type { SessionStore } from '../stores/session';
|
||||
import path from 'node:path';
|
||||
import { QuestAdapter } from '../quest/adapter';
|
||||
import { buildChoicesKeyboard } from './keyboards';
|
||||
|
||||
export function onCallback(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:'))
|
||||
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 sess = sessions.get(chatId);
|
||||
if (sess)
|
||||
adapter.moveTo(sess.currentNode);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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)
|
||||
return;
|
||||
|
||||
const adapter = new QuestAdapter(path.join(rootDir, 'scenarios'));
|
||||
const render = adapter.getTextAndChoices();
|
||||
|
||||
await ctx.reply(render.text, render.choices.length ? buildChoicesKeyboard(render.choices) : undefined);
|
||||
|
||||
// persist session
|
||||
sessions.upsert({ chatId, currentNode: adapter.getCurrentNodeId(), updatedAt: Date.now() });
|
||||
};
|
||||
}
|
||||
9
src/telegram/keyboards.ts
Normal file
9
src/telegram/keyboards.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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}`));
|
||||
// chunk into rows of 2
|
||||
const rows: ReturnType<typeof Markup.button.callback>[][] = [];
|
||||
for (let i = 0; i < buttons.length; i += 2) rows.push(buttons.slice(i, i + 2));
|
||||
return Markup.inlineKeyboard(rows);
|
||||
}
|
||||
19
src/types.ts
19
src/types.ts
@@ -1,19 +0,0 @@
|
||||
import type { Conversation, ConversationFlavor } from '@grammyjs/conversations';
|
||||
import type { Context, SessionFlavor } from 'grammy';
|
||||
|
||||
export interface SessionData {
|
||||
location?: string;
|
||||
product?: string;
|
||||
rating?: number;
|
||||
problemCategory?: string;
|
||||
problemDetails?: string;
|
||||
problemDescription?: string;
|
||||
likes?: string;
|
||||
suggestions?: string;
|
||||
frequency?: string;
|
||||
nps?: number;
|
||||
contact?: string;
|
||||
}
|
||||
|
||||
export type MyContext = Context & SessionFlavor<SessionData> & ConversationFlavor<Context & SessionFlavor<SessionData>>;
|
||||
export type MyConversation = Conversation<MyContext, MyContext>;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: 'src/index.ts',
|
||||
dts: false,
|
||||
platform: 'node',
|
||||
noExternal: ['telegraf', '@robonen/questlang'],
|
||||
minify: false,
|
||||
treeshake: true,
|
||||
noExternal: ['better-sqlite3', 'grammy', '@grammyjs/conversations'],
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user