Skip to content

Repository files navigation

hackbot

Телеграм-бот, который ведёт таймлайн хакатона в отдельной теме группового чата: следит за дедлайнами, напоминает, собирает документы и складывает всё в репозиторий.

Один бот — много хакатонов. Каждый привязан к своей теме форума (chat_id + message_thread_id), поэтому «ЛЦТ 26» и «Вайбатон на Руби» живут рядом и не мешают друг другу.

Что он делает

Живая карточка. Одно закреплённое сообщение в теме, которое бот редактирует, а не пересылает: статус, прогресс-бар, обратный отсчёт до сдачи, что дальше, сколько человек подтвердило явку. Обновляется раз в минуту, чат не засоряет.

Таймлайн. Этапы с типами (регистрация, тех-чек, старт, чек-поинт, менторская, код-фриз, сдача, защита, результаты, афтепати), утренний дайджест, предупреждение о накладках.

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

Заполнение с афиши и из файлов. /new с фотографией условий — vision-модель вытаскивает даты, площадку, ссылки и весь набор этапов. Приложенные PDF, DOCX и текстовые файлы бот читает и разбирает наравне с текстом сообщения. Сканированный PDF без текстового слоя честно попросит скриншот. Чего не нашёл — спросит, и на вопросы можно ответить обычным реплаем.

Свободный текст. Упомяни бота и скажи словами: «перенеси защиту на 19:30», «добавь код-фриз в пятницу 18:00», «сколько осталось до сдачи». Если данных не хватает — переспросит, а не выдумает.

Явка. Под напоминаниями кнопки ✅ / ⏰ / ❌ — без тегов и шума. Поимённый пинг только по явной команде /ping.

Один хакатон — одна тема. Завести его в общей ветке форума нельзя ни командой, ни афишей, ни словами: карточка и напоминания оказались бы посреди общего чата. Признак — именно General форума, а не пустой thread_id: тот же None приходит из обычной группы без тем и из лички, и запрет по нему выключил бы бота везде. Хакатоны, привязанные к General до этого правила, остаются редактируемыми.

Удаление только через кнопки. /drop показывает список, /drop Название — сразу карточку подтверждения с числом этапов, ссылок, документов и людей, которые уедут вместе с ним. Удаляет только нажатие, и нажавшего проверяют по админам чата: клавиатуру видит вся тема. Просьба словами («снеси ЛЦТ») теперь тоже приводит к карточке, а не к немедленному сносу — раньше это был единственный путь, где данные исчезали вообще без проверки прав.

GitHub. /repo new создаёт <название>_<год> в организации и заливает docs/ с README, где таймлайн уже в виде таблицы. /repo attach прикрепляет существующий репозиторий, чтобы ссылка была под рукой в нужной теме.

Календарь. /ics отдаёт файл, а http://<host>:9999/ics/<slug>.ics — живую ленту: подписался один раз, правки таймлайна приезжают сами.

Google Календарь. Необязательная интеграция поверх этого: бот раскладывает этапы всех хакатонов в один ваш календарь. Календарь вы создаёте сами и даёте сервис-аккаунту бота право «Внесение изменений в мероприятия» — владелец вы, бот только пишет события. Календарь общий, поэтому хакатон видно прямо в заголовке: 🏁 Сдача проекта · ЛЦТ 26. Дальше он шарится команде обычным гугловым способом. Пока GOOGLE_CALENDAR_ID пуст, интеграция выключена целиком и бот ведёт себя ровно как раньше. Настройка по шагам — docs/google-calendar.md.

Стикеры. С вероятностью 25% после своего сообщения бот кидает случайный стикер из пака. Настраивается через STICKER_SET и STICKER_CHANCE.

Помнит, с кем говорит. Профиль привязан к telegram id, а не к нику: ник можно сменить, id нельзя. Личность (id, ник, имя, счётчик сообщений) записывается сама на каждое сообщение, которое бот видит — иначе на вопрос «а Саня кто?» отвечать было бы нечем, Саня бота никогда не тегнет. Факты дополняются, а не затираются. /whois — посмотреть, /forgetme — стереть.

Учится сам, без «запомни». Раньше факт попадал в профиль, только если кто-то прямо просил его запомнить — а так никто не делает, и профили стояли пустые. Теперь бот раз в OBSERVE_INTERVAL_MINUTES перечитывает накопившиеся реплики темы и раскладывает устойчивое по людям. Не на каждое сообщение: вызов модели на строку разорителен, а одна строка почти никогда не несёт факта, который переживёт день.

Наблюдение привязывается к telegram id, а не к имени в тексте. Транскрипт подписан id, модель обязана вернуть id из этого списка, а чужой — отбрасывается в коде. Факт под чужим именем хуже отсутствия факта: бот начинает уверенно рассказывать Сане то, что о себе говорил Кирилл.

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

Рассказанное прямо и подмеченное самим хранятся раздельно и в промпте помечены по-разному: после замечено: идут догадки, и боту сказано не выдавать их за факт и верить человеку, если тот поправляет. /whois показывает наблюдения отдельным списком со счётчиками, /forgetme наблюдения стирает только их, /forgetme — всё. OBSERVE_ENABLED=false возвращает старое поведение «только по просьбе».

Влезает в разговор. С вероятностью BANTER_CHANCE (по умолчанию 8%) бот отвечает на сообщение, в котором его не упоминали, глядя на последние BANTER_CONTEXT реплик темы. Между такими репликами держится пауза BANTER_COOLDOWN_SECONDS — десять минут, чтобы всплеск сообщений не превратился в монолог: со старыми 25% и паузой в полторы минуты бот выдавал до сорока реплик в час на тему и переставал быть участником разговора.

Транскрипт, который читает модель, пронумерован, и она отвечает в формате 3 | текст — номер той реплики, о которой на самом деле говорит. Реплай уходит именно туда. Раньше бот выбирал человека из пяти последних сообщений, а отвечал на последнее, часто чужое. Номер не распознан, вышел за границы или указывает на собственную реплику бота — реплай падает на сообщение-триггер, как и раньше.

Работает во всех темах, включая General: разговор чаще идёт там, а не в теме хакатона. BANTER_EVERYWHERE=false вернёт его только в темы с хакатоном — это же переключатель и для реакций. Модель может вернуть ПРОПУСК и промолчать, если влезать не к месту.

Ставит реакции. С вероятностью REACTION_CHANCE (по умолчанию 12%) бот молча вешает на сообщение эмодзи — 🤡 на «задеплою в прод в пятницу», 🤓 на зануду, 🎉 на победу, 😭 на потерянный код. Список доступных эмодзи задан в agent/reaction.py: телеграм принимает только фиксированный набор, а чат может сузить его ещё сильнее. Ответ приходит обычным текстом и проверяется по списку в коде, а не через enum-схему: structured output у Ollama едет на tool calling, и модели на нём спотыкаются — на «го спать уже три часа ночи» enum сжигал обе попытки, а тот же вопрос обычным текстом с первого раза отвечал 🥱.

Модель может не выбрать ничего — на «созвон в 15:00» реакции не будет, и в этом смысл.

Реакции ставятся на любое сообщение, которое бот прочитал, — и на те, где его позвали, и на те, где не звали. Пауза REACTION_COOLDOWN_SECONDS общая на оба случая: реакция, появляющаяся на каждом втором сообщении, перестаёт что-либо значить, и неважно, каким путём она там оказалась.

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

Характер бота

Восемь характеров, по одному на день. Цикл замыкается за восемь дней, так что день недели каждую неделю достаётся новому персонажу. Границу суток считаем в TZ_DEFAULT, а не в UTC: характер меняется в местную полночь.

день характер промпт аватарка
1 🤬 грубиян — прежний, тот же текст persona.md default.png
2 🎩 джентльмен — на «вы», уничтожает вежливо persona_gentleman.md pidjak.png
3 🪶 поэт — рифмой и метафорами persona_poet.md poet.png
4 🔬 учёный — всех поучает и ставит диагнозы persona_scientist.md nauka.png
5 💪 мотиватор — из бага делает точку роста persona_motivator.md motivation.png
6 🦴 пещерный — рычит по словарю persona_caveman.md caveman.png
7 🍌 миньон — ломаный русский с миньонским persona_minion.md minion.png
8 😏 извращенец — флиртует со всеми подряд persona_pervert.md pervert.png

Меняется только как бот говорит. Контракт по инструментам — когда звать get_state, как писать даты, что запоминать про людей — общий и лежит отдельно, в prompts/persona_rules.md: восемь копий одного правила разъезжаются на второй правке. У пещерного и миньона в промпте прописан словарь и требование оставлять в реплике русское слово-якорь, иначе понять их невозможно; конкретику — дату, время, название этапа — любой характер обязан называть прямо.

PERSONA_FORCE=poet прикалывает один характер (удобно проверять), PERSONA_OFFSET сдвигает цикл, PERSONA_ROTATION=false выключает ротацию совсем.

Аватарка меняется вместе с характером. Картинки лежат в assets/avatars/, планировщик сверяет характер дня с записью в KV и при расхождении грузит новую через setMyProfilePhoto. Telegram принимает туда только JPEG и не даёт переиспользовать file_id, поэтому файл каждый раз конвертируется Pillow'ом — прозрачность сводится на белый, вырезается центральный квадрат, результат кэшируется по mtime. Файла нет или Telegram отказал — аватарка просто остаётся прежней, и модуль берёт паузу, чтобы не долбить API на каждом пятнадцатисекундном тике.

Промпты лежат в prompts/ обычным текстом и перечитываются на лету — поправил файл, следующее сообщение уже с новым характером, перезапуск не нужен. Настройки из .env так не подхватываются: там нужен рестарт.

файл что задаёт
prompts/persona.md характер первого дня — грубиян
prompts/persona_<id>.md остальные семь характеров
prompts/persona_rules.md общий контракт по инструментам, приклеивается к любому характеру
prompts/banter.md правила незваной реплики; идёт в дополнение к характеру дня
prompts/reaction.md по какому принципу выбирается эмодзи-реакция
prompts/observe.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).

Настройка бота в Telegram

  1. У @BotFather: Bot Settings → Group Privacy → Turn off — иначе бот не увидит упоминания в реплаях и вложения без команды.
  2. Добавить в группу администратором с правами «Закреплять сообщения» и «Управлять темами».
  3. Зайти в нужную тему и написать /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 выключает
PERSONA_ROTATION false — не менять характер по дням
PERSONA_OFFSET, PERSONA_FORCE сдвинуть цикл или приколоть один характер по id
AVATARS_DIR, AVATAR_ENABLED папка с картинками характеров; false — не трогать аватарку
OBSERVE_ENABLED false — не учиться самому, запоминать только по просьбе
OBSERVE_INTERVAL_MINUTES, OBSERVE_MIN_NEW_LINES как часто и с какого объёма перечитывать тему
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/ и хранится две недели.

About

Telegram bot that runs a hackathon timeline inside a forum topic: deadlines, reminders, LLM poster parsing, docs and GitHub

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages