workshop day 1
English Practice Bot - День 1
Сегодня сами с нуля пишем Telegram-бота, который учит английским словам: показывает карточки apple → яблоко и устраивает квиз с кнопками. Завтра подключим базу данных, чтобы прогресс сохранялся между запусками.
Правила воркшопа
Это не лекция. Каждый код-блок ты вбиваешь руками в свой файл и сразу проверяешь в Telegram. Если что-то не работает - стоп, чиним, и только потом дальше.
| Что делаем | Что НЕ делаем |
|---|---|
| Создаём свою папку и свой `bot.js` с нуля | Не клонируем готовый проект |
| Копируем из урока маленькие куски (3–10 строк) | Не копируем огромные файлы целиком |
| После каждого куска проверяем в Telegram | Не ждём конца урока, чтобы запустить |
| Меняем английские слова, эмодзи, реакции | Не молчим, если не работает |
В уроке используем библиотеку grammY - готовый инструмент для Telegram-ботов. Наша цель не в том, чтобы вручную изучать все низкоуровневые HTTP-запросы Telegram, а в том, чтобы быстро собрать понятный учебный продукт: карточки слов, квиз и счёт.
Шаг 0: получаем токен у BotFather
Зачем: Telegram-бот - это программа на твоём компьютере, которая ходит в Telegram через интернет. Чтобы Telegram нас «узнавал», нужен токен - длинная секретная строка вида 123456:AAH....
Делаем:
- Открой Telegram, найди @BotFather (синяя галочка).
- Жми
/start, потом/newbot. - Имя - любое (например,
Aruzhan English Bot). - Username - должен заканчиваться на
bot(например,aruzhan_english_bot). - BotFather пришлёт токен. Скопируй его - понадобится через минуту.
Шаг 1: создаём проект
1.1. Папка и package.json
mkdir my-bot
cd my-bot
npm init -y
npm init -y создаёт package.json - паспорт проекта.
1.2. Открой package.json и добавь "type": "module"
{
"name": "my-bot",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node bot.js"
}
}
"type": "module" говорит Node, что мы пишем на современном JS (с import).
1.3. Устанавливаем grammY
Перед командой важно понять, что мы ставим.
npm install grammy
Эта команда делает две вещи:
- скачивает grammY в папку
node_modules; - записывает grammY в
package.json, чтобы проект помнил: «мне нужна эта библиотека».
1.4. Создай bot.js и .env
touch bot.js
touch .env
echo ".env" > .gitignore
1.5. Положи токен в .env
TELEGRAM_BOT_TOKEN=123456:вставь_свой_токен_сюда
Шаг 2: первый «Hello»
2.1. Минимальный бот
Сейчас будет первый настоящий код. Не воспринимай его как магию: здесь всего 5 ролей.
| Часть кода | Роль |
|---|---|
| `import { Bot } from "grammy"` | Берём класс `Bot` из библиотеки grammY |
| `readFileSync(".env", "utf8")` | Читаем файл с секретным токеном |
| `const token = ...` | Достаём из `.env` только значение после `TELEGRAM_BOT_TOKEN=` |
| `new Bot(token)` | Создаём объект бота, который умеет говорить с Telegram |
| `bot.command("start", ...)` | { "Говорим": "если пользователь написал `/start`, ответь `Hello! 👋`" } |
| `bot.start()` | Запускаем постоянное ожидание сообщений |
Открой bot.js и впиши:
// Берём главный класс Bot из библиотеки grammY
import { Bot } from "grammy";
// Берём функцию, чтобы прочитать файл .env с токеном
import { readFileSync } from "node:fs";
// Читаем весь файл .env как обычный текст
const env = readFileSync(".env", "utf8");
// Ищем строку TELEGRAM_BOT_TOKEN=..., берём только значение после "="
const token = env.split("\n")
.find((l) => l.startsWith("TELEGRAM_BOT_TOKEN="))
?.split("=")[1]?.trim();
// Создаём бота: теперь наш код умеет говорить с Telegram
const bot = new Bot(token);
// Если пользователь написал /start, отвечаем Hello
bot.command("start", (ctx) => ctx.reply("Hello! 👋"));
// Запускаем бота: он начинает ждать сообщения из Telegram
bot.start();
Главная идея: мы не пишем весь Telegram API сами. Мы описываем реакции бота, а grammY связывает эти реакции с Telegram.
2.2. Запусти
npm start
Открой Telegram, найди своего бота, напиши /start.
Шаг 3: команда /help
3.1. Добавь после bot.command("start", ...)
bot.command("help", (ctx) => // ← новое
ctx.reply( // ← новое
"/start - say hi\n" + // ← новое
"/help - this list", // ← новое
), // ← новое
); // ← новое
Перезапусти (Ctrl+C, потом npm start), напиши боту /help.
3.2. Приветствие по имени
Замени bot.command("start", ...) на:
bot.command("start", (ctx) => {
const name = ctx.from?.first_name || "friend"; // ← новое
ctx.reply(`Hello, ${name}! 👋`); // ← изменилось
});
Шаг 4: словарь EN → RU
Проблема
Бот пока умеет только здороваться. Хотим, чтобы он знал английские слова и их перевод.
Решение: объект-словарь
4.1. Добавь объект в самом верху bot.js (после import)
// dict — это наш мини-словарь: английское слово → русский перевод
const dict = {
apple: "яблоко",
book: "книга",
cat: "кот",
dog: "собака",
house: "дом",
};
4.2. Команда /word - случайное слово
Добавь после bot.command("help", ...):
bot.command("word", (ctx) => {
// Берём все английские слова из словаря: ["apple", "book", ...]
const words = Object.keys(dict); // ["apple","book",...]
// Выбираем случайное слово из массива words
const w = words[Math.floor(Math.random() * words.length)];
// Отправляем слово и перевод. dict[w] достаёт перевод по ключу w
ctx.reply(`📖 *${w}* — ${dict[w]}`, { parse_mode: "Markdown" });
});
Перезапусти, напиши /word несколько раз.
🎯 Remix
- Добавь в
dictсвои 5 слов (любая тема: еда, школа, спорт). Перезапусти бота, прогони/wordнесколько раз - твои слова должны выпадать. - Подумай: что вернёт
dict["banana"], если такого ключа нет? Проверь, добавивconsole.log(dict["banana"])в началоbot.command("word", ...). - А если бот должен принимать слова от пользователя (
/add apple яблоко) - где их хранить, чтобы они не исчезли при перезапуске? Запомни этот вопрос: к нему вернёмся в Day 2.
Шаг 5: квиз с кнопками
Проблема
Хотим проверять студента, а не только показывать слова. План: бот пишет английское слово, под ним 3 кнопки-варианта перевода. Жмёшь правильный - 🎉, неправильный - 💀.
5.1. Импорт InlineKeyboard
Дополни первую строку:
import { Bot, InlineKeyboard } from "grammy"; // ← добавили InlineKeyboard
5.2. Команда /quiz
Добавь после bot.command("word", ...):
bot.command("quiz", (ctx) => {
// Все английские слова из нашего словаря
const words = Object.keys(dict);
// Случайное слово, которое будет правильным ответом
const correct = words[Math.floor(Math.random() * words.length)];
// 2 случайных «неправильных» варианта
const wrong = words
.filter((w) => w !== correct)
.sort(() => Math.random() - 0.5)
.slice(0, 2);
// Перемешиваем 3 варианта
const options = [correct, ...wrong].sort(() => Math.random() - 0.5);
// Клавиатура: 3 кнопки в столбик
const kb = new InlineKeyboard();
for (const opt of options) {
// Если opt — правильное слово, прячем тег ok, иначе no
const tag = opt === correct ? "ok" : "no";
// На кнопке показываем перевод, а внутрь кладём скрытый callback_data
kb.text(dict[opt], `quiz:${tag}:${correct}`).row();
}
// Отправляем вопрос и прикрепляем клавиатуру
ctx.reply(`How do you say *${correct}* in Russian?`, {
parse_mode: "Markdown",
reply_markup: kb,
});
});
Перезапусти, отправь /quiz.
Шаг 6: обработка нажатий
6.1. Сначала - мини-теория: async/await
Скоро напишем await ctx.answerCallbackQuery(...). Что такое await и зачем async перед функцией?
// Без await
for (const step of steps) {
doStep(step); // fire and forget
}
console.log("done"); // печатается СРАЗУ// С await
for (const step of steps) {
await doStep(step); // ждём этого шага
}
console.log("done"); // печатается ПОСЛЕДНИМОбрати внимание: слева «done» появляется не по порядку — шаги бегут параллельно. Справа всё строго по очереди, как мы написали.
Что показывает анимация:
| Без await | С await |
|---|---|
| Все шаги стартуют **одновременно** | Шаги идут **по очереди** |
| `done` появляется не по порядку | `done` появляется строго в конце |
| Если следующая строка зависит от ответа - получим `undefined` | Каждый шаг успевает закончиться до следующего |
Простое правило:
- любой запрос в интернет (
ctx.reply,ctx.answerCallbackQuery,fetch) занимает 200мс - 2с - для компьютера это «долго»; awaitставит функцию на паузу, пока ответ не придёт;- если внутри функции есть
await- сама функция должна бытьasync. Поэтому ниже мы пишемasync (ctx) => {...}.
6.2. Слушаем нажатия кнопок
Добавь после bot.command("quiz", ...):
bot.on("callback_query:data", async (ctx) => {
// Telegram прислал скрытый код из кнопки, например "quiz:ok:apple"
const data = ctx.callbackQuery.data; // "quiz:ok:apple"
// Режем строку по ":" и раскладываем части по переменным
const [type, result, word] = data.split(":"); // ["quiz", "ok", "apple"]
// Если это не наш квиз — ничего не делаем
if (type !== "quiz") return; // не наш callback - игнор
// Говорим Telegram: «я получил нажатие, убери загрузку с кнопки»
await ctx.answerCallbackQuery(); // обязательный ack Telegram
if (result === "ok") {
// result === "ok" значит: ученик нажал правильный вариант
await ctx.reply(`🎉 Right! *${word}* = ${dict[word]}`,
{ parse_mode: "Markdown" });
} else {
// Иначе вариант неправильный, но всё равно показываем правильный перевод
await ctx.reply(`💀 Not quite. *${word}* = ${dict[word]}`,
{ parse_mode: "Markdown" });
}
});
Перезапусти, отправь /quiz, потыкай кнопки.
Шаг 7: счётчик очков
Проблема
Сейчас бот хвалит и ругает, но не помнит результаты. Хотим, чтобы у каждого игрока был счёт.
7.1. Память для очков
В верху файла, рядом с const dict = {...}:
const scores = new Map(); // id игрока → правильных ответов
7.2. Прибавляем очко при правильном ответе
В блоке if (result === "ok"), в самом начале добавь одну строку:
if (result === "ok") {
// id — уникальный номер пользователя Telegram
const id = ctx.from.id; // ← новое
// Берём старые очки или 0, прибавляем 1 и сохраняем обратно
scores.set(id, (scores.get(id) || 0) + 1); // ← новое
await ctx.reply(`🎉 Right! *${word}* = ${dict[word]}`,
{ parse_mode: "Markdown" });
// ... остальное без изменений
}
7.3. Команда /score
Добавь после bot.on("callback_query:data", ...):
bot.command("score", (ctx) => {
// Если очков ещё нет, показываем 0
const pts = scores.get(ctx.from.id) || 0;
// Отправляем текущий счёт игроку
ctx.reply(`Your score: ${pts}`);
});
7.4. Финально обновляем /help
В начале урока /help знал только две команды. Теперь бот умеет больше, поэтому замени старый bot.command("help", ...) на финальную версию:
bot.command("help", (ctx) =>
ctx.reply(
"/start - say hi\n" +
"/help - this list\n" +
"/word - random English word with translation\n" +
"/quiz - test your translation skills\n" +
"/score - see your points",
),
);
Теперь готовый файл и урок совпадают: в финальном боте /help показывает все команды, а не только первые две.
Перезапусти, сыграй несколько раундов /quiz, потом /score.
7.5. Главный момент дня: ломаем игру
Останови бота через Ctrl+C. Запусти снова: npm start. Сделай /score.
Что произошло? Очки исчезли. Все.
| Где живут данные | Что после Ctrl+C |
|---|---|
| В переменной `scores` (память процесса) | Стирается всё, очки = 0 |
| В файле на диске | Сохраняется, но трудно искать и менять одновременно |
| В **базе данных** | Сохраняется + есть язык запросов + работает с многими игроками |
Это и есть проблема, ради которой завтра нам понадобится база данных. А сейчас - небольшая теория, чтобы переспать с этой идеей.
База данных: журнал учителя
Забудем на минуту про код. Представь: учитель ведёт бумажный журнал оценок.
| Имя | Предмет | Оценка | Дата |
|---|---|---|---|
| Aruzhan | English | 5 | 2026-04-12 |
| Dias | English | 4 | 2026-04-12 |
| Amina | Math | 5 | 2026-04-12 |
| Aruzhan | Math | 3 | 2026-04-13 |
Что плохо в бумажном журнале?
- потеряли тетрадь - потеряли данные;
- найти «всех с 5 по понедельникам» - листать вручную полчаса;
- два учителя одновременно править одну страницу не могут;
- ошиблись в имени на 5-й странице - попробуй найти.
Excel-таблица решает часть проблем (поиск, фильтр). Но если 10 учителей правят один файл одновременно - получаем хаос.
База данных - это «общий умный Excel» с правилами и языком запросов. Несколько программ могут писать одновременно, и ничего не сломается.
Три слова, которых хватит на сегодня
| Слово | На бумажном языке | В нашем журнале |
|---|---|---|
| Table (таблица) | Лист тетради с одним типом записей | Лист «оценки» с колонками Имя/Предмет/Оценка/Дата |
| Row (строка) | Одна запись на этом листе | `Aruzhan, English, 5, 2026-04-12` |
| Column (колонка) | Один столбик, одно свойство | `name`, `subject`, `score`, `date` |
Сегодня этого хватит. Завтра в Day 2 будем говорить про связи между таблицами (players ↔ quiz_answers).
SQL: язык, на котором мы спрашиваем журнал
SQL (Structured Query Language) - короткий английский для разговора с таблицами:
| Команда | Что делает | По-русски |
|---|---|---|
| `SELECT` | Читает строки | Покажи |
| `INSERT` | Добавляет строку | Запиши |
| `UPDATE` | Меняет строки | Исправь |
| `DELETE` | Удаляет строки | Сотри |
Дальше - 6 живых карточек. Слева запрос, справа результат после нажатия ▶ Run. В двух карточках есть выпадашка - попробуй разные значения.
SELECT * FROM grades;
Нажми ▶ Run, чтобы выполнить запрос
`*` - «все колонки». Для начала это нормально, в больших таблицах лучше писать колонки по именам.
SELECT name, subject
FROM grades
WHERE score = 5;
Нажми ▶ Run, чтобы выполнить запрос
`WHERE` - фильтр. Поменяй значение в выпадашке слева и снова жми Run.
SELECT name, score, date
FROM grades
WHERE subject = 'English';
Нажми ▶ Run, чтобы выполнить запрос
Текстовые значения в SQL пишут в одинарных кавычках: `'English'`.
INSERT INTO grades (name, subject, score, date)
VALUES ('Dias', 'Math', 5, '2026-04-13');
Нажми ▶ Run, чтобы выполнить запрос
После этого `SELECT * FROM grades` покажет уже 5 строк.
UPDATE grades
SET score = 4
WHERE name = 'Aruzhan' AND subject = 'Math';
Нажми ▶ Run, чтобы выполнить запрос
Без `WHERE` команда поменяет ВСЕ строки. Это самая частая ошибка новичка.
DELETE FROM grades
WHERE date = '2026-04-12' AND subject = 'Math';
Нажми ▶ Run, чтобы выполнить запрос
С `DELETE` тоже легко выстрелить себе в ногу. Перед запуском часто пишут тот же `WHERE` через `SELECT`, чтобы посмотреть, что удалится.
Главное правило SQL: читай сверху вниз, как английский. SELECT name FROM grades WHERE score = 5 - «возьми имя из таблицы оценок где оценка равна 5».
Lab: пробуем SQL руками в Supabase
Supabase - бесплатный сервис, который даёт нам готовый PostgreSQL и SQL-редактор в браузере. Идеально для воркшопа: ничего устанавливать не нужно.
Шаги:
- Открой supabase.com, нажми Start your project, войди через GitHub или Google.
- Создай новый project (имя любое, регион -
Europe (Frankfurt)илиSingapore). Подожди 1-2 минуты. - Слева в меню найди SQL Editor.
Вставь этот блок целиком и нажми Run:
create table grades (
name text,
subject text,
score integer,
date date
);
insert into grades (name, subject, score, date) values
('Aruzhan', 'English', 5, '2026-04-12'),
('Dias', 'English', 4, '2026-04-12'),
('Amina', 'Math', 5, '2026-04-12'),
('Aruzhan', 'Math', 3, '2026-04-13');
select * from grades;
Внизу должна появиться таблица из 4 строк - точно как в карточках выше. Это и есть момент «база данных - не магия».
Теперь попробуй сам:
- найти всех, кто получил 5;
- добавить свою строку с собственным именем;
- обновить оценку у Aruzhan по Math с 3 на 5;
- удалить все записи за
2026-04-12.
После каждой команды делай select * from grades;, чтобы видеть состояние.
Что унести с собой в Day 2
Сегодня было много нового. Не нужно запоминать всё прямо сейчас. Перед сном проговори себе:
- Бот = программа, которая через grammY ходит в Telegram.
bot.command(...)ловит команды,bot.on("callback_query:data", ...)ловит нажатия inline-кнопок.- Inline-кнопки шлют скрытый
callback_data, не видимый текст. Мы режем эту строку черезsplit(":"). awaitнужен для всего, что ходит в интернет: ответ Telegram приходит не мгновенно.- Очки в
Mapисчезают послеCtrl+C- значит, нужна база данных. - База данных = таблица + правила + язык запросов SQL.
- SQL читается как английский:
select... from... where....
Завтра в Day 2:
- подключим Supabase прямо к боту;
- сохраним игроков, слова и ответы в реальные таблицы;
- сделаем
/leaderboard, который переживает перезапуск; - разберём связи между таблицами (
primary key/foreign key) - но уже на работающем боте.
Чекпоинты дня
Если все галочки стоят - день закрыт.
- Бот отвечает в Telegram на
/start,/help. /wordпоказывает случайное английское слово с переводом./quizприсылает 3 inline-кнопки, нажатия работают./scoreпоказывает количество правильных ответов.- После
Ctrl+Cиnpm startочки обнулились - вы это увидели и поняли почему. - В Supabase создан проект и таблица
grades, прогнаны все 4 SQL-команды. - Можешь объяснить разницу между inline-кнопкой и reply-кнопкой одним предложением.
Если что-то не работает - не добавляем новые фичи, чиним этот шаг.
Проверь себя
Зачем боту токен от BotFather?
Что такое `ctx` в `bot.command("start", (ctx) => ...)`?
Чем inline-кнопка отличается от reply-кнопки?
Что вернёт `"quiz:ok:apple".split(":")`?
Зачем нужно `await` перед `ctx.reply(...)`?
Где искать ответ, если непонятно, как работает `InlineKeyboard`?
Что вернёт `SELECT name FROM grades WHERE score = 5`?
Почему `/score` сбрасывается после Ctrl+C?