Телеграм-бот, который ведёт таймлайн хакатона в отдельной теме группового чата: следит за дедлайнами, напоминает, собирает документы и складывает всё в репозиторий.
Один бот — много хакатонов. Каждый привязан к своей теме форума
(chat_id + message_thread_id), поэтому «ЛЦТ 26» и «Вайбатон на Руби» живут
рядом и не мешают друг другу.
Живая карточка. Одно закреплённое сообщение в теме, которое бот редактирует, а не пересылает: статус, прогресс-бар, обратный отсчёт до сдачи, что дальше, сколько человек подтвердило явку. Обновляется раз в минуту, чат не засоряет.
Таймлайн. Этапы с типами (регистрация, тех-чек, старт, чек-поинт, менторская, код-фриз, сдача, защита, результаты, афтепати), утренний дайджест, предупреждение о накладках.
Напоминания приходят за 3 дня, сутки, 3 часа, час и 15 минут до этапа (у дедлайна сдачи есть ещё и за 30 минут). Ступени, которые уже прошли на момент создания этапа, просто не срабатывают — добавил этап за два часа, получишь только две последние.
Заполнение с афиши и из файлов. /new с фотографией условий — vision-модель
вытаскивает даты, площадку, ссылки и весь набор этапов. Приложенные PDF, DOCX и
текстовые файлы бот читает и разбирает наравне с текстом сообщения. Сканированный
PDF без текстового слоя честно попросит скриншот. Чего не нашёл — спросит, и на
вопросы можно ответить обычным реплаем.
Свободный текст. Упомяни бота и скажи словами: «перенеси защиту на 19:30», «добавь код-фриз в пятницу 18:00», «сколько осталось до сдачи». Если данных не хватает — переспросит, а не выдумает.
Явка. Под напоминаниями кнопки ✅ / ⏰ / ❌ — без тегов и шума. Поимённый пинг
только по явной команде /ping.
GitHub. /repo new создаёт <название>_<год> в организации и заливает
docs/ с README, где таймлайн уже в виде таблицы. /repo attach прикрепляет
существующий репозиторий, чтобы ссылка была под рукой в нужной теме.
Календарь. /ics отдаёт файл, а http://<host>:9999/ics/<slug>.ics — живую
ленту: подписался один раз, правки таймлайна приезжают сами.
Google Календарь. Необязательная интеграция поверх этого: бот раскладывает
этапы всех хакатонов в один ваш календарь. Календарь вы создаёте сами и даёте
сервис-аккаунту бота право «Внесение изменений в мероприятия» — владелец вы, бот
только пишет события. Календарь общий, поэтому хакатон видно прямо в заголовке:
🏁 Сдача проекта · ЛЦТ 26. Дальше он шарится команде обычным гугловым способом.
Пока GOOGLE_CALENDAR_ID пуст, интеграция выключена целиком и бот ведёт себя
ровно как раньше. Настройка по шагам — docs/google-calendar.md.
Мудрость дня. /wisdom (он же /совет, /имба) — абсурдный совет в жанре
мотивационных карточек. В ответ на чьё-то сообщение — пингует человека и просит
его версию. Работает и словами: «дай совет», «расскажи имбу».
Стикеры. С вероятностью 25% после своего сообщения бот кидает случайный
стикер из пака. Настраивается через STICKER_SET и STICKER_CHANCE.
Помнит, с кем говорит. Профиль привязан к telegram id, а не к нику: ник можно
сменить, id нельзя. Личность (id, ник, имя, счётчик сообщений) записывается сама
на каждое сообщение, которое бот видит — иначе на вопрос «а Саня кто?» отвечать
было бы нечем, Саня бота никогда не тегнет. Характер и род занятий пишутся только
когда сказали явно: «запомни, я бэкендер на го». Факты дополняются, а не затираются.
/whois — посмотреть, /forgetme — стереть.
Влезает в разговор. С вероятностью BANTER_CHANCE (по умолчанию 25%) бот
отвечает на сообщение, в котором его не упоминали, глядя на последние
BANTER_CONTEXT реплик темы. Между такими репликами держится пауза
BANTER_COOLDOWN_SECONDS, чтобы всплеск сообщений не превратился в монолог.
Работает во всех темах, включая General: разговор чаще идёт там, а не в теме
хакатона. BANTER_EVERYWHERE=false вернёт его только в темы с хакатоном —
это же переключатель и для реакций. Модель может вернуть ПРОПУСК и промолчать, если
влезать не к месту.
Ставит реакции. С вероятностью REACTION_CHANCE (по умолчанию 25%) бот
молча вешает на сообщение эмодзи — 🤡 на «задеплою в прод в пятницу», 🤓 на
зануду, 🎉 на победу, 😭 на потерянный код. Список доступных эмодзи задан в
agent/reaction.py: телеграм принимает только фиксированный набор, а чат может
сузить его ещё сильнее. Ответ приходит обычным текстом и проверяется по списку
в коде, а не через enum-схему: structured output у Ollama едет на tool calling,
и модели на нём спотыкаются — на «го спать уже три часа ночи» enum сжигал обе
попытки, а тот же вопрос обычным текстом с первого раза отвечал 🥱.
Модель может не выбрать ничего — на «созвон в 15:00» реакции не будет, и в этом смысл.
Реакции ставятся на любое сообщение, которое бот прочитал, — и на те, где его
позвали, и на те, где не звали. Пауза REACTION_COOLDOWN_SECONDS общая на оба
случая: реакция, появляющаяся на каждом втором сообщении, перестаёт что-либо
значить, и неважно, каким путём она там оказалась.
Оба этих пути никогда не смотрят вложения. Документы и фото читаются только когда бота упомянули или ответили на его сообщение — сообщение с файлом пропускается целиком, даже если к нему есть подпись.
Промпты лежат в prompts/ обычным текстом и перечитываются на лету —
поправил файл, следующее сообщение уже с новым характером, перезапуск не нужен.
| файл | что задаёт |
|---|---|
prompts/persona.md |
как бот разговаривает и как огрызается |
prompts/wisdom.md |
генератор шуточных советов |
prompts/banter.md |
правила незваной реплики; идёт в дополнение к persona.md |
prompts/reaction.md |
по какому принципу выбирается эмодзи-реакция |
Блоки в <!-- --> вырезаются перед отправкой в модель — там можно держать
заметки для себя. Если файл удалить, включится встроенный вариант из исходников.
Запасной ключ. Если задан OLLAMA_API_KEY_FALLBACK, каждый вызов модели
оборачивается в FallbackModel: запрос, упавший с ошибкой провайдера, тут же
повторяется со вторым ключом. Это покрывает выбранную квоту (429) и отозванный
ключ (401) — то есть случаи, когда перестал работать ключ, а не сама модель.
Запасной ключ подставляется только для Ollama: отправить его в другого
провайдера значило бы превратить один упавший запрос в два.
Python 3.13 · aiogram 3 · pydantic-ai поверх Ollama Cloud · SQLAlchemy 2 async + SQLite · FastAPI. Без Docker, без внешней очереди, без отдельного планировщика: всё живёт в одном процессе и одном файле базы.
cp .env.example .env # заполнить BOT_TOKEN и ключи
uv sync
uv run python -m hackbotБот поднимается на long polling, веб-страница и .ics-ленты — на WEB_PORT
(по умолчанию 9999).
- У @BotFather: Bot Settings → Group Privacy → Turn off — иначе бот не увидит упоминания в реплаях и вложения без команды.
- Добавить в группу администратором с правами «Закреплять сообщения» и «Управлять темами».
- Зайти в нужную тему и написать
/new Название хакатона.
| Переменная | Смысл |
|---|---|
BOT_TOKEN |
токен от @BotFather |
BOT_ADMIN_IDS |
кому можно менять данные; пусто = админам чата |
OLLAMA_API_KEY, OLLAMA_BASE_URL |
Ollama Cloud, OpenAI-совместимый /v1 |
OLLAMA_API_KEY_FALLBACK |
запасной ключ: подхватывается, когда основной отвечает ошибкой |
LLM_MODEL |
ReAct-агент, нужен tool calling |
LLM_VISION_MODEL |
разбор афиш, нужен приём картинок |
LLM_FUN_MODEL |
генератор шуток; вынесен отдельно ради качества русского |
GITHUB_TOKEN, GITHUB_ORG, GITHUB_PRIVATE |
создание и наполнение репозиториев |
GOOGLE_CALENDAR_ID |
идентификатор календаря; пусто = интеграция выключена |
GOOGLE_CREDENTIALS_FILE |
JSON сервис-аккаунта; лежит в data/, в git не уезжает |
GOOGLE_CALENDAR_SYNC_MINUTES |
как часто делать полную сверку с календарём |
TZ_DEFAULT |
часовой пояс по умолчанию для новых хакатонов |
WEB_PORT, WEB_PUBLIC_URL |
порт и внешний адрес для ссылок в .ics |
CARD_REFRESH_SECONDS |
как часто перерисовывать закреплённую карточку |
STICKER_SET, STICKER_CHANCE |
пак стикеров и вероятность; 0 выключает |
BANTER_CHANCE |
шанс влезть в чужой разговор; 0 выключает |
BANTER_COOLDOWN_SECONDS, BANTER_CONTEXT |
пауза между репликами и глубина контекста |
BANTER_EVERYWHERE |
false — оживать только в темах с хакатоном; по умолчанию работает везде |
REACTION_CHANCE |
шанс повесить эмодзи на сообщение; 0 выключает |
REACTION_COOLDOWN_SECONDS |
пауза между реакциями в одной теме |
src/hackbot/
├── config.py настройки из .env
├── db/ модели и сессии SQLAlchemy
├── domain/
│ ├── enums.py типы этапов, статусы, виды ссылок
│ ├── timeutils.py разбор дат и русское форматирование
│ └── services/ вся бизнес-логика
├── agent/ pydantic-ai: извлечение с афиш, ReAct, генератор шуток
├── render/ сборка сообщений Telegram
├── bot/ тонкие хендлеры aiogram
├── scheduler/ цикл напоминаний, дайджестов и бэкапов
└── web/ FastAPI: страница таймлайна и .ics
Правило слоёв: хендлер ничего не знает о базе, а инструменты агента дёргают те же сервисы, что и слэш-команды. Поэтому новая функция сразу доступна тремя способами — командой, кнопкой и обычной фразой.
Времена в базе всегда UTC, в часовой пояс хакатона переводит только слой отрисовки — из-за этого переход на летнее время нигде не участвует в арифметике.
sudo cp deploy/hackbot.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now hackbot
journalctl -u hackbot -fБаза лежит в data/hackbot.db, копия снимается раз в сутки в data/backups/
и хранится две недели.