workshop day 2

English Practice Bot - День 2

Вчера бот умел показывать карточки и считать очки в памяти процесса — после Ctrl+C всё стиралось. Сегодня мы это чиним, учим бота разговаривать с другими серверами и добавляем AI: бот сможет слушать голос и смотреть фотографии — и сам пополнять словарь.

Разминка: вспомним вчера

Открой бот от Day 1, набери /quiz, заработай 2-3 очка, сделай /score.

Теперь останови бот (Ctrl+C) и запусти снова (npm start).

Набери /score.

v1 умеетv1 НЕ умеет
Показывать карточки и квизПомнить очки после restart
Считать очки в памятиХранить слова вместе с группой
Отвечать на командыРазговаривать с другими серверами
Работать прямо сейчасИспользовать AI

Цель дня: убрать все «НЕ умеет» из правой колонки.

Почему нужна база данных

ХранилищеПереживёт restart?Несколько пользователей?Поиск / сортировка?
`const scores = new Map()`❌ нет✅ (один процесс)❌ вручную
Файл (JSON)✅ да❌ конфликты записи❌ вручную
База данных (Supabase)✅ да✅ да✅ SQL

Назови 2 причины, почему база данных лучше Map для нашего бота.

⚡ HTTP за 5 минут — до того как писать код

Сейчас мы будем делать fetch(url, { headers: {...} }). Чтобы это не выглядело как магия, давай быстро разберём что происходит «под капотом».

🔍 Анатомия HTTP-запроса

Наведи на выделенный элемент — увидишь пояснение

← Запрос (Request)

GEThttps://abc.supabase.co/rest/v1/scores

Headers

apikey: eyJ...
Authorization: Bearer eyJ...
Accept: application/json
Нет тела — GET не отправляет данные

← Ответ (Response)

200 OK

Headers

Content-Type: application/json

Body (JSON)

[
  {
    "user_id": 1,
    "points": 7
  }
]

Это всё. Теперь когда увидишь fetch с заголовками — ты знаешь что это такое. Глубже разберём в части C.

Шаг A1: подключаем Supabase

A1.1. Создаём проект

  1. Открой supabase.comStart your project
  2. Создай новый проект (имя любое, регион — ближайший)
  3. Подожди ~1 минуту пока проект стартует
  4. Открой Settings → API
  5. Скопируй Project URL и anon public ключ

A1.2. Кладём ключи в .env

В папке твоего бота открой .env и добавь:

bash
SUPABASE_URL=https://твой-проект.supabase.co
SUPABASE_ANON_KEY=eyJhbGciO...длинная-строка

Шаг A2: первая таблица — scores

Открой SQL Editor в Supabase и запусти:

sql
create table if not exists scores (
  user_id    bigint primary key,
  user_name  text   not null,
  points     integer not null default 0,
  updated_at timestamptz not null default now()
);

Теперь потренируйся — выполни в SQL Editor:

sql
-- вставить тестовую строку
insert into scores (user_id, user_name, points) values (1, 'Test', 5);

-- прочитать
select * from scores;

-- обновить
update scores set points = 10 where user_id = 1;

-- удалить
delete from scores where user_id = 1;

PostgREST DSL — запросы через URL

Supabase использует PostgREST — сервер, который превращает REST-запросы в SQL. URL — это мини-язык запросов:

Пример: GET /scores?user_id=eq.42&select=points&order=created_at.desc&limit=5

SQL-эквивалент: SELECT points FROM scores WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5

В нашем db.js мы будем собирать такие строки вручную — поэтому важно понять эту логику.

Шаг A3: пишем db.js — мозг для работы с БД

Создай в папке src/ новый файл db.js. Это единственное место, где код будет знать про Supabase.

🍝 Спагетти✅ Чисто
`bot.command('score', async ctx => { const res = await fetch(SUPABASE_URL + ...); ... })``bot.command('score', async ctx => { const row = await dbSelect('scores', ...); ... })`
fetch внутри каждого handlerhandler зовёт функцию, функция зовёт fetch
Меняешь URL — правишь 10 местМеняешь URL — правишь 1 место (db.js)

Собираем файл по шагам — каждый подшаг делает одну понятную вещь.


Подшаг 1: читаем .env

Зачем: ключи Supabase нельзя хранить прямо в коде — если закоммитишь в git, ключ утечёт навсегда. .env — файл, который git игнорирует.

src/db.js
1 dotenv/config2 process.env3 Fail fast
...

Что сделали: import "dotenv/config" запускает dotenv, который читает .env и кладёт всё в process.env. Потом мы просто читаем переменные. Если переменная не найдена — process.exit(1) останавливает программу с понятной ошибкой сразу, а не через 5 минут с непонятным TypeError.

Почему мы проверяем `!SUPABASE_URL || !SUPABASE_ANON_KEY` сразу при загрузке файла?


Подшаг 2: собираем заголовки

Зачем: у каждого запроса к Supabase должны быть одни и те же три заголовка. Один объект — меньше дублирования.

src/db.js
1 apikey2 Authorization3 Content-Type
...

Что сделали: apikey — идентифицирует проект (Supabase знает, какая БД). Authorization: Bearer ... — идентифицирует роль (anon, authenticated и т.д.). Content-Type: application/json — говорит серверу, что тело запроса будет JSON.

Зачем в `HEADERS` два поля с ключом — `apikey` и `Authorization: Bearer ...`?


Подшаг 3: функция tableUrl() и класс HttpError

Зачем: URL таблицы всегда строится одинаково. HttpError добавляет HTTP-код к ошибке — позже сможем писать if (err.status === 409) вместо парсинга строк.

src/db.js
1 this.status2 tableUrl()
...

Для чего нужен класс `HttpError` вместо обычного `throw new Error(...)`?


Подшаг 4: пишем dbSelect()

Зачем: все команды бота читают данные. dbSelect() — единственное место, где происходит GET-запрос к Supabase.

src/db.js
1 query=*2 ...HEADERS3 HttpError
...

Три ключевых момента: — гибкий фильтр, — чистые заголовки без дублирования, — ошибки по коду, а не по тексту.

Что вернёт `dbSelect("scores")` если в таблице нет ни одной строки?


Подшаг 5: добавляем dbInsert и dbUpsert

Зачем: для записи данных нужен POST. Upsert — это «вставить или обновить» — нужен для scores (если пользователь уже есть, обновить имя, не создавать дубликат).

src/db.js
1 Prefer: return=representation2 rows[0]3 Upsert заголовки
...

Какая строка заставляет Supabase вернуть созданную запись в ответе на POST?

Шаг A4: очки живут в БД

Открой свой bot.js (или index.js). Найди строку:

javascript
const scores = new Map();

Удали её. Теперь заменим логику /score и callback-обработчик:

Старый /score********:

javascript
bot.command("score", (ctx) => {
  const pts = scores.get(ctx.from.id) ?? 0;
  ctx.reply(`Твои очки: ${pts}`);
});

Новый /score********:

javascript
import { dbSelect, dbUpsert } from "./db.js";

bot.command("score", async (ctx) => {
  const userId = ctx.from.id;
  const rows = await dbSelect("scores", `user_id=eq.${userId}&select=user_name,points`);
  if (!rows.length) return ctx.reply("У тебя пока 0 очков. Сыграй в /quiz!");
  ctx.reply(`🏆 ${rows[0].user_name}: ${rows[0].points} очков`);
});

Старый callback (выдача очка):

javascript
// scores.set(userId, (scores.get(userId) ?? 0) + 1);

Новый callback:

javascript
const rows = await dbSelect("scores", `user_id=eq.${userId}&select=points`);
const current = rows[0]?.points ?? 0;
await dbUpsert("scores", {
  user_id: userId,
  user_name: ctx.from.first_name || "Player",
  points: current + 1,
}, "user_id");

Почему мы пишем `rows[0]?.points ?? 0`, а не просто `rows[0].points`?

Шаг A5: /leaderboard — таблица рекордов

src/bot.js
1 Запрос2 .map()
...

Что нужно поменять в запросе чтобы `/leaderboard` показывал только тех, у кого больше 0 очков?

Шаг B1: словарь переезжает в БД

Запусти в SQL Editor Supabase:

sql
create table if not exists dictionary_words (
  id         bigint generated always as identity primary key,
  en         text not null,
  ru         text not null,
  added_by   bigint references scores(user_id),
  source     text not null default 'manual'
               check (source in ('manual', 'voice', 'photo')),
  created_at timestamptz not null default now(),
  unique (en)
);

Залей базовые слова из Day 1:

sql
insert into dictionary_words (en, ru, source) values
('apple', 'яблоко', 'manual'),
('book',  'книга',  'manual'),
('cat',   'кот',    'manual'),
('dog',   'собака', 'manual'),
('house', 'дом',    'manual')
on conflict (en) do nothing;

ERD: как таблицы связаны

Мы создали две таблицы. Давай посмотрим на них вместе — это называется Entity Relationship Diagram (ERD). ERD — это карта базы данных: кто есть кто, какие поля, как связаны.

Схема базы данных — English Practice Bot
🗄 scores
🔑user_id bigint
·user_name text
·points integer
·updated_at timestamptz
1Nдобавил слова
🗄 dictionary_words
🔑id bigint
·en text
·ru text
🔗added_by bigint
·source text
·created_at timestamptz
🔗 added_by → scores.user_id
🔑 Primary Key🔗 Foreign Key UNIQUE

Читаем ERD как предложение:

  • Один scores (пользователь) → много dictionary_words (слов). Один Bekzat может добавить сотни слов.
  • Каждое слово в dictionary_words знает кто его добавил через added_by → scores.user_id.
  • added_by может быть NULL — это слова из seed-файла, у них нет «автора».

Шаг B2: /word и /quiz из БД

Найди в bot.js старый const dict = {...} — удали его.

Новый /word:

javascript
bot.command("word", async (ctx) => {
  const words = await dbSelect("dictionary_words", "select=en,ru");
  if (!words.length) return ctx.reply("Словарь пуст! Добавь слово: /add sun солнце");
  const w = words[Math.floor(Math.random() * words.length)];
  ctx.reply(`📖 *${w.en}* — ${w.ru}`, { parse_mode: "Markdown" });
});

Обнови /quiz аналогично: вместо Object.keys(dict)await dbSelect("dictionary_words", "select=en,ru").

Шаг B3: /add — добавить слово вручную

Проход 1: базовая версия — добавляем слово по счастливому пути

Зачем: сначала делаем так, чтобы команда работала нормально — без обработки крайних случаев. Потом усилим защиту.

src/bot.js
1 ctx.match2 indexOf3 slice4 /^[a-z]+$/
...

Что сделали: text.indexOf(" ") находит первый пробел. slice(0, firstSpace) берёт всё до него — английское слово. slice(firstSpace + 1) берёт всё после — русский перевод. Регулярное выражение /^[a-z]+$/ проверяет, что первое слово — только латиница.

Что вернёт `text.indexOf(" ")` если пользователь написал просто `/add`?


Проход 2: защита от ошибок — делаем /add надёжным

Сейчас команда упадёт с uncaught error если слово уже есть, или если пользователь новый и его нет в scores.

Проблема 1: новый пользователь пишет /add не сыграв /quiz → FK constraint падает с 409. Решение: делаем dbUpsert пользователя в scores перед вставкой слова.

Проблема 2: слово уже есть в словаре → unique constraint → 409. Нужно поймать и показать понятное сообщение.

Оборачиваем логику вставки в try/catch:

src/bot.js
1 FK guard2 pgCode3 23505
...

Зачем делать `dbUpsert("scores", ...)` перед `dbInsert("dictionary_words", ...)`?

Часть C: HTTP — язык интернета

HTTP — это протокол: набор правил, по которым компьютеры разговаривают через интернет. Каждый раз, когда ты открываешь сайт — твой браузер делает HTTP-запрос.

МетодСмыслМы уже использовали в db.js
GETПолучить данные. Ничего не меняет.`dbSelect()` — читаем строки
POSTСоздать новую запись.`dbInsert()` — добавляем слово
PATCHОбновить часть записи (не всю).`dbUpdate()` — меняем очки
DELETEУдалить запись.`dbDelete()` — удаляем слово
Код статусаЗначениеБытовой аналог
200 OKВсё хорошо, вот данныеЗаказ доставлен
201 CreatedЗапись созданаПосылка отправлена
400 Bad RequestТы прислал кривые данныеНеправильный адрес доставки
401 UnauthorizedНет ключа — нет доступаНет билета — нет входа
404 Not FoundРесурс не существуетТакого товара нет в магазине
500 Internal Server ErrorСервер сломался на своей сторонеКасса не работает

Попробуй все методы — отправь реальный запрос:

HTTP Методы — попробуй сам

https://jsonplaceholder.typicode.com

Запрос

GEThttps://jsonplaceholder.typicode.com/posts/1

Нажми «Отправить запрос» — браузер сделает настоящий HTTP-запрос к серверу и вернёт реальный ответ.

Шаг C2: API — как серверы договариваются

Ты уже знаешь HTTP-методы. Но у публичных API есть ещё один пласт — аутентификация. Без неё сервер не знает, кто ты и можно ли тебе давать данные.

Способы аутентификации — как сервер проверяет «кто ты»:

🔑 Способы аутентификации в API

Нажми на карточку — узнай подробности

💡 В нашем боте мы используем API Key (Supabase anon key) и Bearer Token (OpenAI). Они часто идут вместе — Supabase требует оба в одном запросе.

Шаг C3: разбираем fetch() по косточкам

Смотри на готовую функцию dbInsert из db.js:

src/db.js
1 method: POST2 ...HEADERS3 JSON.stringify4 await res.json()
...

Теперь структура db.js должна читаться как обычный текст: «пойди по URL, методом POST, с этими заголовками, отправь эти данные, жди ответ».

Шаг C3: /weather — первый внешний API

Создай файл src/weather.js. Собираем его по шагам.


Подшаг 1: геокодинг — город → координаты

Зачем: Open-Meteo принимает latitude и longitude, а не название города. Nominatim (OpenStreetMap) бесплатно переводит «Almaty» в координаты.

javascript
export async function getWeather(city) {
  if (!city?.trim()) throw new Error("Укажи город: /weather Almaty");

  // Nominatim: город → координаты (широта/долгота)
  const geoUrl = "https://nominatim.openstreetmap.org/search?" +
    new URLSearchParams({ q: city, format: "json", limit: "1" });

  const geoRes = await fetch(geoUrl, {
    headers: { "User-Agent": "EnglishPracticeBot/2.0 (educational)" },
  });
  const geoData = await geoRes.json();
  if (!geoData.length) throw new Error(`Город "${city}" не найден`);

  const { lat, lon, display_name } = geoData[0];
  console.log(`📍 ${city} → lat=${lat}, lon=${lon}`);

  // ... продолжение в следующем подшаге
}

Зачем нужен геокодинг (Nominatim) если мы хотим погоду?


Подшаг 2: погода — координаты → температура

Зачем: передаём lat и lon в Open-Meteo и получаем текущую температуру и код погоды.

javascript
// Open-Meteo: координаты → температура + код погоды
const weatherUrl = "https://api.open-meteo.com/v1/forecast?" +
  new URLSearchParams({
    latitude: lat,
    longitude: lon,
    current: "temperature_2m,weather_code",
    timezone: "auto",
  });

const weatherData = await (await fetch(weatherUrl)).json();
const temp = weatherData.current.temperature_2m;
const code = weatherData.current.weather_code;

const sign = temp >= 0 ? "+" : "";
const result = `*${display_name.split(",")[0]}:* ${sign}${Math.round(temp)}°C (код: ${code})`;
return result;

Что произойдёт если убрать `timezone: "auto"` из параметров Open-Meteo?


Подшаг 3: эмодзи и кэш

Зачем: WMO weather codes — стандартные коды погоды (0=ясно, 95=гроза). Добавим эмодзи для красоты. Кэш — чтобы не спамить API при каждом сообщении.

Собираем всё вместе — полный src/weather.js:

src/weather.js
1 Кэш-проверка2 Nominatim3 Open-Meteo4 WMO коды5 cache.set
...

В bot.js добавь:

javascript
import { getWeather } from "./weather.js";

bot.command("weather", async (ctx) => {
const city = (ctx.match || "").trim();
if (!city) return ctx.reply("Укажи город: /weather Almaty");
try {
  const result = await getWeather(city);
  ctx.reply(result, { parse_mode: "Markdown" });
} catch (err) {
  ctx.reply(`❌ ${err.message}`);
}
});

Зачем `const cache = new Map()` объявлен вне функции `getWeather`, а не внутри?

Часть D: OpenAI API — AI в нашем боте

🔑 OPENAI_API_KEY

Установим официальный SDK:

bash
npm install openai
МодельЧто принимаетЧто возвращаетЦена (примерно)
whisper-1Аудиофайл (mp3/ogg/wav)Текст на любом языке$0.006 / мин
gpt-5.4-mini (text)Текстовый промптТекстовый ответ$0.00015 / 1K токенов
gpt-5.4-mini (vision)URL изображения + текстТекстовый ответ~$0.001 / фото

Создай src/ai.js. Добавляем три функции по одной — начнём с инициализации:

src/ai.js
1 dotenv/config2 Fail fast3 new OpenAI
...

Функция 1: transcribeAudio() — голос → текст

Зачем: Whisper — специализированная нейросеть для распознавания речи. Принимает аудиофайл, возвращает текст.

src/ai.js
1 Uint8Array2 language не указан3 .trim()
...

Почему не передаём language: "ru"? Если ученик скажет фразу по-английски, фиксированный language: "ru" переведёт всё в кириллицу — extractVocab получит не те слова.

Почему мы НЕ передаём `language: "ru"` в Whisper?


Функция 2: extractVocab() — текст → список слов

Зачем: GPT принимает текст на любом языке и возвращает структурированный JSON с парами {en, ru}. Это называется structured output — заставляем LLM возвращать данные в нужном формате.

src/ai.js
1 temperature 02 System prompt3 Фильтр4 JSON fallback
...

Что делает temperature: 0? Это «креативность» модели: 0 = максимально предсказуемый ответ. Для структурированного вывода нам нужна предсказуемость, не фантазия.

Почему в системном промпте написано «Reply ONLY as a JSON array» и мы всё равно делаем try/catch вокруг JSON.parse?


Функция 3: describeImage() — фото → список объектов

Зачем: GPT-4o-mini Vision — мультимодальная модель. Принимает не только текст, но и изображения. Передаём URL фото и просим назвать объекты.

javascript
// 3. GPT-4o-mini Vision: URL фото → [{en, ru}, ...], максимум 6
export async function describeImage(imageUrl) {
const response = await openai.chat.completions.create({
  model: "gpt-5.4-mini",
  temperature: 0,
  max_completion_tokens: 300,
  messages: [{
    role: "user",
    content: [
      {
        type: "text",
        text:
          "List up to 6 distinct visible objects in this image. " +
          'Reply ONLY as a JSON array: [{"en":"apple","ru":"яблоко"}, ...]. ' +
          "Use simple, common nouns. No explanations, no markdown — just the array.",
      },
      {
        type: "image_url",
        // detail: "low" — дешевле (512×512 crop), достаточно для распознавания объектов
        image_url: { url: imageUrl, detail: "low" },
      },
    ],
  }],
});

const raw = response.choices[0]?.message?.content ?? "[]";
try {
  const parsed = JSON.parse(raw);
  if (!Array.isArray(parsed)) return [];
  return parsed
    .filter((item) => item &&
      typeof item.en === "string" && item.en.trim() &&
      typeof item.ru === "string" && item.ru.trim())
    .slice(0, 6);
} catch {
  const match = raw.match(/\[[\s\S]*\]/);
  if (match) { try { return JSON.parse(match[0]).slice(0, 6); } catch { return []; } }
  return [];
}
}

Зачем detail: "low"? Режим low сжимает изображение до 512×512 до отправки в API. Для распознавания объектов разрешения достаточно, а стоимость в 4-6 раз ниже чем high.

Зачем .slice(0, 6)? Защита от галлюцинаций: даже если GPT «нашёл» 15 объектов, берём только первые 6.

Зачем `.slice(0, 6)` после парсинга ответа `describeImage`?

Шаг D1: голос → Whisper → словарь ⭐⭐

Смотри, что происходит когда ученик отправляет голосовое:

Пайплайн: голосовое → новые слова

Нажми на каждый шаг, чтобы увидеть что входит и что выходит

Собираем обработчик по слоям. Начни с вспомогательной функции в bot.js:


Слой 1: скачиваем аудио

Зачем: Telegram хранит файлы на своих серверах. Сначала нужно получить путь (file_path), потом скачать файл как Buffer (массив байт).

javascript
import { transcribeAudio, extractVocab } from "./ai.js";
import { dbInsert } from "./db.js";

// Скачать файл с серверов Telegram как Buffer
async function downloadTelegramFile(fileId) {
const file = await bot.api.getFile(fileId);
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
return Buffer.from(await (await fetch(url)).arrayBuffer());
}

bot.on("message:voice", async (ctx) => {
await ctx.reply("🎙 Слышу! Распознаю речь...");
try {
  const buffer = await downloadTelegramFile(ctx.message.voice.file_id);
  console.log(`📥 Скачал аудио: ${buffer.length} байт`);
  // ... продолжение в следующем слое
} catch (err) {
  ctx.reply(`❌ Ошибка: ${err.message}`);
}
});

Почему нужно два шага (getFile + fetch) чтобы скачать голосовое из Telegram?


Слой 2: распознаём речь (Whisper)

Зачем: передаём Buffer в Whisper, получаем транскрипт — текст того, что сказал пользователь.

javascript
  // Слой 2: Whisper → текст
  const text = await transcribeAudio(buffer, "voice.oga");
  await ctx.reply(`📝 Распознал: «${text\nПеревожу слова...`);
  // ... продолжение в следующем слое

Обрати внимание: мы сразу отвечаем пользователю — «Распознал: ...». Это промежуточный feedback — пользователь видит, что система работает, не думает что бот завис.

Зачем отвечать пользователю `ctx.reply("📝 Распознал: ...")` до завершения всего пайплайна?


Слой 3: извлекаем словарь (GPT)

Зачем: передаём распознанный текст в GPT, получаем список пар {en, ru}.

javascript
  // Слой 3: GPT → [{en, ru}]
  const words = await extractVocab(text);
  if (!words.length) {
    return ctx.reply("Не смог извлечь слова. Попробуй сказать предложение с существительными.");
  }
  // ... продолжение в следующем слое

Что вернёт `extractVocab("привет")` — приветствие без смысловых слов?


Слой 4: пишем в БД

Зачем: проходим по списку слов и добавляем каждое в dictionary_words. Ошибки на отдельные слова (уже есть) игнорируем — дублирование не повод останавливать всё.

javascript
  // Слой 4: записываем в БД
  let added = 0;
  for (const { en, ru } of words) {
    try {
      await dbInsert("dictionary_words", {
        en: en.toLowerCase(), ru, added_by: ctx.from.id, source: "voice",
      });
      added++;
    } catch { /* слово уже есть — пропускаем */ }
  }

  const list = words.map((w) => `• *${w.en}* — ${w.ru}`).join("\n");
  ctx.reply(`✅ Добавил ${added} слов:\n\n${list}\n\nТеперь в /quiz! 🎯`, {
    parse_mode: "Markdown",
  });

Почему в цикле `for` каждый `dbInsert` обёрнут в отдельный `try/catch`?

Шаг D2: фото → Vision → словарь ⭐⭐

Пайплайн: фото → новые слова

Нажми на каждый шаг

Собираем обработчик по слоям — тот же паттерн что и для голосового.


Слой 1: получаем URL фото

Зачем: Telegram присылает несколько версий фото разного разрешения. Берём самый большой — последний в массиве. Не скачиваем файл — берём только URL (Vision принимает URL напрямую).

javascript
import { describeImage } from "./ai.js";

bot.on("message:photo", async (ctx) => {
  await ctx.reply("🖼 Вижу фото! Анализирую...");
  try {
    // Telegram присылает несколько размеров — берём самый большой (последний)
    const largest = ctx.message.photo[ctx.message.photo.length - 1];
    const file = await bot.api.getFile(largest.file_id);
    const imageUrl = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
    // Не логируем imageUrl — содержит токен!
    console.log(`🖼 Фото: ${file.file_path}`);
    // ... продолжение в следующем слое
  } catch (err) {
    ctx.reply(`❌ Ошибка: ${err.message}`);
  }
});

Почему для фото мы берём `ctx.message.photo[ctx.message.photo.length - 1]`, а не `[0]`?


Слой 2: Vision анализирует фото

Зачем: передаём URL прямо в GPT Vision — модель скачивает изображение и называет объекты.

javascript
  // Слой 2: GPT Vision → [{en, ru}]
  const words = await describeImage(imageUrl);
  if (!words.length) return ctx.reply("Не смог распознать объекты.");
  // ... продолжение в следующем слое

Для `describeImage` мы передаём URL фото, а для `transcribeAudio` — Buffer. Почему разный подход?


Слой 3: записываем в БД

Зачем: тот же паттерн что и в голосовом — проходим по словам, добавляем каждое, пропускаем дубликаты.

javascript
  // Слой 3: записываем в БД
  let added = 0;
  for (const { en, ru } of words) {
    try {
      await dbInsert("dictionary_words", {
        en: en.toLowerCase(), ru, added_by: ctx.from.id, source: "photo",
      });
      added++;
    } catch { /* уже есть */ }
  }

  const allList = words.map((w) => `• *${w.en}* — ${w.ru}`).join("\n");
  ctx.reply(
    `👁 Нашёл на фото:\n\n${allList}\n\n✅ Добавил ${added} новых слов в /quiz!`,
    { parse_mode: "Markdown" },
  );

Что означает `source: "photo"` в словаре и зачем это поле?

Финальная демонстрация

Сейчас играем всей группой:

  1. Каждый добавляет одно слово через /add на тему «моя комната»
  2. Каждый отправляет голосовое — одно предложение на русском
  3. Учитель показывает /leaderboard на проекторе
  4. Группа смотрит select en, ru, source, added_by from dictionary_words order by created_at desc limit 20; — видим кто что добавил и откуда

Итог: в словаре теперь не 5 слов, а 30+. И они пришли от голоса, фото и ручного ввода — это и есть живая база данных.

Что унести домой

7 главных идей дня:

  1. Map умирает с процессом — для данных, которые должны жить, нужна база данных.
  2. Supabase REST — это обычный HTTP: fetch + заголовки + JSON. Никакой магии.
  3. Разделение ответственности: index.js — оркестратор, db.js / weather.js / ai.js — сервисы. Это называется separation of concerns — одна из главных идей в программировании.
  4. HTTP-методы — это глаголы: GET (читай), POST (создай), PATCH (обнови часть), DELETE (удали). Все REST API в мире используют эти же четыре глагола.
  5. API — это договор: ты шлёшь такой запрос, тебе возвращают такие данные. Open-Meteo — API погоды. OpenAI — API для AI. Supabase — API для базы данных.
  6. Whisper + GPT-4o-mini Vision — это две разные нейросети. Whisper специализируется на речи, GPT-4o-mini — на текст+изображения. AI — это инструменты, а не волшебство.
  7. LLM ошибаются — строить надёжные системы с AI значит предусматривать fallback: try/catch, лимиты, фильтрация пустых ответов.

Финальный квиз

Какой HTTP-метод используют, чтобы СОЗДАТЬ новую запись в базе данных?

Чем PATCH отличается от POST?

Почему `db.js` лежит в отдельном файле, а не внутри bot.js?

Что вернёт `dbSelect('scores', 'order=points.desc&limit=5')`?

Что принимает на вход функция `transcribeAudio`?

Ты создал OPENAI_API_KEY. Что ОБЯЗАТЕЛЬНО нужно сделать сразу после?

После команды /voice бот добавил слова в dictionary_words с source='voice'. Что это значит для /quiz?

Зачем мы ограничиваем GPT-4o-mini Vision лимитом 6 объектов?

Как называется способ аутентификации, при котором ключ передаётся в заголовке `Authorization: Bearer ...`?

Что делает `dbUpsert` в отличие от `dbInsert`, и когда это полезно для команды /add?

© 2026 aqlacademy.kz