- Python 98.8%
- Dockerfile 0.7%
- Mako 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Video notes can't have captions, and the button text never included the id (only callback_data did), so there was no way to read a circle's id off /circles at all. |
||
| data | ||
| docker | ||
| migrations | ||
| src/circlebot | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| alembic.ini | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
CuprekBot
Тгбот, который следит за матом в чатах и в ответ иногда присылает заранее одобренный круок. Пользователи присылают боту свои кружки, модераторы принимают/отклоняют их кнопками.
Как работает
Реакция на мат
-
Бот читает все сообщения в группах, куда добавлен.
-
Каждое сообщение проверяется на русский/английский мат — с учётом обфускации (
б л и н,б.л.и.н.,блииин,бл*н,блиН,blin,p a n c a k e,p4ncake, …). -
Счётчик матных сообщений ведётся отдельно на каждую пару (чат, пользователь) за день (граница суток — по
TIMEZONE, по умолчанию МСК). Считаются сообщения, а не количество мата в них. -
Пока матных сообщений немного, ничего не происходит, но если начинается превышение...
-
Начиная со следующего сообщения роллится шанс:
шанс(%) = PROFANITY_BASE_CHANCE + (счётчик - (FREE + 1)) * PROFANITY_STEP, кап на 100%По дефолту 4-е сообщение - 1%, 5-е - 1.5%, 6-е - 2%, ...
-
Три числа кривой (
PROFANITY_BASE_CHANCE,PROFANITY_FREE_MESSAGES,PROFANITY_STEP) — это дефолты из.env. Админ может переопределить любое из них на лету командой/config(см. ниже), без пересборки; переопределение лежит в БД и переживает рестарт. -
Если шанс срабатывает, бот отвечает на сообщение случайным кружком из базы. После этого пользователь на сегодня в этом чате больше не отслеживается — один кружок в день на чат. В трёх разных чатах можно получить три кружка за день.
-
Для user-id из
GUARANTEED_CIRCLE_IDSкружок приходит на КАЖДОЕ матное сообщение — в обходPROFANITY_FREE_MESSAGES, шанса и капа «один кружок в день на чат».ALLOWED_CHAT_IDSдля них по-прежнему действует. -
Антиспам-тормоз: если один пользователь присылает
PROFANITY_SPAM_MESSAGES+ матных сообщений заPROFANITY_SPAM_WINDOWсекунд (по дефолту 3 за 60 с), бот ставит на сообщение реакцию 🤡 и кружок не шлёт — в обход шанса, дневного счётчика иGUARANTEED_CIRCLE_IDS. Как только темп падает ниже порога, поведение возвращается к обычному. Счётчик окна живёт в памяти процесса (скользящее окно, чистится лениво) и сбрасывается при рестарте.
Предложка
- Любой пользователь присылает боту в личку кружок.
- Кружок уходит в закрытый чат модераторов с кнопками принять/отклонить.
- Принятый кружок попадает в общую базу и может выпасть как случайный.
- Автор получает уведомление о решении.
- Дубликаты отклоняются автоматически.
- Больше
MAX_PENDING_PER_USERкружков «на модерации» от одного пользователя не принимается. - Кружки от
ADMIN_IDSв личке добавляются в базу сразу (сид стартового набора), еслиADMIN_DM_AUTO_ACCEPT=true.
Команды администратора (только ADMIN_IDS, в личке с ботом)
-
/circles— бот присылает каждый активный кружок с кнопкой 🗑 Удалить кружок (до 20 за раз, новые сверху). Удаление мягкое:is_active=false, кнопка меняется на ♻️ Вернуть кружок. -
/circles all— то же, но вместе с уже удалёнными (у них кнопка ♻️). -
/rmcircle <id>— удалить кружок по номеру из/circles. -
/status— состояние деплоя: аптайм,@usernameи id бота, версия сборки (GIT_SHA), пинг базы, счётчики кружков и заявок, возраст heartbeat. Проверить сервер с телефона, не заходя по ssh. -
/config— показать текущую кривую «мат → кружок» (эффективные значения, дефолты из.env, пример). Поменять на лету:/config chance 2.0— базовый шанс, % (PROFANITY_BASE_CHANCE);/config free 5— сколько матных сообщений в день не считаются (PROFANITY_FREE_MESSAGES);/config step 1.0— прибавка за каждое следующее сообщение, % (PROFANITY_STEP);/config spam 3— порог антиспам-тормоза, матных сообщений (PROFANITY_SPAM_MESSAGES);/config spamwindow 60— окно антиспам-тормоза, секунд (PROFANITY_SPAM_WINDOW);/config reset [chance|free|step|spam|spamwindow]— вернуть параметр (или все) к значению из.env.
Переопределения хранятся в таблице
bot_settings, применяются со следующего матного сообщения и переживают рестарт. -
На карточке модерации у принятого кружка тоже есть кнопка 🗑 — модератор может откатить своё «Принять».
-
/chats— список чатов, где бот сейчас состоит: название + id, и пометки, если для чата включены исключения (guaranteed off,chance X%)./chats all— вместе с чатами, откуда бота удалили/он вышел. -
/chatguaranteed <chat_id> disable— отключить бесплатный кружок для «гарантов» именно в этом чате (в остальных чатах и в самом списке/guaranteedничего не меняется);enable— вернуть по умолчанию; без аргументов — список исключений. -
/chatchance <chat_id> 5.0— свой базовый шанс (%) для чата, вместо глобального из/config chance;step/freeпо-прежнему общие./chatchance <chat_id> reset— вернуть глобальное значение; без аргументов — список чатов с переопределением. -
/circleexcept add <circle_id> <chat_id>— запретить конкретный кружок в конкретном чате;rm— снять запрет;/circleexcept list circle <circle_id>//circleexcept list chat <chat_id>— посмотреть исключения с одной или другой стороны.Id чатов и кружков берутся из
/chatsи/circles; настройки хранятся в таблицахchatsиcircle_chat_exceptionsи переживают рестарт. Чат появляется в/chatsсам по себе, как только бот получит из него любое сообщение (или сразу — если бота только что добавили/удалили).
Управление доступом (только админ, в личке с ботом)
Списки админов и «гарантированных» правятся прямо из телеги — редактировать .env
и пересобирать контейнер больше не нужно.
/admins— показать список;/admins add 111 222— добавить;/admins rm 111— убрать./guaranteed— то же для списка тех, кому кружок приходит на каждое матное сообщение.- Id можно перечислять через пробел или запятую. Свой id — из
/id.
Значения из .env (ADMIN_IDS, GUARANTEED_CIRCLE_IDS) остаются неудаляемыми
(в списке помечены 🔒): иначе админ мог бы снести всех админов, включая себя, и вернуть
доступ можно было бы только через psql на сервере. Всё, что добавлено командой, лежит
в таблице managed_ids и переживает рестарт контейнера.
Стек
- Python 3.12, aiogram 3 (long polling)
- PostgreSQL 16, SQLAlchemy 2.0 (async) + asyncpg, миграции Alembic
- Детектор мата — свой, на нормализации + curated-regex из
data/*.txt(без ML-зависимостей) - Docker + docker-compose
Быстрый старт
-
Создать бота у @BotFather:
/newbot- получить токен;/setprivacy- Disable (иначе бот не видит обычные сообщения в группах)
-
Создать закрытую группу модераторов, добавить туда бота. Узнать её
chat_id: отправить в этой группе команду/id(бот ответитchat_id, начинается с-100). Свойuser_id— тоже из/idв личке с ботом. -
Заполнить
.env(за основу —.env.example):BOT_TOKEN=... ADMIN_IDS=11111111,22222222 MOD_CHAT_ID=-1001234567890 # на сервере обязательно задать свой — иначе используется дефолтный `bot` POSTGRES_PASSWORD=... -
Запустить:
docker compose up -d --build docker compose logs -f bot # видно "applying database migrations" и старт polling -
Наполнить базу кружков: администратор (
ADMIN_IDS) отправляет боту в личку несколько видео-кружков — они сразу попадают в базу. -
Добавить бота в рабочие чаты.
Разработка
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# быстрые юнит-тесты (без БД)
pytest tests/test_chance.py tests/test_profanity.py
# полный набор — поднять Postgres (repo-тесты создадут отдельную БД circlebot_test).
# Порт 5432 наружу открывает только dev-оверлей, в базовом compose его нет.
# Если 5432 уже занят другим проектом -- задать DEV_PG_PORT.
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres
export DATABASE_URL=postgresql+asyncpg://bot:$POSTGRES_PASSWORD@localhost:${DEV_PG_PORT:-5432}/circlebot
pytest
ruff check src tests
Запуск бота локально (нужен доступный Postgres и .env в корне):
export DATABASE_URL=postgresql+asyncpg://bot:$POSTGRES_PASSWORD@localhost:5432/circlebot
alembic upgrade head
python -m circlebot
Обновление зависимостей
pyproject.toml — источник правды для диапазонов версий, requirements.txt —
зафиксированный результат их разрешения (его и ставит Dockerfile, поэтому сборка
воспроизводима и слой с зависимостями кэшируется). После правки зависимостей в
pyproject.toml пересобрать лок в контейнере, чтобы пины соответствовали Linux:
docker run --rm -v "$PWD":/w -w /w python:3.12-slim-bookworm \
sh -c "pip install -q uv && uv pip compile pyproject.toml -o requirements.txt"
Настройка детектора мата
Списки и regex-паттерны — в data/:
| файл | что это |
|---|---|
ru_squeezed.txt |
корни RU-мата без границ слова (ловят разбивку типа с л о в о, с.л.о.в.о) |
ru_spaced.txt |
RU-паттерны с \b (для корней вроде еб, бля, сук) |
ru_translit.txt |
RU-мат латиницей (nahuy, pizdec) |
ru_whitelist.txt |
исключения (страхуй, психуй, …) |
en_squeezed.txt / en_spaced.txt / en_whitelist.txt |
то же для английского |
После правки — перезапустить бота (docker compose restart bot). Изменения желательно
закрывать тестами в tests/test_profanity.py.
Структура
src/circlebot/
__main__.py точка входа (Bot + Dispatcher + polling)
config.py настройки из .env (pydantic-settings)
logging.py формат логов (всегда UTC, суффикс Z)
health.py `python -m circlebot.health` — healthcheck контейнера
db/ models, engine, repo (запросы)
services/
profanity.py детектор мата
chance.py формула вероятности
runtime_config.py кривая: дефолт из .env + переопределение из bot_settings
access.py IdRegistry: .env-роль + managed_ids, кэш в памяти
locks.py KeyedLock — сериализация сценария по (chat, user)
spam.py счётчик матного спама в скользящем окне (в памяти)
alerts.py WARNING+ из логов -> чат модераторов
heartbeat.py пинг БД + heartbeat-файл для healthcheck
middlewares/
db_session.py AsyncSession на апдейт
handlers/
profanity_watch.py мат -> шанс -> кружок
submissions.py приём кружков в личке
moderation.py кнопки принять/отклонить
circles.py /circles, /rmcircle, /circleexcept — кружки и их чат-исключения
chats.py /chats, /chatguaranteed, /chatchance — реестр чатов и их настройки
config.py /config — правка кривой на лету
idlists.py /admins, /guaranteed — правка списков id
common.py /start, /id
status.py /status — состояние деплоя
migrations/ Alembic
data/ словари/паттерны детектора
Деплой на сервер
Сборка идёт прямо на сервере, реестр не нужен. Нужен только Docker с compose v2
(docker compose version; для длинной формы env_file: требуется ≥ 2.24).
git clone <repo> cuprek && cd cuprek
cp .env.example .env && nano .env # BOT_TOKEN, ADMIN_IDS, MOD_CHAT_ID, POSTGRES_PASSWORD
GIT_SHA=$(git rev-parse --short HEAD) docker compose up -d --build
docker compose ps # bot должен стать healthy в течение ~1 минуты
docker compose logs -f bot
GIT_SHA необязателен, но с ним /status показывает, какая сборка сейчас живая.
Удобно завернуть обновление в скрипт:
git pull
GIT_SHA=$(git rev-parse --short HEAD) docker compose up -d --build
docker image prune -f # подчистить повисшие слои прошлой сборки
Проверить, что всё живо, не заходя в логи:
docker compose ps # healthy / unhealthy по heartbeat-файлу
docker compose exec bot python -m circlebot.health
docker stats --no-stream # фактическое потребление памяти
Что уже настроено, чтобы сервис не разрастался и не падал молча:
- Логи ограничены — 10 МБ × 5 файлов на контейнер, иначе
json-fileрастёт бесконечно и со временем забивает диск. - Имя проекта зафиксировано (
name: cuprekв compose), поэтому том всегдаcuprek_pgdataнезависимо от имени каталога с чекаутом — новый пустой том при переносе не появится. - Postgres не публикует порт наружу — только внутренняя сеть compose. Для доступа
из шелла:
docker compose exec postgres psql -U bot circlebot. - Лимиты памяти — 512 МБ на бота, 512 МБ на Postgres, и
max_connections=30вместо стоковой сотни. Бот в покое занимает ~178 МБ, из них ~121 МБ — сам aiogram (он строит pydantic-модели на весь Telegram API при импорте); лимит стоит с запасом на всплески, а не впритык. - Слой зависимостей кэшируется и запинен (
requirements.txt) — правка кода пересобирает образ за ~12 секунд вместо минуты, и одна и та же ревизия всегда собирается с одними и теми же версиями пакетов. - Образ не от root — процесс идёт под uid 10001.
- Healthcheck по heartbeat — бот раз в
HEARTBEAT_INTERVALсекунд пингует Postgres и трогает файл; если событийный цикл завис или база отвалилась, контейнер уходит вunhealthy, даже если процесс формально жив. - Алерты в Telegram — записи уровня
ALERT_LEVELи выше дублируются вALERT_CHAT_ID(по умолчаниюMOD_CHAT_ID), так что о поломке видно без ssh. - Бэкап базы:
docker compose exec -T postgres pg_dump -U bot circlebot > dump.sql.
Чтение логов удалённо
Всё пишется в stdout, файлов с логами нет — только docker compose logs.
Время всегда UTC и помечено суффиксом Z: сервер обычно живёт в UTC, а «сутки»
бота считаются по TIMEZONE, и без пометки эти два времени не различить.
docker compose logs -f bot # хвост в реальном времени
docker compose logs --since 1h bot # за последний час
docker compose logs --tail 200 bot # последние 200 строк
docker compose logs --since 24h bot | grep -E 'WARNING|ERROR|CRITICAL'
docker compose logs bot | grep 'circle=' # что, кому и с каким шансом улетело
Нормальный старт — четыре строки, по которым видно всё существенное:
2026-09-07 20:33:50Z INFO __main__: starting bot=@cuprekbot id=123456 build=80bade9 circles=42 admins=[111] mod_chat=-100... watched_chats=all guaranteed=none tz=Europe/Moscow
2026-09-07 20:33:50Z INFO __main__: profanity curve: free=3 base=1% step=0.5% overrides=none
2026-09-07 20:33:50Z INFO __main__: log alerts -> chat=-100... at WARNING
2026-09-07 20:33:50Z INFO __main__: polling started
Ошибки конфигурации — одна понятная строка вместо трейсбека в цикле рестартов:
| строка в логе | что делать |
|---|---|
BOT_TOKEN is invalid — Telegram rejected it as Unauthorized |
проверить BOT_TOKEN |
database unreachable at startup: ... |
проверить POSTGRES_PASSWORD и docker compose ps postgres |
circle pool is empty |
админу отправить боту кружки в личку |
heartbeat: database unreachable |
база отвалилась, контейнер уйдёт в unhealthy |
Штатная остановка логируется явно, поэтому рестарт не выглядит как падение:
2026-09-07 20:40:11Z INFO __main__: received SIGTERM, shutting down
2026-09-07 20:40:11Z INFO __main__: polling stopped
2026-09-07 20:40:11Z INFO __main__: shutdown complete
Записи уровня ALERT_LEVEL и выше дополнительно уходят в ALERT_CHAT_ID — с
дедупликацией и лимитом на частоту, чтобы цикл падений не превратился во флуд;
подавленные повторы приезжают счётчиком (+N more suppressed).
Заметки по эксплуатации
- Один инстанс бота (polling). Гонки в сценарии «мат → кружок» закрыты in-process
KeyedLock+ условнымUPDATE ... WHERE circle_sent = false. Для нескольких инстансов понадобятся advisory-локи Postgres. - Старые строки
daily_activityне чистятся автоматически — при желании добавить периодическийDELETE FROM daily_activity WHERE activity_date < current_date - 7. file_idкружков привязан к токену бота: при смене токена базу кружков нужно наполнить заново.