Skip to content

Repository files navigation

dr1wbot

CI License: AGPL v3

Telegram guest-бот: зовёшь @botname в любом чате — он отвечает текстом от Gemini, отрендеренным нативным Telegram Markdown. Работает и в личке, с админ-панелью на кнопках. Go + telego, в Docker.

Готовый инстанс, поднимать ничего не нужно: @dr0wbot.

Без БД: всё состояние — три JSON-файла рядом с бинарником (вайтлист, счётчики лимитов, настройки из панели). Контекст диалога живёт в памяти процесса и не переживает перезапуск — это осознанно, разговор в чате не то, что стоит хранить.

Что умеет

  • Отвечает по вызову в любом чате через guest mode — бот не состоит в группах и не видит их историю.
  • Продолжает разговор реплаем — по цепочке ответов, а не по чату или человеку: реплай на любой ответ бота продолжает ровно ту ветку, к которой он относится, кто бы ни ответил. Реплай на старый ответ ответвляется от него. Новый вопрос — чистая сессия.
  • Разбирает картинки, если их приложили к вопросу.
  • Ищет в интернете через DuckDuckGo, когда вопрос про «сейчас»: модель сама решает, что нужно посмотреть, и отвечает уже по найденному. Ключ и аккаунт не нужны.
  • Печатает ответ на глазах — сообщение правится по мере того, как модель пишет.
  • Ротирует пул ключей Google AI Studio и список моделей: упёрлись в лимит на одном — идёт на следующий.
  • Открывается публике с дневным лимитом на человека, потолком на весь бот, детектором флуда и варнами.
  • Банит по id или @username, на срок или навсегда: /ban @spammer 2ч, /ban 123456789.
  • Управляется из лички: панель с живой статистикой, проверкой ключей, списком людей и настройками, которые правятся кнопками без перезапуска.

Guest mode

Бот работает через Guest Mode (Bot API 10.0). Это значит, что его можно звать в чатах, где его нет — он не состоит в группе, не видит историю и не получает остальные сообщения.

Сначала включи guest mode, иначе бот не получит ни одного апдейта: @BotFather → мини-апп настроек бота → Guest Mode. Если не включено, при старте будет WARN в логе.

Запуск

cp .env.example .env

Заполни .env (минимум — TELEGRAM_BOT_TOKEN и GOOGLE_API_KEYS), затем:

docker compose up -d --build

Логи:

docker compose logs -f

Локально, без Docker:

go run ./cmd/bot

Модель

Единственный провайдер — Google AI Studio, через его OpenAI-совместимый эндпоинт generativelanguage.googleapis.com/v1beta/openai. Ключи бесплатные и без карты: aistudio.google.com/apikey.

По умолчанию LLM_MODEL=gemini-3.6-flash,gemini-3.5-flash — список, а не одно имя. Обе модели на free tier.

Ключи и лимиты

Квота free tier считается на ключ и на модель, поэтому бот держит пул ключей (GOOGLE_API_KEYS, через запятую) и обходит его сам:

  1. Запрос уходит на текущий ключ с первой моделью.
  2. Прилетело 429 — ключ откладывается на LLM_KEY_COOLDOWN (по умолчанию минута), запрос повторяется со следующим ключом.
  3. Попытка замолчала или соединение не поднялось — ключ не откладывается (виноват бэкенд, а не квота), запрос просто уходит на следующий.
  4. Кончились ключи — та же карусель начинается со второй модели.
  5. Кончилось всё — пользователь видит, что лимиты выбраны, и когда они сбросятся.

Времени на это отмерено двумя числами. LLM_TIMEOUT — сколько одна попытка может молчать, а не сколько она может длиться: каждый кусок стримингового ответа продлевает его, поэтому долгий ответ не обрывается, а зависший обрывается через LLM_TIMEOUT после последнего куска. LLM_TOTAL_TIMEOUT (по умолчанию два LLM_TIMEOUT) — бюджет всего вопроса на все попытки. Разделение здесь принципиально: с одним общим бюджетом первая же зависшая попытка съедала его целиком, и до остальных ключей дело не доходило — пул из десяти ключей работал как один. Новая попытка не начинается, если времени осталось меньше, чем на четверть попытки: такой запрос всё равно не успеет, а его ошибка про дедлайн только затрёт настоящую причину.

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

Ключи имеет смысл заводить в разных Google-аккаунтах — в пределах одного проекта квота общая, и второй ключ упрётся в тот же лимит.

Ошибка 400 или отказ модели ключи не жжёт: сломанный запрос сломан на любом ключе, он возвращается сразу. 5xx считается временным — его повторяют на другом ключе, но лимитом не называют.

Про размышления

Gemini 3 думает по умолчанию, и токены размышлений списываются из того же LLM_MAX_TOKENS, что и ответ. Поэтому дефолты здесь — LLM_MAX_TOKENS=4096 и LLM_REASONING_EFFORT=low: иначе короткий вопрос в чате может съесть весь бюджет на невидимые рассуждения и вернуть пустоту. Если ответы всё же обрываются, бот скажет об этом прямо и назовёт, что поднимать.

LLM_BASE_URL переопределяет эндпоинт, если нужно указать на что-то своё (например, локальную llama.cpp).

Картинки

По умолчанию выключены. У картиночных моделей Google нет free tier — ни у gemini-3.1-flash-image (Nano Banana 2), ни у gemini-3-pro-image (Nano Banana Pro), ни даже у старой gemini-2.5-flash-image. Нужен ключ от проекта с включённым биллингом.

Чтобы включить: IMAGE_MODEL, IMAGE_STORAGE_CHAT_ID и, если платный ключ отдельный, IMAGE_API_KEYS. Пул ключей ротируется так же, как текстовый. Пока IMAGE_MODEL пустой, на «нарисуй» бот отвечает, что не умеет.

Доступ

Guest mode делает бота доступным всему Telegram, и каждый вызов тратит токены с твоего ключа. Ограничение — вайтлист из трёх источников:

  • ADMIN_USER_IDS — админы. Всегда могут звать бота и управляют списком командами прямо в чате. Свой ID: @userinfobot.
  • ALLOWED_USER_IDS — эти люди зовут бота в любом чате.
  • ALLOWED_CHAT_IDS — открывает бота всем участникам конкретного чата. ID групп отрицательные. Учти: бот не состоит в чате, так что его ID надо знать заранее.

Достаточно совпадения по любому источнику. Если все три пустые — отвечаем всем, и при старте пишется предупреждение. Незалистенным бот не отвечает вообще, молча: в чате не остаётся следа.

Личка: /start и /admin

Кроме guest mode бот работает и как обычный бот в личке — это второй, отдельный вход.

/start — приветствие: как звать бота и сколько запросов у тебя осталось.

Любое другое сообщение в личке — обычный вопрос: бот отвечает так же, как на вызов в чате, с заглушкой и полным Markdown. Личка при этом не обход правил: вайтлист, лимиты, потолки и баны действуют ровно те же.

/admin — панель на инлайн-кнопках, только для ADMIN_USER_IDS. Экраны: Лимиты (состояние ключей и моделей + живая проверка ключей), Статистика, Доступ (вайтлист), Баны (кто забанен сейчас, с кнопкой «разбанить» на каждого), Настройки (кнопки со стоп-краном и ввод своих значений), Справка. Неадмин на /admin получает обычное приветствие — отказ по имени подтвердил бы, что панель существует.

Тексты используют премиум-эмодзи через <tg-emoji>, кнопки — через icon_custom_emoji_id: это отдельное поле кнопки, Telegram рисует эмодзи перед подписью. Сущности внутри текста кнопки действительно невозможны, но иконка живёт не в тексте, поэтому панель премиальная целиком.

Условие из документации: icon_custom_emoji_id доступен ботам, купившим юзернейм на Fragment, или в сообщениях, которые бот отправляет напрямую в личку, группу или супергруппу, если у владельца бота есть Telegram Premium. Панель — ровно второй случай. Если не выполнено ни одно условие, Telegram отвергает весь запрос — тогда сообщение автоматически переотправляется с обычными эмодзи в тексте, а иконки кнопок сворачиваются в подписи (📊 Лимиты). Панель не ломается ни в одном из вариантов.

Плюс style — у кнопки есть свой цвет (danger / success / primary), и он ничем не обусловлен. Покрашена одна кнопка, которую ищут глазами: стоп-кран публики — красный, когда выключает, зелёный, когда включает обратно. Разбан тоже зелёный.

Настройки из панели

В .env осталось то, что определяет, чем процесс является (токен, ключи, пути, характер варнов). Кнопками правится то, что определяет, как он ведёт себя с людьми: лимит на человека, потолок на бот, всплеск и его окно, длительность автобана, порог доверия, потолки токенов и длины, кэш, поиск, стриминг, флаг -s.

Тап по кнопке перебирает готовые значения по кругу — это быстрый путь. Кнопка «Ввести вручную» открывает список тех же настроек, но с вводом своего значения: панель спрашивает, следующее сообщение отвечает. Сроки пишутся так, как их произносят: 30с, 15м, 2ч, 7д, 1нед; голое число — минуты. Готовый список — это чья-то догадка о том, что кому понадобится, а 40 запросов в сутки и трёхдневный бан — совершенно нормальные числа, которых ни в одном переборе нет.

Отдельно стоп-кран: одна кнопка закрывает публику и запоминает лимит, чтобы обратно включить тем же числом, а не крутить по кругу до нуля.

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

Про лимиты Google

Эндпоинта «сколько квоты осталось» у Gemini API нет. Экран «Лимиты» показывает то, что бот наблюдал сам с момента запуска: сколько ключей свободно, сколько остывает после 429, сколько ответов дала каждая модель. Ответы на второй модели при нулях у первой — это и есть видимый признак, что дневная квота первой выбрана.

Кнопка «Проверить ключи» дёргает GET /models каждым ключом: это бесплатно и показывает, какие ключи вообще живы. Ключ, который отвечает там, всё ещё может упереться в лимит на генерации — счётчики разные.

Публичный доступ и защита от абуза

PUBLIC_DAILY_LIMIT=30 открывает бота всему Telegram: 30 запросов в сутки на человека, сброс в полночь UTC. 0 — бот приватный, как раньше.

Одного дневного лимита мало: скрипт выжрет его за две секунды и пойдёт за следующим аккаунтом. Поэтому сверху:

Механизм Что делает
Потолок на весь бот PUBLIC_GLOBAL_DAILY_LIMIT — сколько публика стоит в сутки суммарно. Без него тысяча новых аккаунтов по 30 запросов выжрет всё, не нарушив ни одного правила
Порог доверия id в Telegram выдаются по возрастанию, так что id выше NEW_ACCOUNT_ID_THRESHOLD — свежий аккаунт. Таким половина лимита, но никогда не ноль: свежесть это подозрение, а не приговор
Всплеск больше PUBLIC_BURST запросов за PUBLIC_BURST_WINDOW — варн и таймаут на PUBLIC_BAN_FOR
Варны висят PUBLIC_WARN_TTL (30 дней) и слетают. Набрал PUBLIC_MAX_WARNS живых — бан навсегда. Один неудачный вечер забывается, регулярность — нет
Молчание забаненному бот не отвечает вообще — ответ это и есть та обратная связь, которую ищет скрипт, а отправить его значит сделать запрос в Telegram за бота
Ручной бан /ban по id или @username, на срок или навсегда: детектор ловит только скорость, а мешать можно и в человеческом темпе
Один запрос на человека пока твой вопрос считается, второй не примут — иначе один вызывающий занял бы всех воркеров
Очередь больше MAX_QUEUE ожидающих — сразу отказ, без накопления висящих заглушек

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

Баны

/ban 123456789          # навсегда
/ban @spammer 2ч        # на два часа
/ban 123456789 7д       # на неделю
/unban @spammer
/bans                   # кто забанен сейчас

Без срока — навсегда: это то, что имел в виду человек, не написавший число, а бан, который тихо истекает, потому что никто не назвал срок, хуже отсутствия бана. Срок пишется как 30м, 2ч, 7д, 1нед (латиница тоже понимается); голое число — минуты; можно написать навсегда явно.

По @username бан ставится даже на того, кого бот ни разу не видел. У Telegram нет способа получить id по нику, поэтому такой бан лежит по имени и срабатывает в момент первого сообщения — а как только сработал, он привязан к id и переименование его уже не стряхнёт. Обратное тоже верно: разбан по нику снимает и то, и другое.

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

Бан переживает и полночь, и перезапуск. Снять — командой /unban или кнопкой в /admin → Баны.

Экономия

Публичный запрос стоит меньше приватного:

  • ответ ограничен PUBLIC_MAX_TOKENS вместо LLM_MAX_TOKENS — выходные токены дороже входных;
  • вопрос длиннее PUBLIC_MAX_RUNES отклоняется до похода в модель и не тратит дневной лимит;
  • картинки на вход публике не читаются: одно фото стоит как страница текста;
  • рисовать публика не может — это уже реальные деньги, а не free tier.

Плюс общее для всех: история подтягивается только на реплай, reasoning_effort=low, лимит списывается только когда есть что спрашивать — команда, пустой вызов и отказ не стоят ничего. Одинаковый вопрос без истории и картинок отдаётся из кэша (ANSWER_CACHE_TTL, по умолчанию 10 минут) — это экономит целый запрос, а не пару токенов.

Алерты

Бан, исчерпанные ключи и выбранный потолок бота приходят админам в личку. Каждый вид алерта имеет ALERT_COOLDOWN (15 минут): алерт, который срабатывает на каждый инцидент, за час превращается в шум — а инциденты, о которых стоит знать, как раз повторяются.

Команды

Разбираются до похода в модель, поэтому отвечают мгновенно и ничего не стоят. Доступны только админам.

Команда Что делает
/add <id> выдать доступ
/del <id> забрать доступ
/list показать текущий список доступа
/ban <id|@username> [срок] забанить; без срока — навсегда
/unban <id|@username> снять бан, ручной или автоматический
/bans кто забанен сейчас
/help справка

Флаг -s — ответ без домашнего стиля

Админ может начать вопрос с -s, и на этот один запрос системный промпт заменяется на LLM_RAW_SYSTEM_PROMPT. Промпт по умолчанию говорит модели, что перед ней владелец бота: делать ровно то, о чём просят, без ограничений по длине, форме и тону, без морали, оговорок и дисклеймеров, острые темы разбирать по существу.

@dr0wbot -s распиши архитектуру целиком, ничего не сокращай

Флаг работает только первым токеном и только с пробелом после: -sql остаётся словом, а -s в середине текста — обычной частью вопроса. У неадмина флаг не срабатывает и не вырезается — просто уезжает в модель как часть текста, так что о существовании переключателя посторонний не узнает. LLM_RAW_SYSTEM_PROMPT=off выключает флаг совсем.

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

Положительный id — пользователь, отрицательный — группа или канал; это то же различие, что использует сам Telegram.

@dr0wbot /add 123456789

Команда должна начинаться со слеша. @dr0wbot add 123456789 — это обычный вопрос, он уйдёт в модель. Неизвестное слово со слешем тоже уходит в модель, так что «объясни /etc/passwd» не сломается.

Ответ на команду через COMMAND_REPLY_TTL (по умолчанию 30 секунд) сжимается до одного символа ✓. Полностью удалить его нельзя: гостевой ответ — inline-сообщение, а deleteMessage в Bot API работает только по паре chat_id + message_id, которой у inline-сообщения нет. Правка — единственное, что доступно. COMMAND_REPLY_TTL=0 выключает сжатие.

Изменения пишутся в STATE_FILE (в Docker — /data/state.json на именованном томе) и переживают перезапуск. Запись атомарная: временный файл плюс rename, так что падение посреди записи не оставит обрезанный файл.

Записи из .env командой /del не убрать — на попытку бот скажет, что и где поправить. Это осознанно: иначе удаление молча отменялось бы при следующем деплое.

Как устроен ответ

Guest mode разрешает ответить на вызов ровно один раз, и этот ответ — inline-сообщение, чей id возвращается. Отсюда форма:

  1. Сразу отвечаем заглушкой ●●● — успеваем в дедлайн guest-запроса и показываем, что вызов принят.
  2. Идём в модель.
  3. Пока она пишет, правим то же сообщение обычным текстом — раз в STREAM_INTERVAL, с курсором ▍ в конце.
  4. Готовый ответ кладём туда же через editMessageText с rich_message.

Промежуточные правки идут без разметки намеренно: половина таблицы или незакрытый блок кода — это не валидный Markdown, и Telegram отвергнет такую правку целиком. Форматирование получает только законченный ответ. Если модель уходит в поиск, вместо обрывка фразы показывается 🔎 Ищу в интернете: <запрос> — иначе на глазах у читателя пропадал бы уже написанный текст.

Rich Message (Bot API 10.1) принимает Markdown напрямую — заголовки, списки, таблицы, блоки кода, цитаты, формулы — без экранирования MarkdownV2.

Что бы ни случилось, заглушка всегда доводится до финального состояния:

Отказ Что видит пользователь
Модель вернула ошибку ⚠️ Не получилось получить ответ от модели.
Лимиты выбраны на всех ключах и моделях ⚠️ Лимиты Google AI Studio исчерпаны…
Модель не уложилась в LLM_TOTAL_TIMEOUT ⚠️ Модель не ответила вовремя.
Занято дольше LLM_TIMEOUT (лимит MAX_CONCURRENT) ⚠️ Сейчас слишком много запросов.
Telegram отверг разметку тот же текст, но без форматирования
Позвали без вопроса подсказка, как звать

Правка идёт по контексту, отвязанному от обработчика: если бот выключается, уже отправленная заглушка всё равно доводится до ответа.

Поиск в интернете

Модель знает мир только до конца своего обучения — и, что хуже, не знает, что он закончился: на вопрос про «сейчас» она уверенно отвечает позапрошлогодним. Поэтому к каждому запросу добавляется сегодняшняя дата, а самой модели выдан инструмент web_search.

Дальше решает она: вопрос про новости, цены, курсы, версии, погоду или «кто сейчас» — вызывает поиск, получает выдачу DuckDuckGo со ссылками и пишет ответ уже по ней. Объяснение, код и математика идут мимо поиска.

DuckDuckGo выбран по одной причине: у него нет ни API-ключа, ни аккаунта, ни карты. Берётся та же HTML-выдача, которую отдаёт браузер, и парсится как HTML — со всей хрупкостью, которая из этого следует. Поэтому неудачный поиск не роняет ответ: модели так и сообщается, что посмотреть не вышло, и она отвечает по памяти, предупредив об этом.

Если выдача внезапно стала пустой, проверить парсер по живой странице:

LIVE_SEARCH=1 go test ./internal/websearch/ -run Live -v

Число походов в поиск за один вопрос ограничено (maxToolRounds, сейчас 2): цикл «поищу ещё раз» оплачивается каждым витком.

Выключается в /admin → Настройки или через SEARCH_ENABLED=false.

Контекст диалога

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

Так и должно быть в группе: предыдущий обмен там обычно чужой, и подтянуть его — значит ответить не на тот вопрос. Сама история живёт в памяти процесса не дольше MEMORY_TTL (30 минут) и не длиннее восьми реплик.

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

Что бот получает на вход

Только сообщение-вызов и, если это был reply, сообщение, на которое отвечали. Больше в guest mode ничего нет. Упоминание @botname из текста вырезается по UTF-16 offset'ам, которые присылает Telegram, — иначе смещения ломаются на кириллице и эмодзи.

Структура

Пакет Ответственность
cmd/bot сборка зависимостей, long polling, graceful shutdown
internal/config чтение и валидация env; все ошибки конфига сообщаются разом
internal/access вайтлист и его сохранение на диск
internal/admin команды /add, /del, /list, /ban, /unban, /bans
internal/keyring пул ключей: ротация и остывание после лимита
internal/llm клиент Google AI Studio: модели × ключи, стриминг, вызов инструментов
internal/websearch поиск в DuckDuckGo без ключа и аккаунта
internal/imagegen генерация картинок (по умолчанию выключена)
internal/quota лимиты публики, детектор всплесков, варны и баны по id и @username
internal/answers кэш ответов на одинаковые вопросы
internal/alert алерты админам в личку, с антиспамом
internal/settings настройки, которые правятся из панели, на диске
internal/menu личка: /start, /admin, кнопки
internal/tgemoji премиум-эмодзи в тексте и на кнопках, фолбэк на обычные
internal/reply заглушка → модель → правка, со всеми фолбэками
internal/mdtext вырезание упоминания, сборка промпта, обрезка ответа

Тесты

go test -race ./...

internal/reply тестируется на фейковых Telegram и LLM, internal/llm и internal/imagegen — на httptest (включая ротацию ключей и фолбэк на вторую модель), internal/keyring — на подменённых часах. Сеть не нужна.

Лицензия

GNU AGPL-3.0.

Коротко, что это значит:

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

Последний пункт — причина, по которой здесь именно AGPL, а не GPL. Обычная GPL требует раскрывать код при раздаче программы, а бот никто не раздаёт: его запускают у себя, а люди просто им пользуются. AGPL закрывает ровно эту дыру, добавляя то же требование для доступа по сети.

Это не юридическая консультация — полный текст в LICENSE.

About

Telegram guest-mode bot on Gemini: answers when summoned in any chat, with key rotation, public rate limits and an in-chat admin panel

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages