MAATRIX / Блог / Свой Telegram-бот на локальной модели Ollama

Свой Telegram-бот на локальной модели Ollama

Свой Telegram-бот на локальной модели Ollama

MAATRIX

Личный ассистент в Telegram, который отвечает на вашей собственной модели, а не гоняет запросы через чужой API, — это буквально час работы: BotFather, полсотни строк на Python и Ollama на порту 11434. Отдельная головная боль — не дать боту стать бесплатным чат-ассистентом для всех, кто найдёт его юзернейм, поэтому ниже сразу разберём ограничение доступа по chat_id, а не оставим это «на потом».

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Что понадобится

Схема простая: Telegram Bot API получает сообщения от пользователей, ваш Python-скрипт их обрабатывает и пересылает в Ollama по HTTP на localhost, Ollama генерирует ответ локальной моделью и отдаёт его обратно скрипту, а тот — пользователю в Telegram.

Перед началом нужно:

  • сервер (VPS или выделенный) с уже установленной и запущенной Ollama — если её ещё нет, разверните по шагам из статьи про локальный запуск LLM через Ollama;
  • загруженная модель, например ollama pull llama3.1:8b — если сомневаетесь, какая модель влезет по памяти, свериться можно с таблицей RAM для моделей Ollama;
  • Python 3.10+ и токен бота от BotFather;
  • права на создание systemd-юнита (root или sudo).

Для теста, что Ollama вообще отвечает, достаточно:

curl http://localhost:11434/api/generate -d '{
  "model": "llama3.1:8b",
  "prompt": "Привет, как дела?",
  "stream": false
}'

Если в ответ пришёл JSON с полем response — можно двигаться дальше.

Создаём бота через BotFather

Открываем в Telegram диалог с @BotFather и выполняем:

  1. /newbot
  2. Вводим отображаемое имя бота, например «My Ollama Assistant».
  3. Вводим юзернейм — обязательно оканчивается на bot, например my_ollama_assistant_bot.
  4. BotFather присылает токен вида 123456789:AAExampleTokenDoNotUseThisOne. Сохраните его — это единственный ключ доступа к боту, при компрометации сразу делайте /revoke.

Дополнительно стоит настроить через BotFather команду /setprivacyDisable, если планируете добавлять бота в групповые чаты и хотите, чтобы он видел все сообщения, а не только адресованные ему через /. Для личного ассистента в личке это не требуется — оставляйте Enable по умолчанию, это безопаснее.

Токен не кладите в код — только в переменные окружения или systemd EnvironmentFile, об этом ниже.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть Ollama

Пишем Python-скрипт

Библиотека python-telegram-bot (версия 20+, асинхронная) — самый предсказуемый вариант для такой задачи. Устанавливаем окружение:

mkdir -p /opt/ollama-bot && cd /opt/ollama-bot
python3 -m venv venv
source venv/bin/activate
pip install "python-telegram-bot>=21,<22" httpx python-dotenv

Файл .env с секретами (права 600, владелец — сервисный пользователь):

TELEGRAM_TOKEN=123456789:AAExampleTokenDoNotUseThisOne
OLLAMA_MODEL=llama3.1:8b
OLLAMA_URL=http://localhost:11434/api/generate
ALLOWED_CHAT_IDS=111111111,222222222

Сам бот, bot.py:

import os
import logging
import httpx
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import (
    ApplicationBuilder, MessageHandler, CommandHandler,
    ContextTypes, filters,
)

load_dotenv()

TOKEN = os.environ["TELEGRAM_TOKEN"]
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1:8b")
ALLOWED = {
    int(x) for x in os.environ.get("ALLOWED_CHAT_IDS", "").split(",") if x.strip()
}

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("ollama-bot")


def is_allowed(chat_id: int) -> bool:
    return not ALLOWED or chat_id in ALLOWED


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(f"Ваш chat_id: {update.effective_chat.id}")


async def ask_ollama(prompt: str) -> str:
    async with httpx.AsyncClient(timeout=120) as client:
        resp = await client.post(OLLAMA_URL, json={
            "model": MODEL,
            "prompt": prompt,
            "stream": False,
        })
        resp.raise_for_status()
        return resp.json().get("response", "").strip()


async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = update.effective_chat.id
    if not is_allowed(chat_id):
        log.warning("Отказ chat_id=%s", chat_id)
        await update.message.reply_text("Доступ закрыт.")
        return

    text = update.message.text
    await context.bot.send_chat_action(chat_id=chat_id, action="typing")
    try:
        answer = await ask_ollama(text)
    except httpx.HTTPError as e:
        log.error("Ollama error: %s", e)
        answer = "Модель сейчас недоступна, попробуйте позже."

    await update.message.reply_text(answer or "Пустой ответ от модели.")


def main():
    app = ApplicationBuilder().token(TOKEN).build()
    app.add_handler(CommandHandler("start", start))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_message))
    app.run_polling()


if __name__ == "__main__":
    main()

Команда /start специально отвечает вашим chat_id — так вы узнаёте его без сторонних ботов и сразу вписываете в ALLOWED_CHAT_IDS. После первого запуска команду можно закомментировать или оставить — она безобидна, если у бота уже нет открытого доступа к Ollama для чужих chat_id.

Обратите внимание на stream: false в запросе к Ollama — это упрощает код: ответ приходит одним куском, без сборки чанков. Для длинных генераций это означает, что пользователь не увидит текст, пока модель не закончит целиком, — это осознанный компромисс ради простоты, при желании его можно доработать позже (см. раздел «Что доработать»).

Ограничение доступа по chat_id

Без этого шага любой человек, который найдёт юзернейм бота, получает бесплатный доступ к вашей модели и вашим ресурсам CPU/GPU — а юзернейм легко находится через поиск в Telegram или случайную пересылку ссылки. Проверка is_allowed() в коде выше — это минимально достаточная защита для личного или семейного использования, но у неё есть нюансы:

  • ALLOWED_CHAT_IDS пустой = бот открыт для всех. Это осознанное поведение для этапа отладки — не забудьте заполнить список перед тем, как оставлять бота работать без присмотра.
  • chat_id личных чатов стабилен, но если бот добавлен в группу — там свой отдельный chat_id (отрицательное число), его тоже нужно вписать отдельно, если хотите разрешить именно эту группу.
  • Для более гибкой схемы (несколько уровней доступа, лимит запросов в час на пользователя) есть смысл хранить список разрешённых ID не в .env, а в SQLite и добавлять администратора отдельной командой /allow <id> с проверкой, что её вызывает только владелец бота — сравнивайте chat_id вызывающего с заранее зашитым OWNER_ID.

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

Запуск как systemd-сервис

Чтобы бот работал в фоне и поднимался после перезагрузки сервера, оформляем его как systemd-юнит вместо screen/nohup — так вы получите автоперезапуск при падении и нормальные логи через journalctl.

Создаём пользователя без прав входа (не обязательно, но правильнее, чем гонять бота от root):

useradd -r -s /usr/sbin/nologin -d /opt/ollama-bot botuser
chown -R botuser:botuser /opt/ollama-bot
chmod 600 /opt/ollama-bot/.env

Файл /etc/systemd/system/ollama-bot.service:

[Unit]
Description=Telegram bot on local Ollama model
After=network.target ollama.service
Wants=ollama.service

[Service]
Type=simple
User=botuser
Group=botuser
WorkingDirectory=/opt/ollama-bot
EnvironmentFile=/opt/ollama-bot/.env
ExecStart=/opt/ollama-bot/venv/bin/python /opt/ollama-bot/bot.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

After=network.target ollama.service и Wants=ollama.service нужны, чтобы бот стартовал после того, как поднялась сама Ollama — иначе первый запрос после перезагрузки сервера может упасть с ошибкой соединения.

Включаем и запускаем:

systemctl daemon-reload
systemctl enable --now ollama-bot.service
systemctl status ollama-bot.service
journalctl -u ollama-bot.service -f

Если сервис падает в цикл рестартов — почти всегда это неверный TELEGRAM_TOKEN, недоступная Ollama на 11434 или опечатка в пути ExecStart; всё это видно в journalctl за первые секунды после старта.

Что можно доработать

Рабочий вариант выше сознательно простой — реальные проекты обычно дорастают до следующего:

Что доработатьЗачем
stream: true + постепенное редактирование сообщенияответ виден по мере генерации, а не одним куском в конце
История диалога (контекст)модель помнит предыдущие сообщения в рамках чата, а не отвечает на каждое как на новое
Очередь запросовесли модель одна, а пользователей несколько — без очереди параллельные запросы будут конкурировать за CPU/GPU и тормозить друг друга
Таймауты и ретраи к Ollamaгенерация на CPU может занимать десятки секунд, дефолтный timeout в HTTP-клиенте это нужно закладывать заранее
Логирование в файл с ротациейjournalctl хранит логи ограниченное время, для истории обращений лучше отдельный файл

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть Ollama

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Частые вопросы

Ollama и бот обязательно должны быть на одном сервере?

Нет, но проще и быстрее — да: локальный HTTP-запрос на 127.0.0.1:11434 не упирается в сеть. Если разносите на разные машины, замените OLLAMA_URL на адрес другого сервера и закройте порт 11434 файрволом от внешнего доступа — Ollama по умолчанию не требует авторизации.

Что если бот не отвечает, а Ollama работает?

Проверьте journalctl -u ollama-bot.service -f во время отправки сообщения — если запрос вообще не долетает до скрипта, вероятная причина в токене или в том, что Telegram API недоступен из региона сервера без прокси. Если запрос доходит, но зависает — увеличьте timeout в httpx.AsyncClient, генерация на слабом CPU может быть медленной.

Можно ли обойтись без python-telegram-bot и написать проще?

Да, через прямые запросы к Telegram Bot API (getUpdates/sendMessage по HTTP) без сторонних библиотек — но тогда самому придётся реализовывать long polling, обработку ошибок сети и повторные попытки, а python-telegram-bot уже это делает за вас.

ALLOWED_CHAT_IDS — это надёжная защита?

Для личного использования и небольшой группы доверенных людей — да, если токен бота не утёк и порт Ollama не торчит наружу. Для публичного продукта нужна полноценная авторизация с базой пользователей, а не список ID в .env.

Как посмотреть, сколько ресурсов ест модель во время диалога?

Общая нагрузка на CPU/RAM в реальном времени видна через htop или nvidia-smi (если модель на GPU); для отдельного мониторинга под нагрузкой от Ollama есть гайд по мониторингу нагрузки локальной LLM.

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.