feat: init
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
BOT_TOKEN=your_telegram_bot_token_here
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
15
eslint.config.mjs
Normal file
15
eslint.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import antfu from '@antfu/eslint-config';
|
||||||
|
|
||||||
|
export default antfu(
|
||||||
|
{
|
||||||
|
stylistic: {
|
||||||
|
indent: 2,
|
||||||
|
semi: true,
|
||||||
|
quotes: 'single',
|
||||||
|
overrides: {
|
||||||
|
'style/comma-dangle': ['error', 'always-multiline'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typescript: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "maybe-coffee-bot",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Maybe Coffee Telegram bot for collecting feedback about vending coffee machines",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "^6.0.0",
|
||||||
|
"@robonen/tsconfig": "^0.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^24.9.1",
|
||||||
|
"eslint": "^9.38.0",
|
||||||
|
"tsdown": "^0.15.9",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
3663
pnpm-lock.yaml
generated
Normal file
3663
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
135
src/conversation.ts
Normal file
135
src/conversation.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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 = {};
|
||||||
|
}
|
||||||
58
src/database.ts
Normal file
58
src/database.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
42
src/index.ts
Normal file
42
src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { MyContext, SessionData } from './types';
|
||||||
|
|
||||||
|
import process from 'node:process';
|
||||||
|
import { conversations, createConversation } from '@grammyjs/conversations';
|
||||||
|
import { Bot, session } from 'grammy';
|
||||||
|
|
||||||
|
import { feedbackConversation } from './conversation';
|
||||||
|
import { mainMenuKeyboard } from './keyboards';
|
||||||
|
|
||||||
|
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||||
|
|
||||||
|
if (!BOT_TOKEN)
|
||||||
|
throw new Error('BOT_TOKEN is not defined in environment variables');
|
||||||
|
|
||||||
|
const bot = new Bot<MyContext>(BOT_TOKEN);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.hears('📝 Оставить отзыв', async (ctx) => {
|
||||||
|
await ctx.conversation.enter('feedbackConversation');
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on('callback_query:data', async (ctx) => {
|
||||||
|
await ctx.answerCallbackQuery();
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.catch((err) => {
|
||||||
|
console.error('Bot error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.start();
|
||||||
74
src/keyboards.ts
Normal file
74
src/keyboards.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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');
|
||||||
19
src/types.ts
Normal file
19
src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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>;
|
||||||
5
tsconfig.json
Normal file
5
tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
13
tsdown.config.ts
Normal file
13
tsdown.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
platform: 'node',
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
dts: false,
|
||||||
|
minify: false,
|
||||||
|
treeshake: true,
|
||||||
|
noExternal: ['better-sqlite3', 'grammy', '@grammyjs/conversations'],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user