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....

Делаем:

  1. Открой Telegram, найди @BotFather (синяя галочка).
  2. Жми /start, потом /newbot.
  3. Имя - любое (например, Aruzhan English Bot).
  4. Username - должен заканчиваться на bot (например, aruzhan_english_bot).
  5. BotFather пришлёт токен. Скопируй его - понадобится через минуту.

Шаг 1: создаём проект

1.1. Папка и package.json

bash
mkdir my-bot
cd my-bot
npm init -y

npm init -y создаёт package.json - паспорт проекта.

1.2. Открой package.json и добавь "type": "module"

json
{
  "name": "my-bot",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node bot.js"
  }
}

"type": "module" говорит Node, что мы пишем на современном JS (с import).

1.3. Устанавливаем grammY

Перед командой важно понять, что мы ставим.

bash
npm install grammy

Эта команда делает две вещи:

  1. скачивает grammY в папку node_modules;
  2. записывает grammY в package.json, чтобы проект помнил: «мне нужна эта библиотека».

1.4. Создай bot.js и .env

bash
touch bot.js
touch .env
echo ".env" > .gitignore

1.5. Положи токен в .env

bash
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 и впиши:

javascript
// Берём главный класс 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. Запусти

bash
npm start

Открой Telegram, найди своего бота, напиши /start.

Шаг 3: команда /help

3.1. Добавь после bot.command("start", ...)

javascript
bot.command("help", (ctx) =>            // ← новое
  ctx.reply(                             // ← новое
    "/start - say hi\n" +                // ← новое
    "/help - this list",                 // ← новое
  ),                                     // ← новое
);                                       // ← новое

Перезапусти (Ctrl+C, потом npm start), напиши боту /help.

3.2. Приветствие по имени

Замени bot.command("start", ...) на:

javascript
bot.command("start", (ctx) => {
const name = ctx.from?.first_name || "friend";   // ← новое
ctx.reply(`Hello, ${name}! 👋`);                  // ← изменилось
});

Шаг 4: словарь EN → RU

Проблема

Бот пока умеет только здороваться. Хотим, чтобы он знал английские слова и их перевод.

Решение: объект-словарь

4.1. Добавь объект в самом верху bot.js (после import)

javascript
// dict — это наш мини-словарь: английское слово → русский перевод
const dict = {
  apple: "яблоко",
  book:  "книга",
  cat:   "кот",
  dog:   "собака",
  house: "дом",
};

4.2. Команда /word - случайное слово

Добавь после bot.command("help", ...):

javascript
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

  1. Добавь в dict свои 5 слов (любая тема: еда, школа, спорт). Перезапусти бота, прогони /word несколько раз - твои слова должны выпадать.
  2. Подумай: что вернёт dict["banana"], если такого ключа нет? Проверь, добавив console.log(dict["banana"]) в начало bot.command("word", ...).
  3. А если бот должен принимать слова от пользователя (/add apple яблоко) - где их хранить, чтобы они не исчезли при перезапуске? Запомни этот вопрос: к нему вернёмся в Day 2.

Шаг 5: квиз с кнопками

Проблема

Хотим проверять студента, а не только показывать слова. План: бот пишет английское слово, под ним 3 кнопки-варианта перевода. Жмёшь правильный - 🎉, неправильный - 💀.

5.1. Импорт InlineKeyboard

Дополни первую строку:

javascript
import { Bot, InlineKeyboard } from "grammy";   // ← добавили InlineKeyboard

5.2. Команда /quiz

Добавь после bot.command("word", ...):

javascript
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 vs С await
Без await
// Без await
for (const step of steps) {
  doStep(step);          // fire and forget
}
console.log("done");      // печатается СРАЗУ
(пусто — нажми Запустить)
С await
// С 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", ...):

javascript
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 = {...}:

javascript
const scores = new Map();   // id игрока → правильных ответов

7.2. Прибавляем очко при правильном ответе

В блоке if (result === "ok"), в самом начале добавь одну строку:

javascript
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", ...):

javascript
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", ...) на финальную версию:

javascript
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
В файле на дискеСохраняется, но трудно искать и менять одновременно
В **базе данных**Сохраняется + есть язык запросов + работает с многими игроками

Это и есть проблема, ради которой завтра нам понадобится база данных. А сейчас - небольшая теория, чтобы переспать с этой идеей.

База данных: журнал учителя

Забудем на минуту про код. Представь: учитель ведёт бумажный журнал оценок.

ИмяПредметОценкаДата
AruzhanEnglish52026-04-12
DiasEnglish42026-04-12
AminaMath52026-04-12
AruzhanMath32026-04-13

Что плохо в бумажном журнале?

  1. потеряли тетрадь - потеряли данные;
  2. найти «всех с 5 по понедельникам» - листать вручную полчаса;
  3. два учителя одновременно править одну страницу не могут;
  4. ошиблись в имени на 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. В двух карточках есть выпадашка - попробуй разные значения.

1. Покажи всё, что есть в журнале
SQL psql →
SELECT * FROM grades;
Результат

Нажми ▶ Run, чтобы выполнить запрос

`*` - «все колонки». Для начала это нормально, в больших таблицах лучше писать колонки по именам.

2. Найти всех с заданной оценкой - попробуй разные значения!
SQL psql →
SELECT name, subject
FROM grades
WHERE score = 5;
Результат

Нажми ▶ Run, чтобы выполнить запрос

`WHERE` - фильтр. Поменяй значение в выпадашке слева и снова жми Run.

3. Только записи по выбранному предмету
SQL psql →
SELECT name, score, date
FROM grades
WHERE subject = 'English';
Результат

Нажми ▶ Run, чтобы выполнить запрос

Текстовые значения в SQL пишут в одинарных кавычках: `'English'`.

4. Записать новую оценку
SQL psql →
INSERT INTO grades (name, subject, score, date)
VALUES ('Dias', 'Math', 5, '2026-04-13');
Результат

Нажми ▶ Run, чтобы выполнить запрос

После этого `SELECT * FROM grades` покажет уже 5 строк.

5. Исправить ошибку в оценке
SQL psql →
UPDATE grades
SET score = 4
WHERE name = 'Aruzhan' AND subject = 'Math';
Результат

Нажми ▶ Run, чтобы выполнить запрос

Без `WHERE` команда поменяет ВСЕ строки. Это самая частая ошибка новичка.

6. Удалить старую запись
SQL psql →
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-редактор в браузере. Идеально для воркшопа: ничего устанавливать не нужно.

Шаги:

  1. Открой supabase.com, нажми Start your project, войди через GitHub или Google.
  2. Создай новый project (имя любое, регион - Europe (Frankfurt) или Singapore). Подожди 1-2 минуты.
  3. Слева в меню найди SQL Editor.

Вставь этот блок целиком и нажми Run:

sql
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 строк - точно как в карточках выше. Это и есть момент «база данных - не магия».

Теперь попробуй сам:

  1. найти всех, кто получил 5;
  2. добавить свою строку с собственным именем;
  3. обновить оценку у Aruzhan по Math с 3 на 5;
  4. удалить все записи за 2026-04-12.

После каждой команды делай select * from grades;, чтобы видеть состояние.

Что унести с собой в Day 2

Сегодня было много нового. Не нужно запоминать всё прямо сейчас. Перед сном проговори себе:

  1. Бот = программа, которая через grammY ходит в Telegram.
  2. bot.command(...) ловит команды, bot.on("callback_query:data", ...) ловит нажатия inline-кнопок.
  3. Inline-кнопки шлют скрытый callback_data, не видимый текст. Мы режем эту строку через split(":").
  4. await нужен для всего, что ходит в интернет: ответ Telegram приходит не мгновенно.
  5. Очки в Map исчезают после Ctrl+C - значит, нужна база данных.
  6. База данных = таблица + правила + язык запросов SQL.
  7. SQL читается как английский: select... from... where....

Завтра в Day 2:

  1. подключим Supabase прямо к боту;
  2. сохраним игроков, слова и ответы в реальные таблицы;
  3. сделаем /leaderboard, который переживает перезапуск;
  4. разберём связи между таблицами (primary key / foreign key) - но уже на работающем боте.

Чекпоинты дня

Если все галочки стоят - день закрыт.

  1. Бот отвечает в Telegram на /start, /help.
  2. /word показывает случайное английское слово с переводом.
  3. /quiz присылает 3 inline-кнопки, нажатия работают.
  4. /score показывает количество правильных ответов.
  5. После Ctrl+C и npm start очки обнулились - вы это увидели и поняли почему.
  6. В Supabase создан проект и таблица grades, прогнаны все 4 SQL-команды.
  7. Можешь объяснить разницу между 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?

© 2026 aqlacademy.kz