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)
Headers
← Ответ (Response)
Headers
Body (JSON)
[
{
"user_id": 1,
"points": 7
}
]Это всё. Теперь когда увидишь fetch с заголовками — ты знаешь что это такое. Глубже разберём в части C.
Шаг A1: подключаем Supabase
A1.1. Создаём проект
- Открой supabase.com → Start your project
- Создай новый проект (имя любое, регион — ближайший)
- Подожди ~1 минуту пока проект стартует
- Открой Settings → API
- Скопируй Project URL и anon public ключ
A1.2. Кладём ключи в .env
В папке твоего бота открой .env и добавь:
SUPABASE_URL=https://твой-проект.supabase.co
SUPABASE_ANON_KEY=eyJhbGciO...длинная-строка
Шаг A2: первая таблица — scores
Открой SQL Editor в Supabase и запусти:
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:
-- вставить тестовую строку
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 внутри каждого handler | handler зовёт функцию, функция зовёт fetch |
| Меняешь URL — правишь 10 мест | Меняешь URL — правишь 1 место (db.js) |
Собираем файл по шагам — каждый подшаг делает одну понятную вещь.
Подшаг 1: читаем .env
Зачем: ключи Supabase нельзя хранить прямо в коде — если закоммитишь в git, ключ утечёт навсегда. .env — файл, который git игнорирует.
Что сделали: import "dotenv/config" запускает dotenv, который читает .env и кладёт всё в process.env. Потом мы просто читаем переменные. Если переменная не найдена — process.exit(1) останавливает программу с понятной ошибкой сразу, а не через 5 минут с непонятным TypeError.
Почему мы проверяем `!SUPABASE_URL || !SUPABASE_ANON_KEY` сразу при загрузке файла?
Подшаг 2: собираем заголовки
Зачем: у каждого запроса к Supabase должны быть одни и те же три заголовка. Один объект — меньше дублирования.
Что сделали: 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) вместо парсинга строк.
Для чего нужен класс `HttpError` вместо обычного `throw new Error(...)`?
Подшаг 4: пишем dbSelect()
Зачем: все команды бота читают данные. dbSelect() — единственное место, где происходит GET-запрос к Supabase.
Три ключевых момента: — гибкий фильтр, — чистые заголовки без дублирования, — ошибки по коду, а не по тексту.
Что вернёт `dbSelect("scores")` если в таблице нет ни одной строки?
Подшаг 5: добавляем dbInsert и dbUpsert
Зачем: для записи данных нужен POST. Upsert — это «вставить или обновить» — нужен для scores (если пользователь уже есть, обновить имя, не создавать дубликат).
Какая строка заставляет Supabase вернуть созданную запись в ответе на POST?
Шаг A4: очки живут в БД
Открой свой bot.js (или index.js). Найди строку:
const scores = new Map();
Удали её. Теперь заменим логику /score и callback-обработчик:
Старый /score********:
bot.command("score", (ctx) => {
const pts = scores.get(ctx.from.id) ?? 0;
ctx.reply(`Твои очки: ${pts}`);
});
Новый /score********:
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 (выдача очка):
// scores.set(userId, (scores.get(userId) ?? 0) + 1);
Новый callback:
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 — таблица рекордов
Что нужно поменять в запросе чтобы `/leaderboard` показывал только тех, у кого больше 0 очков?
Шаг B1: словарь переезжает в БД
Запусти в SQL Editor Supabase:
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:
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 — это карта базы данных: кто есть кто, какие поля, как связаны.
Читаем ERD как предложение:
- Один
scores(пользователь) → многоdictionary_words(слов). Один Bekzat может добавить сотни слов. - Каждое слово в
dictionary_wordsзнает кто его добавил черезadded_by → scores.user_id. added_byможет бытьNULL— это слова из seed-файла, у них нет «автора».
Шаг B2: /word и /quiz из БД
Найди в bot.js старый const dict = {...} — удали его.
Новый /word:
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: базовая версия — добавляем слово по счастливому пути
Зачем: сначала делаем так, чтобы команда работала нормально — без обработки крайних случаев. Потом усилим защиту.
Что сделали: 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:
Зачем делать `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:
Теперь структура db.js должна читаться как обычный текст: «пойди по URL, методом POST, с этими заголовками, отправь эти данные, жди ответ».
Шаг C3: /weather — первый внешний API
Создай файл src/weather.js. Собираем его по шагам.
Подшаг 1: геокодинг — город → координаты
Зачем: Open-Meteo принимает latitude и longitude, а не название города. Nominatim (OpenStreetMap) бесплатно переводит «Almaty» в координаты.
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 и получаем текущую температуру и код погоды.
// 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:
В bot.js добавь:
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 в нашем боте
Установим официальный SDK:
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. Добавляем три функции по одной — начнём с инициализации:
Функция 1: transcribeAudio() — голос → текст
Зачем: Whisper — специализированная нейросеть для распознавания речи. Принимает аудиофайл, возвращает текст.
Почему не передаём language: "ru"? Если ученик скажет фразу по-английски, фиксированный language: "ru" переведёт всё в кириллицу — extractVocab получит не те слова.
Почему мы НЕ передаём `language: "ru"` в Whisper?
Функция 2: extractVocab() — текст → список слов
Зачем: GPT принимает текст на любом языке и возвращает структурированный JSON с парами {en, ru}. Это называется structured output — заставляем LLM возвращать данные в нужном формате.
Что делает temperature: 0? Это «креативность» модели: 0 = максимально предсказуемый ответ. Для структурированного вывода нам нужна предсказуемость, не фантазия.
Почему в системном промпте написано «Reply ONLY as a JSON array» и мы всё равно делаем try/catch вокруг JSON.parse?
Функция 3: describeImage() — фото → список объектов
Зачем: GPT-4o-mini Vision — мультимодальная модель. Принимает не только текст, но и изображения. Передаём URL фото и просим назвать объекты.
// 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 (массив байт).
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, получаем транскрипт — текст того, что сказал пользователь.
// Слой 2: Whisper → текст
const text = await transcribeAudio(buffer, "voice.oga");
await ctx.reply(`📝 Распознал: «${text}»\nПеревожу слова...`);
// ... продолжение в следующем слое
Обрати внимание: мы сразу отвечаем пользователю — «Распознал: ...». Это промежуточный feedback — пользователь видит, что система работает, не думает что бот завис.
Зачем отвечать пользователю `ctx.reply("📝 Распознал: ...")` до завершения всего пайплайна?
Слой 3: извлекаем словарь (GPT)
Зачем: передаём распознанный текст в GPT, получаем список пар {en, ru}.
// Слой 3: GPT → [{en, ru}]
const words = await extractVocab(text);
if (!words.length) {
return ctx.reply("Не смог извлечь слова. Попробуй сказать предложение с существительными.");
}
// ... продолжение в следующем слое
Что вернёт `extractVocab("привет")` — приветствие без смысловых слов?
Слой 4: пишем в БД
Зачем: проходим по списку слов и добавляем каждое в dictionary_words. Ошибки на отдельные слова (уже есть) игнорируем — дублирование не повод останавливать всё.
// Слой 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 напрямую).
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 — модель скачивает изображение и называет объекты.
// Слой 2: GPT Vision → [{en, ru}]
const words = await describeImage(imageUrl);
if (!words.length) return ctx.reply("Не смог распознать объекты.");
// ... продолжение в следующем слое
Для `describeImage` мы передаём URL фото, а для `transcribeAudio` — Buffer. Почему разный подход?
Слой 3: записываем в БД
Зачем: тот же паттерн что и в голосовом — проходим по словам, добавляем каждое, пропускаем дубликаты.
// Слой 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"` в словаре и зачем это поле?
Финальная демонстрация
Сейчас играем всей группой:
- Каждый добавляет одно слово через /add на тему «моя комната»
- Каждый отправляет голосовое — одно предложение на русском
- Учитель показывает
/leaderboardна проекторе - Группа смотрит
select en, ru, source, added_by from dictionary_words order by created_at desc limit 20;— видим кто что добавил и откуда
Итог: в словаре теперь не 5 слов, а 30+. И они пришли от голоса, фото и ручного ввода — это и есть живая база данных.
Что унести домой
7 главных идей дня:
Mapумирает с процессом — для данных, которые должны жить, нужна база данных.- Supabase REST — это обычный HTTP: fetch + заголовки + JSON. Никакой магии.
- Разделение ответственности:
index.js— оркестратор,db.js/weather.js/ai.js— сервисы. Это называется separation of concerns — одна из главных идей в программировании. - HTTP-методы — это глаголы: GET (читай), POST (создай), PATCH (обнови часть), DELETE (удали). Все REST API в мире используют эти же четыре глагола.
- API — это договор: ты шлёшь такой запрос, тебе возвращают такие данные. Open-Meteo — API погоды. OpenAI — API для AI. Supabase — API для базы данных.
- Whisper + GPT-4o-mini Vision — это две разные нейросети. Whisper специализируется на речи, GPT-4o-mini — на текст+изображения. AI — это инструменты, а не волшебство.
- 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?