В этой статье я покажу, как буквально за 15 минут создать собственного Telegram чат-бота на базе ИИ и начать использовать его абсолютно бесплатно.

Пока продолжается работа над основным проектом, который я курирую (ссылка), хочу поделиться приятным опытом экспериментов с микроконтроллером ESP32 на примере платы Lolin Lite.
Не скрою — эта модель мне особенно симпатична благодаря компактности, достаточному числу портов ввода-вывода и хорошему запасу по производительности.

Надеюсь, эта статья окажется полезной и для вас.

Начало

Для реализации проекта потребуется выполнить несколько шагов:

  1. Зарегистрировать бота в Telegram

  2. Создать аккаунт на openrouter.ai

  3. Внести изменения в прошивку

  4. Залить прошивку на микроконтроллер

Регистрация бота в Telegram

Через поиск в Telegram находим @BotFather (без кавычек) и открываем диалог.

Отправляем команду /newbot — после этого BotFather предложит ввести несколько параметров:

  1. Название бота
    Укажите публичное имя — то, что будут видеть пользователи (например, SmartHome Assistant).

  2. Юзернейм (адрес) бота
    Это уникальное имя, которое будет заканчиваться на bot (например, smarthome_helper_bot). Если имя занято, BotFather попросит ввести другое.

  3. Получение API-токена
    После успешного создания BotFather выдаст токен доступа (API key). Сохраните его — он потребуется позже.

  4. Готово
    Бот зарегистрирован, можно переходить к следующему шагу.

Регистрация в OpenRouter

Шаги регистрации и получения API-ключа:

OpenRouter — это платформа, которая предоставляет доступ к различным нейросетям, как на платной основе, так и бесплатно. В рамках этой статьи мы будем использовать именно бесплатные возможности.

  1. Переходим на сайт openrouter.ai и регистрируемся. Можно авторизоваться через Google, GitHub или с помощью почты.

  2. После входа открываем: Settings → API Keys → Create API Key.

  3. В поле Name указываем любое название (для себя) и нажимаем Create.

  4. Появится ваш API key — сохраните его, он понадобится далее.

  5. Затем снова переходим в Settings и открываем раздел Training, Logging, & Privacy. Включаем второй переключатель напротив пункта: Enable free endpoints that may publish prompts.

  6. Этап завершён.

Код для микроконтроллера

Далее я выкладываю сам код, который будет необходимо немного настроить:

Скрытый текст
// ESP32 Telegram LLM BOT by RealZel
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "time.h"

// ====== Настройки ======
const char* ssid = "Название точки доступа Wi-Fi";
const char* password = "Пароль точки доступа Wi-Fi";

const char* telegramBotToken = "Сюда ваш API с Botfather Telegram"; 
const char* telegramApiBase = "https://api.telegram.org/bot";

const char* LLM_API_URL = "https://openrouter.ai/api/v1/chat/completions";
const char* LLM_MODEL = "deepseek/deepseek-chat-v3.1:free";
const char* LLM_API_KEY = "Сюда ваш API с OpenRouter"; 

const char* ntpServer = "pool.ntp.org";
const long  gmtOffset_sec = 2 * 3600;
const int   daylightOffset_sec = 0;

const unsigned long POLL_INTERVAL_MS = 2000;
const size_t TELEGRAM_MSG_LIMIT = 3800;

// ====== Persona / системный промпт ======
const char* systemPrompt =
"При получении команды /start в своем сообщении сообщай, что ты текстовый помощник и готов ответить на вопросы. Среднее время ответа до 30 секунд.\n"
"Не строй таблицы.\n"
"Каждый ответ должен умещаться максимум в два сообщения Telegram (~7600 символов).\n"
"Если текст длиннее, сокращай, сохраняя ключевую и самую важную информацию.\n"
"Делай текст связным и понятным, избегай обрезки важных предложений.\n"
"Размер одного сообщения не должен превышать 3800 символов.\n";

// ====== Глобальные переменные ======
WiFiClientSecure client;
long lastUpdateId = 0;
unsigned long lastPoll = 0;

// ====== Хранение предыдущих сообщений ======
String lastUserMessage = "";
String lastBotMessage = "";

// ====== Вспомогательные функции ======
void setupTime() {
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  time_t now = time(nullptr);
  int tries = 0;
  while (now < 1600000000 && tries < 20) {
    delay(500);
    now = time(nullptr);
    tries++;
  }
}

String httpGet(const String &url) {
  HTTPClient https;
  if (url.startsWith("https://")) https.begin(client, url);
  else https.begin(url);
  int code = https.GET();
  String payload = "";
  if (code > 0) payload = https.getString();
  https.end();
  return payload;
}

String httpPostJsonWithAuth(const String &url, const String &body, const char* bearer) {
  HTTPClient https;
  if (url.startsWith("https://")) https.begin(client, url);
  else https.begin(url);
  https.addHeader("Content-Type", "application/json");
  if (bearer && strlen(bearer) > 0) {
    String auth = String("Bearer ") + bearer;
    https.addHeader("Authorization", auth);
  }
  int code = https.POST(body);
  String payload = "";
  if (code > 0) payload = https.getString();
  https.end();
  return payload;
}

// ====== Отправка сообщений в Telegram с делением на части ======
void sendTelegramMessage(long chat_id, const String &text, long reply_to_message_id = 0) {
  size_t start = 0;
  size_t len = text.length();
  while (start < len) {
    String chunk = text.substring(start, start + TELEGRAM_MSG_LIMIT);
    start += TELEGRAM_MSG_LIMIT;

    StaticJsonDocument<2000> doc;
    doc["chat_id"] = chat_id;
    doc["text"] = chunk;
    doc["parse_mode"] = "HTML";
    if (reply_to_message_id) doc["reply_to_message_id"] = reply_to_message_id;
    String body;
    serializeJson(doc, body);
    httpPostJsonWithAuth(String(telegramApiBase) + telegramBotToken + "/sendMessage", body, nullptr);
  }
}

// ====== Формирование тела запроса к LLM с учетом предыдущих сообщений ======
String buildLLMRequestBody(const String &userMessage) {
  StaticJsonDocument<8192> req;
  req["model"] = LLM_MODEL;
  req["temperature"] = 0.7;
  req["max_tokens"] = 1200; // ограничиваем, чтобы LLM сам пытался уложиться в 2 сообщения

  JsonArray messages = req.createNestedArray("messages");

  // Системный промпт
  JsonObject m0 = messages.createNestedObject();
  m0["role"] = "system";
  m0["content"] = systemPrompt;

  // Предыдущее сообщение пользователя и ответ бота
  if (lastUserMessage.length() > 0 && lastBotMessage.length() > 0) {
    JsonObject prevUser = messages.createNestedObject();
    prevUser["role"] = "user";
    prevUser["content"] = lastUserMessage;

    JsonObject prevBot = messages.createNestedObject();
    prevBot["role"] = "assistant";
    prevBot["content"] = lastBotMessage;
  }

  // Текущее сообщение пользователя
  JsonObject currentUser = messages.createNestedObject();
  currentUser["role"] = "user";
  currentUser["content"] = userMessage;

  String out;
  serializeJson(req, out);
  return out;
}

// ====== Универсальный разбор ответа LLM ======
String parseLLMResponse(const String &resp) {
  if (resp.length() == 0) return "Ответа нет, попробуйте еще раз.";
  StaticJsonDocument<20000> doc;
  DeserializationError err = deserializeJson(doc, resp);
  if (err) return "Ошибка разбора JSON от LLM.";

  if (doc.containsKey("choices")) {
    JsonArray choices = doc["choices"].as<JsonArray>();
    if (choices.size() > 0) {
      JsonObject c0 = choices[0].as<JsonObject>();
      if (c0.containsKey("message") && c0["message"].containsKey("content"))
        return String((const char*)c0["message"]["content"]);
      if (c0.containsKey("text")) return String((const char*)c0["text"]);
      if (c0.containsKey("content")) return String((const char*)c0["content"]);
    }
  }

  if (doc.containsKey("text")) return String((const char*)doc["text"]);
  return "LLM вернул неожиданный формат ответа.";
}

String callLLM(const String &userMessage) {
  String body = buildLLMRequestBody(userMessage);
  String resp = httpPostJsonWithAuth(String(LLM_API_URL), body, LLM_API_KEY);
  String result = parseLLMResponse(resp);
  return result;
}

// ====== Фильтры ======
bool containsDangerous(const String &text) {
  String low = text;
  low.toLowerCase();
  const char* forbids[] = {"убить", nullptr};
  for (int i=0; forbids[i]!=nullptr; ++i) if (low.indexOf(forbids[i]) != -1) return true;
  return false;
}

bool looksLikeMedicalPrescription(const String &text) {
  String low = text;
  low.toLowerCase();
  const char* meds[] = {"", nullptr};
  for (int i=0; meds[i]!=nullptr; ++i) if (low.indexOf(meds[i]) != -1) return true;
  return false;
}

String postFilterResponse(const String &reply) {
  if (reply.length() == 0) return "Нейросеть молчит, попробуйте позже.";
  if (looksLikeMedicalPrescription(reply)) return "Не даю медицинских рекомендаций. Обратитесь к врачу.";
  if (containsDangerous(reply)) return "На такие опасные инструкции не отвечаю.";
  return reply;
}

// ====== Telegram Updates ======
void processUpdates() {
  String url = String(telegramApiBase) + String(telegramBotToken) + "/getUpdates?timeout=5";
  if (lastUpdateId) url += "&offset=" + String(lastUpdateId + 1);
  String resp = httpGet(url);
  if (resp.length() == 0) return;

  StaticJsonDocument<20000> doc;
  if (deserializeJson(doc, resp)) return;
  if (!doc.containsKey("result")) return;

  for (JsonVariant update : doc["result"].as<JsonArray>()) {
    if (update.containsKey("update_id")) lastUpdateId = update["update_id"].as<long>();
    if (!update.containsKey("message")) continue;

    JsonObject msg = update["message"].as<JsonObject>();
    long chat_id = msg["chat"]["id"].as<long>();
    long message_id = msg["message_id"].as<long>();

    if (!msg.containsKey("text")) {
      sendTelegramMessage(chat_id, "Пишите текст, я не умею обрабатывать медиа.", message_id);
      continue;
    }

    String text = String((const char*)msg["text"]);
    if (containsDangerous(text)) {
      sendTelegramMessage(chat_id, "На такие запросы не могу ответить.", message_id);
      continue;
    }

    String hfReply = callLLM(text);
    String safeReply = postFilterResponse(hfReply);

    // Сохраняем контекст для следующего запроса
    lastUserMessage = text;
    lastBotMessage = safeReply;

    sendTelegramMessage(chat_id, safeReply, message_id);
  }
}

// ====== setup / loop ======
void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  int tries = 0;
  while (WiFi.status() != WL_CONNECTED && tries < 60) {
    delay(500); Serial.print(".");
    tries++;
  }
  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("WiFi connected, IP: " + WiFi.localIP().toString());
  } else Serial.println("WiFi connection failed");

  setupTime();
  client.setInsecure();
  lastUpdateId = 0;
}

void loop() {
  if (millis() - lastPoll >= POLL_INTERVAL_MS) {
    lastPoll = millis();
    processUpdates();
  }
}

В коде необходимо указать четыре параметра:

  • SSID и пароль вашей Wi-Fi сети

  • API-токен Telegram (из BotFather)

  • API-ключ OpenRouter

  • Persona / системный промт (необязательно)

Что делает прошивка сейчас:

  • Принимает сообщения от пользователя и, в соответствии с инструкциями из блока Persona, отправляет их в OpenRouter на модель deepseek-chat-v3.1.

  • Получает ответ от модели и отправляет его в Telegram.

  • Автоматически делит длинные ответы на несколько сообщений.

  • Позволяет менять характер бота: достаточно изменить текст в разделе Persona.

  • Имеет встроенные фильтры: если сообщение содержит запрещённые слова, бот сразу отправляет уведомление, что не может отвечать на такие запросы.

  • Запоминает предыдущее сообщение в переписке, поддерживая контекст общения.

  • При желании можно использовать любую другую LLM на OpenRouter — достаточно изменить значение в строке LLM_MODEL.

Пример ответа на сообщения
Пример ответа на сообщения

В результате всех шагов у вас будет собственный Telegram-бот, который будет отвечать на сообщения.

Важно понимать, что память ESP32 ограничена, и устройство стабильно обрабатывает запросы только от одного пользователя. Поэтому текущая реализация подходит прежде всего для личных экспериментов и хобби-проектов.

Впрочем, есть и практичные сценарии: вы можете привязать бота к своему каналу и запрограммировать его, например, на публикацию сообщений по расписанию или по событиям.

Вот и всё на сегодня. Надеюсь вам было интересно и полезно.

Комментарии (4)


  1. monowar
    02.10.2025 13:22

    С запятыми в заголовке кажется перебор. За инструкцию спасибо!


  1. pol_pot
    02.10.2025 13:22

    В телеграме очень много подводных камней для ИИ чатботов. Подавляющее большинство таких ботов, даже те у кого миллионы юзеров, не обучены элементарному, как донести текст от юзера до ллм и обратно не испортив.

    Самый простой пример - юзер кидает текст размером 5000 символов, телеграм режет его на 2 части и в бота прилетает почти одновременно 2 сообщения от юзера. Дальше большинство ботов отвечают на первую часть а вторую отбрасывают, либо отвечают на обе но раздельно.


  1. kalapanga
    02.10.2025 13:22

    И с апи телеграм и с опенроутером предполагается работа по https, но в статье нет ни слова о том, как научить этому ESP. И в коде тоже.

    Автор, у Вас действительно работает этот код?


    1. RealZel Автор
      02.10.2025 13:22

      Код исправен. Сообщение, опубликованное в статье, как раз было сделано через него.