No description
  • Python 98.8%
  • Dockerfile 0.7%
  • Mako 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
hotcaller 3af65a382e fix: show circle id on the /circles delete/restore button
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.
2026-09-14 15:06:45 +03:00
data mvp 2026-09-07 17:44:59 +03:00
docker mvp 2026-09-07 17:44:59 +03:00
migrations feat: anti-annoyance changes 2026-09-14 14:01:33 +03:00
src/circlebot fix: show circle id on the /circles delete/restore button 2026-09-14 15:06:45 +03:00
tests feat: anti-annoyance changes 2026-09-14 14:01:33 +03:00
.dockerignore mvp v1.2 2026-09-08 10:16:36 +03:00
.env.example feat: anti-spam system 2026-09-10 17:46:42 +03:00
.gitattributes mvp v1.2 2026-09-08 10:16:36 +03:00
.gitignore mvp 2026-09-07 17:44:59 +03:00
alembic.ini mvp 2026-09-07 17:44:59 +03:00
docker-compose.dev.yml mvp v1.2 2026-09-08 10:16:36 +03:00
docker-compose.yml mvp v1.3 2026-09-08 11:30:50 +03:00
Dockerfile mvp v1.2 2026-09-08 10:16:36 +03:00
pyproject.toml mvp 2026-09-07 17:44:59 +03:00
README.md feat: anti-annoyance changes 2026-09-14 14:01:33 +03:00
requirements.txt mvp v1.2 2026-09-08 10:16:36 +03:00

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

Быстрый старт

  1. Создать бота у @BotFather:

    • /newbot - получить токен;
    • /setprivacy - Disable (иначе бот не видит обычные сообщения в группах)
  2. Создать закрытую группу модераторов, добавить туда бота. Узнать её chat_id: отправить в этой группе команду /id (бот ответит chat_id, начинается с -100). Свой user_id — тоже из /id в личке с ботом.

  3. Заполнить .env (за основу — .env.example):

    BOT_TOKEN=...
    ADMIN_IDS=11111111,22222222
    MOD_CHAT_ID=-1001234567890
    # на сервере обязательно задать свой — иначе используется дефолтный `bot`
    POSTGRES_PASSWORD=...
    
  4. Запустить:

    docker compose up -d --build
    docker compose logs -f bot     # видно "applying database migrations" и старт polling
    
  5. Наполнить базу кружков: администратор (ADMIN_IDS) отправляет боту в личку несколько видео-кружков — они сразу попадают в базу.

  6. Добавить бота в рабочие чаты.

Разработка

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 кружков привязан к токену бота: при смене токена базу кружков нужно наполнить заново.