1
0
Fork 0
mirror of https://github.com/EDeev/chatping_abobot.git synced 2026-10-07 20:49:45 +03:00

Compare commits

...

3 commits
v3.1.0 ... main

Author SHA1 Message Date
737b3b57af Образы на git.deev.su вместо dcr.deev.su: реестр zot выведен из работы, копию из GHCR делает синхронизатор 2026-10-06 17:03:57 +03:00
af372fbdf0 README: версия 4 — PostgreSQL, ограничения больших чатов, /settings, /top, /month, учёт бота в группах, развёртывание 2026-10-06 11:26:12 +00:00
a2a0adcf8e AboBot 4.0: PostgreSQL, обработчики по роутерам, HTML-разметка, новые команды
База — PostgreSQL (asyncpg) вместо четырёх файлов SQLite с таблицей на каждый чат:
chats, users, members, chat_stats и member_stats с полем «период» — месяцы хранятся, а не
обнуляются; счётчики растут одним запросом без блокировки бота. scripts/migrate_sqlite.py
переносит старые базы со сверкой сумм (проверено на копии рабочих баз: 20 694 пользователя,
45 чатов, 3 142 038 сообщений — совпадает).

Учёт бота в чатах: статус и права бота (событие my_chat_member и сверка с Telegram при
запуске и раз в 6 часов), история изменений в bot_status_history; из чатов, где бота
исключили, данные не удаляются — меняется только статус.

Большие чаты (больше 100 участников): /all — только для админов, раз в 5 минут и только
писавшие за 30 дней; упоминание одного человека по имени — не чаще раза в минуту. В любом
чате /all делится на сообщения до 4096 символов (раньше в чате на 14 тысяч падал).

Новое: /settings (упоминания, имена в голосовых, ивенты, удаление служебных сообщений —
меняют админы), /top, /month (итоги месяца по команде). /stop_bot раньше ничего не
выключал — флаг не проверялся; теперь работает. Участник, вышедший из чата, больше не
упоминается, но его статистика сохраняется. Ошибки — в технический чат DEBUG_CHAT_ID.

Код: handlers.py разбит на роутеры, Markdown заменён на HTML (экранирование через html.escape),
тесты на PostgreSQL (18), CI на Python 3.10 и 3.12.
2026-10-06 11:16:00 +00:00
37 changed files with 1741 additions and 1282 deletions

View file

@ -1,2 +1,6 @@
# Токен бота от @BotFather # Токен бота от @BotFather
BOT_TOKEN=123456:your-token BOT_TOKEN=123456:your-token
# База PostgreSQL (в compose.yaml задаётся автоматически)
DATABASE_URL=postgresql://abobot:abobot@localhost:5432/abobot
# Технический чат для сообщений об ошибках (необязательно)
DEBUG_CHAT_ID=

View file

@ -8,12 +8,26 @@ on:
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: abobot_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/abobot_test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: ${{ matrix.python-version }}
- run: sudo apt-get update -qq && sudo apt-get install -y -qq libenchant-2-2 hunspell-en-us - run: sudo apt-get update -qq && sudo apt-get install -y -qq libenchant-2-2 hunspell-en-us
- run: pip install -r requirements-dev.txt - run: pip install -r requirements-dev.txt
- run: ruff check --select E9,F,B code tests - run: ruff check --select E9,F,B code tests scripts
- run: pytest -q - run: pytest -q

View file

@ -19,17 +19,11 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta - id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: |
ghcr.io/edeev/chatping_abobot ghcr.io/edeev/chatping_abobot
dcr.deev.su/edeev/chatping_abobot
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}

1
.gitignore vendored
View file

@ -2,4 +2,3 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
db/*.db

View file

@ -14,10 +14,9 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY code/ code/ COPY code/ code/
COPY data/ data/ COPY data/ data/
RUN useradd --create-home --uid 1000 app && mkdir -p db && chown -R app:app /app RUN useradd --create-home --uid 1000 app && chown -R app:app /app
USER app USER app
VOLUME ["/app/db"]
# пути к базам и картинкам в коде — относительно папки code/ (../db, ../data) # пути к картинкам в коде — относительно папки code/ (../data)
WORKDIR /app/code WORKDIR /app/code
CMD ["python", "bot.py"] CMD ["python", "bot.py"]

View file

@ -18,16 +18,22 @@ The bot speaks Russian.
**Status:** personal project, running since 2021 · bot [@chat_abobot](https://t.me/chat_abobot) · over **Status:** personal project, running since 2021 · bot [@chat_abobot](https://t.me/chat_abobot) · over
20,000 users from 45 chats in the bot's database (October 2026) 20,000 users from 45 chats in the bot's database (October 2026)
**Stack:** Python 3.12 · aiogram 3 · pymorphy3 · pyenchant · SpeechRecognition · gTTS · SQLite · Docker **Stack:** Python 3.10+ · aiogram 3 · PostgreSQL (asyncpg) · pymorphy3 · pyenchant · SpeechRecognition · gTTS · Docker
## Features ## Features
- **Name mentions.** Words of a message are reduced to their base form (pymorphy3) and compared with - **Name mentions.** Words of a message are reduced to their base form (pymorphy3) and compared with
members' names, so any grammatical case of a name mentions the right person. A custom name can be set with `/edit`. Names are also found members' names, so any grammatical case of a name mentions the right person. A custom name can be set with `/edit`. Names are also found
in voice notes up to a minute long. in voice notes up to a minute long.
- **`/all`** mentions every member. - **`/all`** mentions every member. A long list is split into several messages.
- **Big chats (over 100 members).**
- `/all` is for admins only, at most once in 5 minutes, and only for people who wrote in the last 30 days.
- The same person is mentioned by name at most once a minute.
- **All-time and monthly statistics** (`/stat_group`, `/stat_user`): messages, replies, commands, links, - **All-time and monthly statistics** (`/stat_group`, `/stat_user`): messages, replies, commands, links,
media, stickers, voice and video notes. Monthly numbers reset at the start of each month. media, stickers, voice and video notes. Months are kept, so `/month 2026-09` shows any month's summary
and `/top` shows the most active members for the month or all time (`/top all`).
- **`/settings`** — per-chat switches: name mentions, names in voice notes (audio goes to Google), text
events, deleting service messages. Admins can change them.
- **Wrong keyboard layout.** A message like `ghbdtn` is repeated as «привет». English words are recognized - **Wrong keyboard layout.** A message like `ghbdtn` is repeated as «привет». English words are recognized
with a dictionary (pyenchant) and left alone. with a dictionary (pyenchant) and left alone.
- **Voice.** `/recognize` as a reply to a voice note transcribes it; «Озвучь - текст» turns text into a - **Voice.** `/recognize` as a reply to a voice note transcribes it; «Озвучь - текст» turns text into a
@ -39,37 +45,56 @@ The bot speaks Russian.
- mock fights; - mock fights;
- picture reactions. - picture reactions.
Turned off with `/stop_bot`. Turned off with `/stop_bot` or in `/settings`.
- Deleting service messages (joins, leaves, title and photo changes) when the bot is an admin. - Deleting service messages (joins, leaves, title and photo changes) when the bot is an admin.
- **Tracking the bot in groups.** The bot records which groups it is in and with which rights: Telegram
events plus a check on start and every 6 hours. Changes are kept as history. If the bot is removed, the
group's statistics stay; only the mark that the bot is no longer there changes.
## Running ## Running
```bash ```bash
git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot
cp .env.example .env # BOT_TOKEN from @BotFather cp .env.example .env # BOT_TOKEN from @BotFather
docker compose up -d docker compose up -d # the bot and PostgreSQL
``` ```
Prebuilt image: `docker pull ghcr.io/edeev/chatping_abobot` or `docker pull dcr.deev.su/edeev/chatping_abobot`. Prebuilt image: `docker pull ghcr.io/edeev/chatping_abobot` or `docker pull git.deev.su/edeev/chatping_abobot`.
The SQLite databases are created on first start. Tables are created on first start (`code/schema.sql`).
Without Docker you need Python 3.12, the enchant system library and an English dictionary Without Docker you need:
(`apt install libenchant-2-2 hunspell-en-us`). Then: `pip install -r requirements.txt` and - Python 3.10+;
`cd code && BOT_TOKEN=… python bot.py`. - PostgreSQL;
- the enchant system library with an English dictionary (`apt install libenchant-2-2 hunspell-en-us`).
Then: `pip install -r requirements.txt` and `cd code && BOT_TOKEN=… DATABASE_URL=postgresql://… python bot.py`.
Migrating data from the old version (four SQLite databases):
`python scripts/migrate_sqlite.py --sqlite-dir path/to/db --dsn postgresql://…`. The script checks the
totals after the transfer.
## Deployment
[@chat_abobot](https://t.me/chat_abobot) runs on a home server as a systemd service: its own venv, settings in
`/etc/abobot.env`, PostgreSQL on the same server. The nightly backup dumps the database, and the statistics
feed a Metabase dashboard.
## How it works ## How it works
``` ```
code/bot.py entry point and monthly statistics reset code/bot.py entry point, error reports to a tech chat, checking the bot's status in groups
code/handlers.py message, command, voice and event handlers code/handlers/ routers: help, settings, stats, mentions, voice, events, chat (bot in groups, service messages)
code/script.py statistics, name lookup, keyboard layout, text reversal code/db.py PostgreSQL queries
code/sql.py four SQLite databases: groups, users, all-time and monthly statistics code/schema.sql schema: chats, users, members, chat_stats and member_stats by period, bot_status_history
data/ event pictures and the greeting code/nlp.py base forms of names, keyboard layout, reversal, escaping
scripts/ migration from SQLite
data/ event pictures and the greeting
``` ```
Member statistics live in a separate table per chat — that is how the bot's production databases are All-time and current-month counters grow in a single query (`INSERT … ON CONFLICT DO UPDATE`), so
built. Counters are incremented in a single query, so simultaneous messages are not lost. Speech recognition simultaneous messages are not lost. Database calls are asynchronous and speech recognition and synthesis
and synthesis (Google Web Speech and gTTS) run in a separate thread, so the bot never freezes. (Google Web Speech and gTTS) run in a separate thread, so the bot never freezes. Messages use HTML markup and
user text is escaped.
## Development ## Development
@ -78,14 +103,17 @@ pip install -r requirements-dev.txt
ruff check --select E9,F,B code tests && pytest ruff check --select E9,F,B code tests && pytest
``` ```
What the tests cover: The tests need PostgreSQL (`TEST_DATABASE_URL`). What they cover:
- statistics counters; - counters by period;
- mentions and `/all`; - mentions and big-chat limits;
- the monthly reset; - splitting `/all`;
- keyboard layout, reversal and escaping; - the bot's status in groups and its history;
- loading of the bot's handlers. - migration from SQLite;
- keyboard layout and escaping.
The Docker image is built on `v*` tags and published to GitHub Packages and `dcr.deev.su`. CI runs them on Python 3.10 and 3.12.
The Docker image is built on `v*` tags and published to GitHub Packages and `git.deev.su`.
## License ## License

View file

@ -13,19 +13,25 @@ Telegram-бот для групповых чатов. Он:
- распознаёт и озвучивает голосовые; - распознаёт и озвучивает голосовые;
- устраивает шуточные ивенты. - устраивает шуточные ивенты.
**Статус:** личный проект, работает с 2021 года · бот [@chat_abobot](https://t.me/chat_abobot) · в базе **Статус:** личный проект, работает с 2021 года, версия 4 · бот [@chat_abobot](https://t.me/chat_abobot) · в базе
бота больше 20 000 пользователей из 45 чатов (октябрь 2026) бота больше 20 000 пользователей из 45 чатов (октябрь 2026)
**Стек:** Python 3.12 · aiogram 3 · pymorphy3 · pyenchant · SpeechRecognition · gTTS · SQLite · Docker **Стек:** Python 3.10+ · aiogram 3 · PostgreSQL (asyncpg) · pymorphy3 · pyenchant · SpeechRecognition · gTTS · Docker
## Возможности ## Возможности
- **Упоминания по имени.** Слова сообщения приводятся к начальной форме (pymorphy3) и сравниваются с - **Упоминания по имени.** Слова сообщения приводятся к начальной форме (pymorphy3) и сравниваются с
именами участников, поэтому участницу Машу позовут и «Маша», и «позови Машу», и «с Машей». Своё имя именами участников, поэтому участницу Машу позовут и «Маша», и «позови Машу», и «с Машей». Своё имя
для упоминаний можно задать командой `/edit`. Имена ищутся и в голосовых до минуты. для упоминаний можно задать командой `/edit`. Имена ищутся и в голосовых до минуты.
- **`/all`** — упомянуть всех участников. - **`/all`** — упомянуть всех участников. Длинный список делится на несколько сообщений.
- **Статистика за всё время и за месяц** (`/stat_group`, `/stat_user`): сообщения, ответы, команды, - **Большие чаты (больше 100 участников).**
ссылки, медиа, стикеры, голосовые и кружочки. Месячная обнуляется в начале месяца. - `/all` — только для администраторов, не чаще раза в 5 минут и только для писавших за 30 дней.
- Одного человека по имени бот зовёт не чаще раза в минуту.
- **Статистика за всё время и по месяцам** (`/stat_group`, `/stat_user`): сообщения, ответы, команды,
ссылки, медиа, стикеры, голосовые и кружочки. Месяцы хранятся, поэтому `/month 2026-09` покажет итоги
любого месяца, а `/top` — самых активных за месяц или за всё время (`/top all`).
- **`/settings`** — переключатели для чата: упоминания по имени, поиск имён в голосовых (голос уходит в
Google), текстовые ивенты, удаление служебных сообщений. Менять могут администраторы.
- **Неправильная раскладка.** Сообщение вида `ghbdtn` бот повторит как «привет». Английские слова он - **Неправильная раскладка.** Сообщение вида `ghbdtn` бот повторит как «привет». Английские слова он
отличает по словарю (pyenchant) и не трогает. отличает по словарю (pyenchant) и не трогает.
- **Голос.** `/recognize` ответом на голосовое — расшифровка; «Озвучь - текст» — голосовое из текста. - **Голос.** `/recognize` ответом на голосовое — расшифровка; «Озвучь - текст» — голосовое из текста.
@ -36,37 +42,56 @@ Telegram-бот для групповых чатов. Он:
- «Подраться с …»; - «Подраться с …»;
- «Чмокнуть» и другие действия с картинками. - «Чмокнуть» и другие действия с картинками.
Отключаются командой `/stop_bot`. Отключаются командой `/stop_bot` или в `/settings`.
- Удаление служебных сообщений (вход, выход, смена названия и фото) — если у бота права администратора. - Удаление служебных сообщений (вход, выход, смена названия и фото) — если у бота права администратора.
- **Учёт бота в группах.** Бот записывает, в каких группах состоит и с какими правами: события Telegram
плюс сверка при запуске и раз в 6 часов. История изменений хранится. Если бота исключили, статистика
группы не удаляется — меняется только отметка, что бота там больше нет.
## Запуск ## Запуск
```bash ```bash
git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot
cp .env.example .env # BOT_TOKEN от @BotFather cp .env.example .env # BOT_TOKEN от @BotFather
docker compose up -d docker compose up -d # бот и PostgreSQL
``` ```
Готовый образ: `docker pull ghcr.io/edeev/chatping_abobot` или `docker pull dcr.deev.su/edeev/chatping_abobot`. Готовый образ: `docker pull ghcr.io/edeev/chatping_abobot` или `docker pull git.deev.su/edeev/chatping_abobot`.
Базы SQLite создаются при первом запуске. Таблицы создаются при первом запуске (`code/schema.sql`).
Без Docker нужны Python 3.12, системная библиотека enchant и английский словарь Без Docker нужны:
(`apt install libenchant-2-2 hunspell-en-us`). Затем: `pip install -r requirements.txt` и - Python 3.10+;
`cd code && BOT_TOKEN=… python bot.py`. - PostgreSQL;
- системная библиотека enchant с английским словарём (`apt install libenchant-2-2 hunspell-en-us`).
Затем: `pip install -r requirements.txt` и `cd code && BOT_TOKEN=… DATABASE_URL=postgresql://… python bot.py`.
Перенос данных старой версии (четыре базы SQLite):
`python scripts/migrate_sqlite.py --sqlite-dir путь/к/db --dsn postgresql://…`. Скрипт сверяет суммы после
переноса.
## Развёртывание
[@chat_abobot](https://t.me/chat_abobot) работает на домашнем сервере как systemd-служба: свой venv, настройки
в `/etc/abobot.env`, база — PostgreSQL на том же сервере. Ночной бэкап снимает дамп базы, статистика
выводится в дашборд Metabase.
## Как устроено ## Как устроено
``` ```
code/bot.py запуск и ежемесячное обнуление статистики code/bot.py запуск, отправка ошибок в технический чат, сверка статуса бота в группах
code/handlers.py обработчики сообщений, команд, голосовых и ивентов code/handlers/ роутеры: help, settings, stats, mentions, voice, events, chat (бот в группах, служебные сообщения)
code/script.py учёт статистики, поиск имён, раскладка, переворот текста code/db.py запросы к PostgreSQL
code/sql.py четыре базы SQLite: группы, пользователи, статистика за всё время и за месяц code/schema.sql схема: chats, users, members, chat_stats и member_stats по периодам, bot_status_history
data/ картинки ивентов и приветствие code/nlp.py имена в начальной форме, раскладка, переворот, экранирование
scripts/ перенос данных из SQLite
data/ картинки ивентов и приветствие
``` ```
Статистика участников хранится в отдельной таблице на каждый чат — так устроены рабочие базы бота. Счётчики за всё время и за текущий месяц растут одним запросом (`INSERT … ON CONFLICT DO UPDATE`),
Счётчики увеличиваются одним запросом, поэтому одновременные сообщения не теряются. Распознавание и синтез поэтому одновременные сообщения не теряются. Запросы к базе асинхронные, а распознавание и синтез речи
речи (Google Web Speech и gTTS) идут в отдельном потоке, чтобы бот не замирал. (Google Web Speech и gTTS) идут в отдельном потоке — бот не замирает. Разметка сообщений — HTML,
пользовательский текст экранируется.
## Разработка ## Разработка
@ -75,14 +100,17 @@ pip install -r requirements-dev.txt
ruff check --select E9,F,B code tests && pytest ruff check --select E9,F,B code tests && pytest
``` ```
Что проверяют тесты: Тестам нужен PostgreSQL (`TEST_DATABASE_URL`). Что они проверяют:
- счётчики статистики; - счётчики по периодам;
- упоминания и `/all`; - упоминания и ограничения больших чатов;
- обнуление месячной статистики; - деление `/all` на сообщения;
- раскладку, переворот и экранирование; - статус бота в группах и историю;
- что обработчики бота загружаются. - перенос из SQLite;
- раскладку и экранирование.
Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `dcr.deev.su`. CI прогоняет их на Python 3.10 и 3.12.
Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `git.deev.su`.
## Лицензия ## Лицензия

View file

@ -1,10 +1,4 @@
import os # Списки для ивентов и исправления раскладки (токены и id чатов — в config.py)
# ДАННЫЕ БОТА (из переменных окружения)
TOKEN = os.getenv("BOT_TOKEN", "XXXXXXXXXX") # @chat_abobot
DEBAG = os.getenv("DEBUG_CHAT_ID", "") # Технический чат
TEX_GROUP = os.getenv("TECH_GROUP_ID", "")
# ОБРАЩЕНИЯ К ПОЛЬЗОВАТЕЛЯМ # ОБРАЩЕНИЯ К ПОЛЬЗОВАТЕЛЯМ
KILL_LIST = ["побить", "отмудохать", "избить", "уебать", "отметелить"] KILL_LIST = ["побить", "отмудохать", "избить", "уебать", "отметелить"]

View file

@ -1,49 +1,66 @@
import asyncio import asyncio
import logging import logging
from datetime import date import traceback
from init import bot, dp, db, dm from aiogram import Bot, Dispatcher
from handlers import router from aiogram.client.bot import DefaultBotProperties
from aiogram.enums.parse_mode import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import ErrorEvent
import config
import db
from handlers import routers
from handlers.chat import check_all
CHECK_INTERVAL = 6 * 3600 # сверка статуса бота в чатах
def reset_month_if_needed(today=None): def create_dispatcher():
"""Статистика «за месяц» раньше нигде не обнулялась и копилась с момента создания таблиц. dp = Dispatcher(storage=MemoryStorage())
При первом запуске месяц только запоминается — обнуление со следующей смены месяца""" for router in routers:
key = (today or date.today()).strftime("%Y-%m") dp.include_router(router)
saved = db.get_meta("month")
if saved == key: @dp.errors()
return False async def on_error(event: ErrorEvent, bot: Bot):
if saved is not None: """Ошибка обработчика — в лог и в технический чат"""
db.reset_month() logging.exception("Ошибка обработки", exc_info=event.exception)
dm.reset_all() if config.DEBUG_CHAT_ID:
logging.info("Месячная статистика обнулена: %s → %s", saved, key) tb = "".join(traceback.format_exception(event.exception))[-3500:]
db.set_meta("month", key) try:
return saved is not None await bot.send_message(config.DEBUG_CHAT_ID, f"<b>AboBot: ошибка</b>\n<pre>{tb.replace('<', '&lt;')}</pre>")
except Exception:
pass
return True
return dp
async def month_watcher(): async def periodic_check(bot: Bot):
while True: while True:
try: try:
reset_month_if_needed() await check_all(bot)
except Exception: except Exception:
logging.exception("Не удалось обнулить месячную статистику") logging.exception("Сверка статуса бота в чатах")
await asyncio.sleep(600) await asyncio.sleep(CHECK_INTERVAL)
async def main() -> None: async def main() -> None:
dp.include_router(router) bot = Bot(token=config.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await db.connect(config.DATABASE_URL)
dp = create_dispatcher()
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
watcher = asyncio.create_task(month_watcher()) checker = asyncio.create_task(periodic_check(bot))
try: try:
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
finally: finally:
watcher.cancel() checker.cancel()
await db.close()
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
try:
try: asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
print("Бот остановлен") pass
pass

10
code/config.py Normal file
View file

@ -0,0 +1,10 @@
import os
BOT_TOKEN = os.getenv("BOT_TOKEN", "XXXXXXXXXX") # @chat_abobot
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://abobot:abobot@localhost:5432/abobot")
DEBUG_CHAT_ID = os.getenv("DEBUG_CHAT_ID", "") # технический чат для ошибок
BIG_CHAT = 100 # с какого числа участников действуют ограничения
ALL_COOLDOWN = 300 # /all в большом чате — не чаще, с
ALL_ACTIVE_DAYS = 30 # /all в большом чате зовёт тех, кто писал за столько дней
MENTION_COOLDOWN = 60 # упоминание одного человека в большом чате — не чаще, с

199
code/db.py Normal file
View file

@ -0,0 +1,199 @@
"""Работа с PostgreSQL. Счётчики увеличиваются одним запросом (INSERT … ON CONFLICT DO UPDATE)"""
import json
import os
from datetime import date
import asyncpg
COLUMNS = {1: "mes", 2: "rep", 3: "com", 4: "url", 5: "med", 6: "sti", 7: "voi"}
STAT_FIELDS = ("mes", "rep", "com", "url", "med", "sti", "voi")
SETTINGS = ("events_enabled", "mentions_enabled", "voice_names", "delete_service")
pool: asyncpg.Pool | None = None
def month_key(day=None):
return (day or date.today()).strftime("%Y-%m")
async def connect(dsn):
global pool
pool = await asyncpg.create_pool(dsn, min_size=1, max_size=5)
with open(os.path.join(os.path.dirname(__file__), "schema.sql"), encoding="utf-8") as f:
await pool.execute(f.read())
return pool
async def close():
if pool:
await pool.close()
# ЧАТЫ И УЧАСТНИКИ
async def ensure_chat(chat_id, title=None):
await pool.execute(
"INSERT INTO chats (id, title) VALUES ($1, $2) "
"ON CONFLICT (id) DO UPDATE SET title = COALESCE(EXCLUDED.title, chats.title)", chat_id, title)
async def chat_exists(chat_id):
return await pool.fetchval("SELECT EXISTS (SELECT 1 FROM chats WHERE id = $1)", chat_id)
async def migrate_chat(old_id, new_id):
"""Группа стала супергруппой — у неё новый id; связанные таблицы обновятся каскадом"""
await pool.execute("UPDATE chats SET id = $2 WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM chats WHERE id = $2)",
old_id, new_id)
async def ensure_user(user_id):
await pool.execute("INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING", user_id)
async def get_setting(chat_id, name):
assert name in SETTINGS
value = await pool.fetchval(f"SELECT {name} FROM chats WHERE id = $1", chat_id)
return True if value is None else value
async def toggle_setting(chat_id, name):
assert name in SETTINGS
return await pool.fetchval(f"UPDATE chats SET {name} = NOT {name} WHERE id = $1 RETURNING {name}", chat_id)
async def settings(chat_id):
row = await pool.fetchrow(f"SELECT {', '.join(SETTINGS)} FROM chats WHERE id = $1", chat_id)
return dict(row) if row else dict.fromkeys(SETTINGS, True)
async def chat_names(chat_id):
"""Имена действующих участников: {имя: id}"""
rows = await pool.fetch("SELECT name, user_id FROM members WHERE chat_id = $1 AND left_at IS NULL AND name <> ''",
chat_id)
return {r["name"]: r["user_id"] for r in rows}
async def active_members(chat_id, days=None):
"""Действующие участники: [(id, имя)]; days — только писавшие за последние N дней"""
query = "SELECT user_id, name FROM members WHERE chat_id = $1 AND left_at IS NULL AND name <> ''"
args = [chat_id]
if days:
query += " AND last_message_at > now() - make_interval(days => $2)"
args.append(days)
return [(r["user_id"], r["name"]) for r in await pool.fetch(query + " ORDER BY name", *args)]
async def member_count(chat_id):
return await pool.fetchval("SELECT count(*) FROM members WHERE chat_id = $1 AND left_at IS NULL", chat_id)
async def member_name(chat_id, user_id):
return await pool.fetchval("SELECT name FROM members WHERE chat_id = $1 AND user_id = $2", chat_id, user_id)
async def set_custom_name(chat_id, user_id, name):
await pool.execute("UPDATE members SET name = $3, custom_name = TRUE WHERE chat_id = $1 AND user_id = $2",
chat_id, user_id, name)
async def reset_custom_name(chat_id, user_id, name):
"""Возвращает True, если своё имя было задано"""
return await pool.fetchval(
"UPDATE members SET name = $3, custom_name = FALSE WHERE chat_id = $1 AND user_id = $2 AND custom_name "
"RETURNING TRUE", chat_id, user_id, name) or False
async def member_left(chat_id, user_id):
await pool.execute("UPDATE members SET left_at = now() WHERE chat_id = $1 AND user_id = $2", chat_id, user_id)
async def mark_all(chat_id):
await pool.execute("UPDATE chats SET last_all_at = now() WHERE id = $1", chat_id)
async def last_all(chat_id):
return await pool.fetchval("SELECT last_all_at FROM chats WHERE id = $1", chat_id)
# СТАТИСТИКА
async def count(chat_id, user_id, var_ids, name, title=None, day=None):
"""Учёт события: участник появляется в чате (имя обновляется, если не задано своё),
счётчики var_ids растут за всё время и за текущий месяц — одной транзакцией"""
periods = ("all", month_key(day))
columns = [COLUMNS[v] for v in var_ids]
increments = ", ".join(f"{c} = s.{c} + EXCLUDED.{c}" for c in columns)
values = ", ".join("1" for _ in columns)
column_list = ", ".join(columns)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
"INSERT INTO chats (id, title) VALUES ($1, $2) "
"ON CONFLICT (id) DO UPDATE SET title = COALESCE(EXCLUDED.title, chats.title)", chat_id, title)
await conn.execute("INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING", user_id)
await conn.execute(
"INSERT INTO members (chat_id, user_id, name, last_message_at) VALUES ($1, $2, $3, now()) "
"ON CONFLICT (chat_id, user_id) DO UPDATE SET last_message_at = now(), left_at = NULL, "
"name = CASE WHEN members.custom_name THEN members.name ELSE EXCLUDED.name END",
chat_id, user_id, name)
for period in periods:
await conn.execute(
f"INSERT INTO chat_stats AS s (chat_id, period, {column_list}) VALUES ($1, $2, {values}) "
f"ON CONFLICT (chat_id, period) DO UPDATE SET {increments}", chat_id, period)
await conn.execute(
f"INSERT INTO member_stats AS s (chat_id, user_id, period, {column_list}) VALUES ($1, $2, $3, {values}) "
f"ON CONFLICT (chat_id, user_id, period) DO UPDATE SET {increments}", chat_id, user_id, period)
def _stats(row):
return tuple(row[f] for f in STAT_FIELDS) if row else (0,) * len(STAT_FIELDS)
async def chat_stats(chat_id, period):
return _stats(await pool.fetchrow("SELECT * FROM chat_stats WHERE chat_id = $1 AND period = $2", chat_id, period))
async def member_stats(chat_id, user_id, period):
return _stats(await pool.fetchrow("SELECT * FROM member_stats WHERE chat_id = $1 AND user_id = $2 AND period = $3",
chat_id, user_id, period))
async def top(chat_id, period, field="mes", limit=10):
"""Лучшие участники по счётчику: [(id, имя, значение)]"""
assert field in STAT_FIELDS
rows = await pool.fetch(
f"SELECT s.user_id, m.name, s.{field} AS value FROM member_stats s "
f"JOIN members m USING (chat_id, user_id) WHERE s.chat_id = $1 AND s.period = $2 AND s.{field} > 0 "
f"ORDER BY s.{field} DESC, m.name LIMIT $3", chat_id, period, limit)
return [(r["user_id"], r["name"], r["value"]) for r in rows]
# БОТ В ЧАТАХ
IN_CHAT = ("member", "administrator", "restricted", "creator")
async def set_bot_status(chat_id, status, rights, source, member_count=None, chat_type=None, title=None):
"""Записывает статус бота в чате; в историю — только если статус или права изменились"""
rights_json = None if rights is None else json.dumps(rights, sort_keys=True)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
"INSERT INTO chats (id, title) VALUES ($1, $2) "
"ON CONFLICT (id) DO UPDATE SET title = COALESCE(EXCLUDED.title, chats.title)", chat_id, title)
changed = await conn.fetchval(
"SELECT bot_status IS DISTINCT FROM $2 OR bot_rights IS DISTINCT FROM $3::jsonb FROM chats WHERE id = $1",
chat_id, status, rights_json)
await conn.execute(
"UPDATE chats SET bot_status = $2, bot_rights = $3::jsonb, checked_at = now(), "
"bot_status_at = CASE WHEN $4 THEN now() ELSE bot_status_at END, "
"member_count = COALESCE($5, member_count), chat_type = COALESCE($6, chat_type) WHERE id = $1",
chat_id, status, rights_json, changed, member_count, chat_type)
if changed:
await conn.execute("INSERT INTO bot_status_history (chat_id, status, rights, source) "
"VALUES ($1, $2, $3::jsonb, $4)", chat_id, status, rights_json, source)
return changed
async def chats_to_check():
"""Чаты для сверки: все, где бот считается состоящим, и те, где статус неизвестен"""
rows = await pool.fetch("SELECT id FROM chats WHERE bot_status IS NULL OR bot_status = ANY($1::text[])",
list(IN_CHAT))
return [r["id"] for r in rows]

View file

@ -1,534 +0,0 @@
from aiogram import F, Router
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
from aiogram.filters import Command, CommandStart
from aiogram.enums import ContentType
import asyncio
import logging
import os
import random
import re
import tempfile
import requests
import soundfile as sf
import speech_recognition as sr
from gtts import gTTS
import base
from init import bot, db, du, dg, dm, morph, engl_dict
from script import upd_stat, lang_form, notice, checker, translator, revers, md, plain
router = Router()
def recognize_file(oga_path):
"""Голосовое (OGG/Opus) → текст через Google Web Speech. Синхронно — вызывается в отдельном потоке"""
data, samplerate = sf.read(oga_path)
wav_path = oga_path[:-4] + ".wav"
sf.write(wav_path, data, samplerate)
r = sr.Recognizer()
with sr.AudioFile(wav_path) as source:
r.pause_threshold = 100
audio = r.listen(source)
return r.recognize_google(audio, language='ru-RU')
async def recognize_voice(file_id):
"""Распознавание без блокировки бота: раньше каждое голосовое в группе распознавалось прямо
в обработчике, и бот на это время не отвечал никому. Файлы — во временной папке на запрос
(раньше ../data/voices/<id>_<случайное число>, при ошибке файлы оставались)"""
with tempfile.TemporaryDirectory() as tmp:
oga = os.path.join(tmp, "voice.oga")
file = await bot.get_file(file_id)
await bot.download_file(file.file_path, oga)
try:
return await asyncio.to_thread(recognize_file, oga)
except sr.UnknownValueError: # речь не разобрана
return None
except Exception:
logging.exception("Ошибка распознавания голосового")
return None
def inflect_past(word):
"""Глагол в прошедшем времени; если pymorphy3 не справился — как есть"""
form = morph.parse(word.lower())[0].inflect({'past', 'sing', 'indc'})
return form.word if form else word
# НОВЫЕ УЧАСТНИКИ ГРУППЫ
@router.message(F.content_type == ContentType.NEW_CHAT_MEMBERS)
async def notification(message: Message):
if not du.group_exists(message.chat.id):
du.add_group(message.chat.id)
group_id = du.get_group_id(message.chat.id)
db.add_group(group_id)
dg.created_group(group_id)
await bot.send_message(
message.chat.id,
f'*Привет группа {plain(message.chat.title)}!*\n\n'
f'Все функции вы можете узнать по команде /help! Для того, чтобы '
f'уведомления по имени и команда /all нормально функционировали, '
f'необходимо чтобы каждый участник группы написал хотя бы одно '
f'сообщение в чат! Также для того, чтобы системные сообщения удалялись '
f'автоматически, необходимо боту выдать права администратора!'
)
else:
try:
await message.delete()
except Exception:
return
# ОБНОВЛЕНИЕ АЙДИ ГРУППЫ
@router.message(F.content_type.in_([ContentType.MIGRATE_TO_CHAT_ID, ContentType.MIGRATE_FROM_CHAT_ID]))
async def chat_reload(message: Message):
if du.group_exists(message.migrate_from_chat_id):
du.update_group_id(du.get_group_id(message.migrate_from_chat_id), message.migrate_to_chat_id)
# КОМАНДЫ HELP И START
@router.message(CommandStart())
@router.message(Command('help'))
async def helps(message: Message):
if message.chat.id > 0:
user_id = message.from_user.id
if not du.user_exists(user_id):
du.add_user(user_id)
else:
group_id, user_id = message.chat.id, message.from_user.id
if not du.user_exists(user_id):
du.add_user(user_id)
if not du.group_exists(group_id):
du.add_group(group_id)
upd_stat(user_id, group_id, 3, message.from_user.first_name, True)
buttons = [
[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
InlineKeyboardButton(text="ИВЕНТЫ", callback_data="even")],
[InlineKeyboardButton(text="АВТОР", callback_data="auth"),
InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun")]
]
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
try:
await bot.send_voice(
chat_id=message.chat.id,
voice=FSInputFile('../data/tts.ogg'),
caption='*-* Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, '
'ивентами и удобными функциями, которые облегчают использование чата)\n\n'
'*-* Также прошу если вам понравился бот, оставить отзыв о его использовании на '
'команду /report)',
reply_markup=keyboard
)
except Exception:
await bot.send_message(
chat_id=message.chat.id,
text='*-* Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, '
'ивентами и удобными функциями, которые облегчают использование чата)\n\n'
'*-* Также прошу если вам понравился бот, оставить отзыв о его использовании на '
'команду /report)',
reply_markup=keyboard
)
# INLINE КЛАВИАТУРА ОБРАБОТЧИКИ
@router.callback_query(F.data == "auth")
async def author(call: CallbackQuery):
await call.message.answer(
text='*| АВТОР |*\n\n*>>* Этот бот, как бы это не печально звучало, но одна из лучших'
' моих работ и если кого-нибудь у меня получится действительно достойный продукт,'
' вы сможете о нём узнать в моём телеграм канале *@programium*'
)
@router.callback_query(F.data == "fun")
async def function(call: CallbackQuery):
await call.message.answer(
text='*| ФУНКЦИИ |*\n\n*1.* Возможность перевода случайно написанного текста на '
'транслите\n*2.* Ведение обширной статистики сообщений\n*3.* Упоминание участника '
'при написании его имени в чате\n*4.* Автоматическое удаление системных сообщений'
)
@router.callback_query(F.data == "com")
async def commands(call: CallbackQuery):
await call.message.answer(
text='*| КОМАНДЫ |*\n\n*/all* - упомянуть всех в чате\n*/help* - полный список функций\n'
'*/recognize* - транскрипция отмеченного голосового сообщения в текст\n'
'*/stat_group* - полная статистика группы\n*/stat_user* - полная статистика '
'отправителя\n*/edit и /back_edit* - первая команда даёт возможность '
'сменить имя для упоминаний на любое слово, а вторая для возврата динамического '
'имени\n*/start_bot и /stop_bot* - возможность отключения текстовых ивентов'
)
@router.callback_query(F.data == "even")
async def events(call: CallbackQuery):
await call.message.answer(
text='*| ИВЕНТЫ |*\n\n*"Чмокнуть"* - сделать кому-нибудь приятно\n*"Отмудохать"* - '
'выместить злость на кого-нибудь\n*"Число от ... до ..."* - случайное значение из '
'диапозона\n*"Подраться с ..."* - повод кого-нибудь побить\n*"Переведи (..) - ..."* '
'- перевод слова на керпичный язык\n*"Дай блять совет!"* - даёт рандомный охуенный '
'совет\n*"Переверни - ..."* - переворачивает слова в предложении\n*"Озвучь - ..."* '
'- озвучивает написанный текст'
)
# КОМАНДА ALL
@router.message(Command('all'))
async def every(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
try:
await message.reply(
notice(du.get_user_id(message.from_user.id), True,
du.get_group_id(message.chat.id), message.from_user.id)
)
except Exception:
await message.reply('В группе состоит менее 3х человек, из-за чего команда не работает!')
else:
await message.reply('Эта команда предназначена для вызова в чате!')
# КОМАНДА RECOGNIZE
@router.message(Command("recognize"))
async def recognise(message: Message):
if not (message.reply_to_message and message.reply_to_message.voice):
await message.reply("Ответьте этой командой на голосовое сообщение!")
return
mes = await bot.send_message(
chat_id=message.chat.id,
text="Распознавание.....",
reply_to_message_id=message.reply_to_message.message_id
)
query = await recognize_voice(message.reply_to_message.voice.file_id)
if query:
await mes.edit_text(f'*{plain(message.reply_to_message.from_user.first_name)} сказал(a)* "{md(query)}"')
else:
await mes.edit_text("Распознать сообщение не удалось!")
# СТАТИСТИКА ГРУППЫ
@router.message(Command('stat_group'))
async def stat_group(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
group_id = du.get_group_id(message.chat.id)
group = db.stat_group(group_id)
month = db.month_stat_group(group_id)
await message.answer(
text=f'*| СТАТИСТИКА ГРУППЫ |*\n\n'
f'*>>* В целом сообщений *[ {message.message_id} ]*\n\n'
f'*- За всё время* / *За месяц -*\n'
f'*>>* Сообщений в базе *[ {group[0]} / {month[0]} ]*\n\n'
f'*>* Ответов *- [ {group[1]} / {month[1]} ]*\n'
f'*>* Команд *- [ {group[2]} / {month[2]} ]*\n'
f'*>* Ссылок *- [ {group[3]} / {month[3]} ]*\n'
f'*>* Стикеров *- [ {group[5]} / {month[5]} ]*\n'
f'*>* Медиа файлов *- [ {group[4]} / {month[4]} ]*\n'
f'*>* Голос/Кружочки *- [ {group[6]} / {month[6]} ]*'
)
# СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ
@router.message(Command('stat_user'))
async def stat_user(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
user_id, group_id = du.get_user_id(message.from_user.id), du.get_group_id(message.chat.id)
group = dg.stat_user(user_id, group_id)
month = dm.stat_user(user_id, group_id)
namer = "".join(re.sub(r'[^\w\s]', '', message.from_user.first_name).split())
name = morph.parse(namer)[0].inflect({"gent"})
if name is None:
name = namer
else:
name = name.word
await message.answer(
text=f'*| СТАТИСТИКА {plain(name.upper())} |*\n\n'
f'*- За всё время* / *За месяц -*\n'
f'*>>* Сообщений в базе *[ {group[0]} / {month[0]} ]*\n\n'
f'*>* Ответов *- [ {group[1]} / {month[1]} ]*\n'
f'*>* Команд *- [ {group[2]} / {month[2]} ]*\n'
f'*>* Ссылок *- [ {group[3]} / {month[3]} ]*\n'
f'*>* Стикеров *- [ {group[5]} / {month[5]} ]*\n'
f'*>* Медиа файлов *- [ {group[4]} / {month[4]} ]*\n'
f'*>* Голос/Кружочки *- [ {group[6]} / {month[6]} ]*'
)
# РЕДАКТИРОВАНИЕ ИМЕНИ
@router.message(Command('edit'))
async def update(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
user_id, group_id = du.get_user_id(message.from_user.id), du.get_group_id(message.chat.id)
text_edit = re.sub(r'[^\w\s]', '', message.text.lower()).split()
if len(text_edit) == 2:
name = morph.parse(text_edit[1])[0]
if name is None:
name = text_edit[1]
else:
name = name.normal_form
if name not in list(map(lambda x: x[0], dg.all_names(group_id))):
if not db.edit_user_exists(user_id):
db.add_edit_user(user_id)
dg.update_name(user_id, group_id, name)
await message.reply(f'{md(name.title())}, ваше имя было успешно изменено)')
else:
await message.reply(f'{md(message.from_user.first_name.lower().title())}, такое имя уже присутствует в чате!')
else:
await message.reply('Вы не правильно ввели имя! Имя должно быть '
'из одного слова и идти сразу после команды!')
# ВОЗВРАТ К ДИНАМИЧЕСКОМУ ИМЕНИ
@router.message(Command('back_edit'))
async def update_return(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
user_id, group_id = du.get_user_id(message.from_user.id), du.get_group_id(message.chat.id)
name = re.sub(r'[^\w\s]', '', message.from_user.first_name.lower())
if db.edit_user_exists(user_id):
db.del_edit_user(user_id)
dg.update_name(user_id, group_id, name)
await message.reply(f'{md(name.title())}, вы успешно вернулись к динамическому изменению имени)')
else:
await message.reply(f'{md(name.title())}, вы не устанавливали постоянное имя!')
# ВКЛЮЧЕНИЕ ТЕКСТОВЫХ ИВЕНТОВ
@router.message(Command('start_bot'))
async def opening(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
group_id = du.get_group_id(message.chat.id)
if db.check_status(group_id):
await message.answer("У вас уже включены текстовые ивенты!")
else:
db.update_status(group_id)
await message.answer("Текстовые ивенты включены!")
# ОТКЛЮЧЕНИЕ ТЕКСТОВЫХ ИВЕНТОВ
@router.message(Command('stop_bot'))
async def closing(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
group_id = du.get_group_id(message.chat.id)
if db.check_status(group_id):
db.update_status(group_id)
await message.answer("Текстовые ивенты отключены!")
else:
await message.answer("У вас уже отключены текстовые ивенты!")
# УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЕЙ АВТОМАТИЧЕСКИ
@router.message(F.content_type == ContentType.LEFT_CHAT_MEMBER)
async def delete(message: Message):
if du.user_exists(message.left_chat_member.id):
user_id, group_id = du.get_user_id(message.left_chat_member.id), du.get_group_id(message.chat.id)
dg.del_user(group_id, user_id)
if db.group_exists_month(group_id):
if dm.user_exists(user_id, group_id):
dm.del_user(group_id, user_id)
try:
await message.delete()
except Exception:
return
# УДАЛЕНИЕ ТЕХНИЧЕСКИХ СООБЩЕНИЙ
@router.message(F.content_type.in_([
ContentType.NEW_CHAT_TITLE,
ContentType.NEW_CHAT_PHOTO,
ContentType.PINNED_MESSAGE,
ContentType.VIDEO_CHAT_ENDED,
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED
]))
async def chat_events(message: Message):
try:
await message.delete()
except Exception:
return
# МЕДИА ФАЙЛЫ
@router.message(F.content_type.in_([
ContentType.LOCATION,
ContentType.CONTACT,
ContentType.VIDEO,
ContentType.PHOTO,
ContentType.AUDIO,
ContentType.DOCUMENT
]))
async def media(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 5, message.from_user.first_name, True)
# ГОЛОСОВЫЕ СООБЩЕНИЯ И КРУЖОЧКИ
@router.message(F.content_type.in_([ContentType.VOICE, ContentType.VIDEO_NOTE]))
async def voice(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 7, message.from_user.first_name, True)
# упоминание по имени, произнесённому в голосовом
if message.voice and message.chat.id < 0 and message.voice.duration <= 60:
query = await recognize_voice(message.voice.file_id)
if not query:
return
group_id = du.get_group_id(message.chat.id)
unsigned = re.sub(r'[^\w\s]', '', query.lower()).split()
first_form = [morph.parse(i)[0].normal_form for i in unsigned]
chat_names = [x[0] for x in dg.all_names(group_id)]
names = [_ for _ in first_form if _ in chat_names]
text = notice(names, False, group_id, message.from_user.id) if names else None
if text:
await message.reply(text)
# СТИКЕРЫ
@router.message(F.content_type == ContentType.STICKER)
async def stick(message: Message):
if message.chat.id < 0:
upd_stat(message.from_user.id, message.chat.id, 6, message.from_user.first_name, True)
# ТЕКСТОВЫЕ ИВЕНТЫ
@router.message(F.content_type == ContentType.TEXT)
async def send_events(message: Message):
group_id, user_id, name = message.chat.id, message.from_user.id, message.from_user.first_name
# ОБНОВЛЕНИЕ СТАТИСТИКИ
if message.chat.id < 0:
upd_stat(user_id, group_id, 1, name) # СООБЩЕНИЙ
if len(message.text) > 1 and message.text[0] == '/':
upd_stat(user_id, group_id, 3, name) # КОМАНД
if message.reply_to_message:
upd_stat(user_id, group_id, 2, name) # ОТВЕТОВ НА СООБЩЕНИЯ
# ПЕРЕМЕННЫЕ
low_mes = message.text.lower()
words = message.text.split()
unsigned = re.sub(r'[^\w\s]', '', low_mes).split()
first_form = [morph.parse(i)[0].normal_form for i in unsigned]
if message.chat.id < 0:
names = [_ for _ in first_form if _ in list(map(lambda x: x[0], dg.all_names(du.get_group_id(group_id))))]
# сообщение только из знаков препинания: дальше нужны слова (раньше — IndexError на unsigned[0])
if len(words) >= 2 and unsigned:
# ПОЛЕЗНЫЕ ФУНКЦИИ
if len(words) == 5 and "число от" in low_mes:
try:
num1, num2 = int(low_mes.split()[2]), int(low_mes.split()[4])
await message.answer(
text=f"Число *[ {random.randint(num1, num2)} ]*"
)
except ValueError:
pass
return
if unsigned[0] == 'переведи':
if "переведи - " in low_mes:
await message.answer(md(lang_form([words[_] for _ in range(len(words)) if _ > 1])))
elif len(unsigned) > 1 and f"переведи ({unsigned[1]}) - " in low_mes:
if len(unsigned[1]) == 1:
await message.answer(md(lang_form([words[_] for _ in range(len(words)) if _ > 2], unsigned[1])))
return
if unsigned[0] == 'переверни':
if "переверни - " in low_mes:
await message.answer(md(revers(message.text[12:], True)))
elif "переверни полностью - " in low_mes:
await message.answer(md(revers(message.text[22:], False)))
return
if unsigned[0] == 'озвучь' and "озвучь - " in low_mes:
text_to_voice = message.text[9:]
# синтез — в отдельном потоке и во временный файл (раньше общий voice.ogg:
# одновременные запросы перезаписывали друг друга)
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "voice.mp3")
try:
await asyncio.to_thread(gTTS(text_to_voice, lang='ru').save, path)
except Exception:
logging.exception("gTTS")
await message.reply("Озвучить не получилось, попробуйте позже")
return
await bot.send_voice(chat_id=group_id, voice=FSInputFile(path), caption=f"*{plain(text_to_voice)}*")
return
# РАНДОМ ИВЕНТЫ
if "подраться" == words[0].lower() and "с" == words[1].lower():
text = " ".join([words[i] for i in range(len(words)) if i > 1])
if random.randint(0, 1) == 0:
await message.answer_photo(
photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"),
caption=f"{md(message.from_user.first_name)}, ты был унижен {md(text.title())}"
f", с помощью {base.VAR_LOSE[random.randint(0, 3)]}"
)
else:
await message.answer_photo(
photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"),
caption=f"{md(message.from_user.first_name)}, ты победил в драке "
f"с {md(text.title())}, {base.VAR_WIN[random.randint(0, 1)]}"
)
# ИВЕНТ ВЗАИМОДЕЙСТВИЯ
for word in base.TMOK_LIST:
if word in low_mes:
lst = [words[i] for i in range(len(words)) if i != 0]
text = " ".join(lst)
await message.answer_photo(
photo=FSInputFile(f"../data/tmok/({random.randint(1, 4)}).jpg"),
caption=f"{md(message.from_user.first_name)} "
f"{inflect_past(words[0])} {md(text)}"
)
for word in base.KILL_LIST:
if word in low_mes:
lst = [words[i] for i in range(len(words)) if i != 0]
text = " ".join(lst)
await message.answer_photo(
photo=FSInputFile(f"../data/kill/({random.randint(1, 6)}).jpg"),
caption=f"{md(message.from_user.first_name)} "
f"{inflect_past(words[0])} {md(text)}"
)
if (len(unsigned) >= 3 and
unsigned[0] in base.QUAT_LIST[0] and
unsigned[1] in base.QUAT_LIST[1] and
unsigned[2] in base.QUAT_LIST[2]):
try:
# без таймаута и в обработчике запрос мог надолго заморозить бота
resp = await asyncio.to_thread(requests.get, 'http://fucking-great-advice.ru/api/random', timeout=5)
await message.reply(md(resp.json()["text"]))
except Exception:
logging.warning("Сервис советов недоступен")
return
if message.chat.id < 0:
# УПОМИНАНИЯ ПО ИМЕНАМ
if names:
text = notice(names, False, du.get_group_id(group_id), user_id)
if text:
await message.reply(text)
return
# ПЕРЕВОДЧИК СЛОВ
if (checker([i for i in low_mes], words, group_id, user_id, name.lower()) == len(low_mes) and
not any([engl_dict.check(i) for i in unsigned if len(i) > 1])):
await message.reply(
# раньше tg://user_id?id= — такой ссылки нет, упоминание не работало
f"[{plain(message.from_user.first_name)}](tg://user?id={user_id}) *>* {md(translator(words))}"
)
return

View file

@ -0,0 +1,4 @@
from . import chat, events, help, mentions, settings, stats, voice
# порядок важен: команды раньше общего обработчика текста
routers = [chat.router, help.router, settings.router, stats.router, mentions.router, voice.router, events.router]

126
code/handlers/chat.py Normal file
View file

@ -0,0 +1,126 @@
"""Жизнь бота в чатах: добавили/исключили/поменяли права, служебные сообщения, вход и выход участников"""
import asyncio
import logging
from aiogram import Bot, F, Router
from aiogram.enums import ContentType
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import ChatMemberUpdated, Message
import db
router = Router()
WELCOME = ('<b>Привет группа {title}!</b>\n\nВсе функции вы можете узнать по команде /help! Для того, чтобы '
'уведомления по имени и команда /all нормально функционировали, необходимо чтобы каждый участник '
'группы написал хотя бы одно сообщение в чат! Также для того, чтобы системные сообщения удалялись '
'автоматически, необходимо боту выдать права администратора!')
def rights_of(member):
"""Права бота из ChatMember: все поля can_* (у админа — что он может, у ограниченного — что ему можно)"""
data = member.model_dump()
return {k: v for k, v in data.items() if k.startswith("can_") and isinstance(v, bool)} or None
# СТАТУС БОТА: событие Telegram при любом изменении (добавили, повысили, ограничили, исключили)
@router.my_chat_member()
async def bot_status_changed(update: ChatMemberUpdated, bot: Bot):
if update.chat.type == "private":
return
member = update.new_chat_member
count = None
if member.status in db.IN_CHAT:
try:
count = await bot.get_chat_member_count(update.chat.id)
except Exception:
pass
changed = await db.set_bot_status(update.chat.id, member.status, rights_of(member), "update",
member_count=count, chat_type=update.chat.type, title=update.chat.title)
if changed:
logging.info("Бот в чате %s (%s): %s", update.chat.id, update.chat.title, member.status)
# бота только что добавили — приветствие
old = update.old_chat_member.status
if member.status in ("member", "administrator") and old in ("left", "kicked"):
try:
await bot.send_message(update.chat.id, WELCOME.format(title=update.chat.title or ""))
except Exception:
pass
async def check_chat(bot: Bot, chat_id):
"""Сверка статуса бота с Telegram — ловит изменения, пока бот был выключен"""
try:
chat = await bot.get_chat(chat_id)
member = await bot.get_chat_member(chat_id, bot.id)
count = await bot.get_chat_member_count(chat_id) if member.status in db.IN_CHAT else None
await db.set_bot_status(chat_id, member.status, rights_of(member), "check",
member_count=count, chat_type=chat.type, title=chat.title)
except TelegramForbiddenError:
# исключён или чат удалён: данные не трогаем, фиксируем, что бота там нет
await db.set_bot_status(chat_id, "kicked", None, "check")
except TelegramBadRequest as e:
if "chat not found" in str(e).lower() or "upgraded to a supergroup" in str(e).lower():
await db.set_bot_status(chat_id, "left", None, "check")
else:
logging.warning("Сверка чата %s: %s", chat_id, e)
async def check_all(bot: Bot):
for chat_id in await db.chats_to_check():
await check_chat(bot, chat_id)
await asyncio.sleep(0.5) # без спешки — лимиты Telegram API
# НОВЫЕ УЧАСТНИКИ: служебное сообщение удаляется (если включено и есть права)
@router.message(F.content_type == ContentType.NEW_CHAT_MEMBERS)
async def new_members(message: Message):
await db.ensure_chat(message.chat.id, message.chat.title)
if any(u.id == message.bot.id for u in message.new_chat_members):
return # о самом боте — приветствие в bot_status_changed
if await db.get_setting(message.chat.id, "delete_service"):
try:
await message.delete()
except Exception:
pass
# ГРУППА СТАЛА СУПЕРГРУППОЙ — новый id чата
@router.message(F.content_type.in_([ContentType.MIGRATE_TO_CHAT_ID, ContentType.MIGRATE_FROM_CHAT_ID]))
async def chat_migrated(message: Message):
if message.migrate_to_chat_id:
await db.migrate_chat(message.chat.id, message.migrate_to_chat_id)
elif message.migrate_from_chat_id:
await db.migrate_chat(message.migrate_from_chat_id, message.chat.id)
# УЧАСТНИК ВЫШЕЛ: статистика остаётся, участник больше не упоминается
@router.message(F.content_type == ContentType.LEFT_CHAT_MEMBER)
async def left_member(message: Message):
if message.left_chat_member.id == message.bot.id:
return
await db.member_left(message.chat.id, message.left_chat_member.id)
if await db.get_setting(message.chat.id, "delete_service"):
try:
await message.delete()
except Exception:
pass
# ТЕХНИЧЕСКИЕ СООБЩЕНИЯ
@router.message(F.content_type.in_([
ContentType.NEW_CHAT_TITLE,
ContentType.NEW_CHAT_PHOTO,
ContentType.PINNED_MESSAGE,
ContentType.VIDEO_CHAT_ENDED,
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED
]))
async def chat_events(message: Message):
if message.new_chat_title:
await db.ensure_chat(message.chat.id, message.new_chat_title)
if await db.get_setting(message.chat.id, "delete_service"):
try:
await message.delete()
except Exception:
pass

44
code/handlers/common.py Normal file
View file

@ -0,0 +1,44 @@
"""Общее для обработчиков: учёт статистики, права, размер чата"""
import time
from aiogram import Bot
from aiogram.types import Message
import config
import db
from nlp import clean_name
_size_cache = {}
def in_group(message: Message):
return message.chat.id < 0
async def count(message: Message, *var_ids):
"""Учёт события в группе от автора сообщения"""
if in_group(message) and message.from_user and not message.from_user.is_bot:
await db.count(message.chat.id, message.from_user.id, var_ids, clean_name(message.from_user.first_name),
message.chat.title)
async def is_admin(bot: Bot, chat_id, user_id):
member = await bot.get_chat_member(chat_id, user_id)
return member.status in ("creator", "administrator")
async def chat_size(bot: Bot, chat_id):
"""Число участников чата по Telegram (кэш на 10 минут)"""
cached = _size_cache.get(chat_id)
if cached and time.monotonic() - cached[1] < 600:
return cached[0]
try:
size = await bot.get_chat_member_count(chat_id)
except Exception:
size = await db.member_count(chat_id)
_size_cache[chat_id] = (size, time.monotonic())
return size
async def is_big(bot: Bot, chat_id):
return await chat_size(bot, chat_id) > config.BIG_CHAT

140
code/handlers/events.py Normal file
View file

@ -0,0 +1,140 @@
"""Текстовые сообщения: учёт статистики, ивенты, упоминания по имени, исправление раскладки; медиа и стикеры"""
import asyncio
import logging
import os
import random
import re
import tempfile
import requests
from aiogram import Bot, F, Router
from aiogram.enums import ContentType
from aiogram.types import FSInputFile, Message
import base
import db
from nlp import h, has_url, inflect_past, lang_form, mention, revers, translator, wrong_layout
from .common import count, in_group
from .mentions import mention_in_text
router = Router()
@router.message(F.content_type.in_([
ContentType.LOCATION,
ContentType.CONTACT,
ContentType.VIDEO,
ContentType.PHOTO,
ContentType.AUDIO,
ContentType.DOCUMENT
]))
async def media(message: Message):
await count(message, 5, 1)
@router.message(F.content_type == ContentType.STICKER)
async def stick(message: Message):
await count(message, 6, 1)
async def run_events(message: Message, bot: Bot, low_mes, words, unsigned):
"""Текстовые ивенты; True — если сообщение обработано ивентом"""
if len(words) == 5 and "число от" in low_mes:
try:
num1, num2 = int(words[2]), int(words[4])
await message.answer(f"Число <b>[ {random.randint(min(num1, num2), max(num1, num2))} ]</b>")
except ValueError:
pass
return True
if unsigned[0] == 'переведи':
if "переведи - " in low_mes:
await message.answer(h(lang_form(words[2:])))
elif len(unsigned) > 1 and f"переведи ({unsigned[1]}) - " in low_mes and len(unsigned[1]) == 1:
await message.answer(h(lang_form(words[3:], unsigned[1])))
return True
if unsigned[0] == 'переверни':
if "переверни - " in low_mes:
await message.answer(h(revers(message.text[12:], True)))
elif "переверни полностью - " in low_mes:
await message.answer(h(revers(message.text[22:], False)))
return True
if unsigned[0] == 'озвучь' and "озвучь - " in low_mes:
from gtts import gTTS
text_to_voice = message.text[9:]
# синтез — в отдельном потоке и во временный файл
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "voice.mp3")
try:
await asyncio.to_thread(gTTS(text_to_voice, lang='ru').save, path)
except Exception:
logging.exception("gTTS")
await message.reply("Озвучить не получилось, попробуйте позже")
return True
await message.answer_voice(voice=FSInputFile(path), caption=f"<b>{h(text_to_voice)}</b>")
return True
if words[0].lower() == "подраться" and words[1].lower() == "с":
text = h(" ".join(words[2:]).title())
name = h(message.from_user.first_name)
if random.randint(0, 1) == 0:
caption = f"{name}, ты был унижен {text}, с помощью {random.choice(base.VAR_LOSE)}"
else:
caption = f"{name}, ты победил в драке с {text}, {random.choice(base.VAR_WIN)}"
await message.answer_photo(photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"), caption=caption)
for folder, verbs, pictures in (("tmok", base.TMOK_LIST, 4), ("kill", base.KILL_LIST, 6)):
if any(word in low_mes for word in verbs):
await message.answer_photo(
photo=FSInputFile(f"../data/{folder}/({random.randint(1, pictures)}).jpg"),
caption=f"{h(message.from_user.first_name)} {h(inflect_past(words[0]))} {h(' '.join(words[1:]))}")
if (len(unsigned) >= 3 and unsigned[0] in base.QUAT_LIST[0] and unsigned[1] in base.QUAT_LIST[1]
and unsigned[2] in base.QUAT_LIST[2]):
try:
resp = await asyncio.to_thread(requests.get, 'http://fucking-great-advice.ru/api/random', timeout=5)
await message.reply(h(resp.json()["text"]))
except Exception:
logging.warning("Сервис советов недоступен")
return True
return False
@router.message(F.content_type == ContentType.TEXT)
async def text_message(message: Message, bot: Bot):
text = message.text
low_mes = text.lower()
words = text.split()
unsigned = re.sub(r'[^\w\s]', '', low_mes).split()
# статистика: сообщение, команда, ответ, ссылка — одним запросом
if in_group(message):
events = [1]
if len(text) > 1 and text[0] == '/':
events.append(3)
if message.reply_to_message:
events.append(2)
if has_url(text):
events.append(4)
await count(message, *events)
# ивенты (в группах выключаются /stop_bot; раньше флаг нигде не проверялся)
if len(words) >= 2 and unsigned and (not in_group(message) or await db.get_setting(message.chat.id,
"events_enabled")):
if await run_events(message, bot, low_mes, words, unsigned):
return
if not in_group(message):
return
reply = await mention_in_text(bot, message, text)
if reply:
await message.reply(reply)
return
if not has_url(text) and wrong_layout(text):
await message.reply(f"{mention(message.from_user.id, message.from_user.first_name)} <b>></b> "
f"{h(translator(words))}")

73
code/handlers/help.py Normal file
View file

@ -0,0 +1,73 @@
from aiogram import F, Router
from aiogram.filters import Command, CommandStart
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup, Message
from .common import count, in_group
import db
router = Router()
ABOUT = ('<b>-</b> Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, '
'ивентами и удобными функциями, которые облегчают использование чата)\n\n'
'<b>-</b> Также прошу если вам понравился бот, оставить отзыв о его использовании на '
'команду /report)')
@router.message(CommandStart())
@router.message(Command('help'))
async def helps(message: Message):
await db.ensure_user(message.from_user.id)
if in_group(message):
await count(message, 3, 1)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
InlineKeyboardButton(text="ИВЕНТЫ", callback_data="even")],
[InlineKeyboardButton(text="АВТОР", callback_data="auth"),
InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun")]
])
try:
await message.answer_voice(voice=FSInputFile('../data/tts.ogg'), caption=ABOUT, reply_markup=keyboard)
except Exception:
await message.answer(ABOUT, reply_markup=keyboard)
@router.callback_query(F.data == "auth")
async def author(call: CallbackQuery):
await call.message.answer(
'<b>| АВТОР |</b>\n\n<b>>></b> Этот бот, как бы это не печально звучало, но одна из лучших'
' моих работ и если кого-нибудь у меня получится действительно достойный продукт,'
' вы сможете о нём узнать в моём телеграм канале <b>@programium</b>')
@router.callback_query(F.data == "fun")
async def function(call: CallbackQuery):
await call.message.answer(
'<b>| ФУНКЦИИ |</b>\n\n<b>1.</b> Возможность перевода случайно написанного текста на '
'транслите\n<b>2.</b> Ведение обширной статистики сообщений\n<b>3.</b> Упоминание участника '
'при написании его имени в чате\n<b>4.</b> Автоматическое удаление системных сообщений')
@router.callback_query(F.data == "com")
async def commands(call: CallbackQuery):
await call.message.answer(
'<b>| КОМАНДЫ |</b>\n\n<b>/all</b> - упомянуть всех в чате\n<b>/help</b> - полный список функций\n'
'<b>/recognize</b> - транскрипция отмеченного голосового сообщения в текст\n'
'<b>/stat_group</b> - полная статистика группы\n<b>/stat_user</b> - полная статистика '
'отправителя\n<b>/top</b> - самые активные участники (<b>/top all</b> - за всё время)\n'
'<b>/month</b> - итоги месяца (<b>/month 2026-09</b> - за любой месяц)\n'
'<b>/edit и /back_edit</b> - первая команда даёт возможность '
'сменить имя для упоминаний на любое слово, а вторая для возврата динамического '
'имени\n<b>/settings</b> - настройки бота в чате\n'
'<b>/start_bot и /stop_bot</b> - возможность отключения текстовых ивентов')
@router.callback_query(F.data == "even")
async def events(call: CallbackQuery):
await call.message.answer(
'<b>| ИВЕНТЫ |</b>\n\n<b>"Чмокнуть"</b> - сделать кому-нибудь приятно\n<b>"Отмудохать"</b> - '
'выместить злость на кого-нибудь\n<b>"Число от ... до ..."</b> - случайное значение из '
'диапозона\n<b>"Подраться с ..."</b> - повод кого-нибудь побить\n<b>"Переведи (..) - ..."</b> '
'- перевод слова на керпичный язык\n<b>"Дай блять совет!"</b> - даёт рандомный охуенный '
'совет\n<b>"Переверни - ..."</b> - переворачивает слова в предложении\n<b>"Озвучь - ..."</b> '
'- озвучивает написанный текст')

123
code/handlers/mentions.py Normal file
View file

@ -0,0 +1,123 @@
"""Упоминания: /all, упоминание по имени, своё имя для упоминаний"""
import time
from datetime import datetime, timezone
from aiogram import Bot, Router
from aiogram.filters import Command
from aiogram.types import Message
import config
import db
from nlp import clean_name, find_names, h, join_names, mention, morph
from .common import count, in_group, is_admin, is_big
router = Router()
TEXT_LIMIT = 4096
_last_mention = {} # (чат, участник) → время последнего упоминания по имени
def chunks(parts, suffix, limit=TEXT_LIMIT):
"""Список упоминаний, разбитый на сообщения не длиннее лимита Telegram"""
messages, current = [], []
for part in parts:
candidate = join_names(current + [part]) + suffix
if current and len(candidate) > limit:
messages.append(join_names(current))
current = []
current.append(part)
messages.append(join_names(current) + suffix)
return messages
async def mention_names(bot: Bot, message: Message, found):
"""Текст упоминания найденных в сообщении имён; в большом чате одного человека — не чаще раза в минуту"""
chat_id, author = message.chat.id, message.from_user.id
names = await db.chat_names(chat_id)
targets = [(names[n], n) for n in found if n in names and names[n] != author]
if not targets:
return None
if await is_big(bot, chat_id):
now = time.monotonic()
targets = [(uid, n) for uid, n in targets
if now - _last_mention.get((chat_id, uid), 0) >= config.MENTION_COOLDOWN]
for uid, _ in targets:
_last_mention[(chat_id, uid)] = now
if not targets:
return None
links = [mention(uid, n) for uid, n in targets]
return f"{links[0]}, тебя упомянули)" if len(links) == 1 else f"{join_names(links)} вас упомянули)"
async def mention_in_text(bot: Bot, message: Message, text):
if not await db.get_setting(message.chat.id, "mentions_enabled"):
return None
found = find_names(text, set((await db.chat_names(message.chat.id)).keys()))
return await mention_names(bot, message, found) if found else None
@router.message(Command('all'))
async def every(message: Message, bot: Bot):
if not in_group(message):
await message.reply('Эта команда предназначена для вызова в чате!')
return
await count(message, 3, 1)
chat_id = message.chat.id
big = await is_big(bot, chat_id)
if big:
# в большом чате — только админы, не чаще раза в 5 минут и только недавно писавшие
if not await is_admin(bot, chat_id, message.from_user.id):
await message.reply(f'В чатах больше {config.BIG_CHAT} участников /all доступна только администраторам!')
return
last = await db.last_all(chat_id)
if last and (datetime.now(timezone.utc) - last).total_seconds() < config.ALL_COOLDOWN:
await message.reply(f'В больших чатах /all можно вызывать раз в {config.ALL_COOLDOWN // 60} минут!')
return
members = await db.active_members(chat_id, config.ALL_ACTIVE_DAYS)
else:
members = await db.active_members(chat_id)
others = [(uid, name) for uid, name in members if uid != message.from_user.id]
if len(others) < 2:
await message.reply('В группе состоит менее 3х человек, из-за чего команда не работает!')
return
author = await db.member_name(chat_id, message.from_user.id) or clean_name(message.from_user.first_name)
parts = chunks([mention(uid, name) for uid, name in others], f" вас вызывает {h(author.title())}")
await message.reply(parts[0])
for part in parts[1:]:
await message.answer(part)
await db.mark_all(chat_id)
@router.message(Command('edit'))
async def edit_name(message: Message):
if not in_group(message):
return
await count(message, 3, 1)
words = clean_name(message.text).split()
if len(words) != 2:
await message.reply('Вы не правильно ввели имя! Имя должно быть из одного слова и идти сразу после команды!')
return
name = morph.parse(words[1])[0].normal_form or words[1]
if name in await db.chat_names(message.chat.id):
await message.reply(f'{h(message.from_user.first_name.title())}, такое имя уже присутствует в чате!')
return
await db.set_custom_name(message.chat.id, message.from_user.id, name)
await message.reply(f'{h(name.title())}, ваше имя было успешно изменено)')
@router.message(Command('back_edit'))
async def back_edit(message: Message):
if not in_group(message):
return
await count(message, 3, 1)
name = clean_name(message.from_user.first_name)
if await db.reset_custom_name(message.chat.id, message.from_user.id, name):
await message.reply(f'{h(name.title())}, вы успешно вернулись к динамическому изменению имени)')
else:
await message.reply(f'{h(name.title())}, вы не устанавливали постоянное имя!')

70
code/handlers/settings.py Normal file
View file

@ -0,0 +1,70 @@
"""Настройки бота в чате: кнопки-переключатели, менять могут администраторы"""
from aiogram import Bot, F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
import db
from .common import count, in_group, is_admin
router = Router()
LABELS = {
"mentions_enabled": "Упоминания по имени",
"voice_names": "Имена в голосовых (через Google)",
"events_enabled": "Текстовые ивенты",
"delete_service": "Удаление служебных сообщений",
}
async def keyboard(chat_id):
current = await db.settings(chat_id)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=f"{'✅' if current[key] else '❌'} {label}", callback_data=f"set:{key}")]
for key, label in LABELS.items()
])
@router.message(Command('settings'))
async def show_settings(message: Message):
if not in_group(message):
await message.reply('Настройки есть только у групп — вызовите команду в чате!')
return
await count(message, 3, 1)
await message.answer('<b>| НАСТРОЙКИ ЧАТА |</b>\n\nНажмите, чтобы включить или выключить. Менять настройки '
'могут администраторы.', reply_markup=await keyboard(message.chat.id))
@router.callback_query(F.data.startswith("set:"))
async def toggle(call: CallbackQuery, bot: Bot):
key = call.data[4:]
if key not in LABELS:
return
if not await is_admin(bot, call.message.chat.id, call.from_user.id):
await call.answer("Настройки могут менять только администраторы", show_alert=True)
return
value = await db.toggle_setting(call.message.chat.id, key)
await call.message.edit_reply_markup(reply_markup=await keyboard(call.message.chat.id))
await call.answer(f"{LABELS[key]}: {'включено' if value else 'выключено'}")
async def set_events(message: Message, enable):
if not in_group(message):
return
await count(message, 3, 1)
if await db.get_setting(message.chat.id, "events_enabled") == enable:
await message.answer("У вас уже включены текстовые ивенты!" if enable
else "У вас уже отключены текстовые ивенты!")
return
await db.toggle_setting(message.chat.id, "events_enabled")
await message.answer("Текстовые ивенты включены!" if enable else "Текстовые ивенты отключены!")
@router.message(Command('start_bot'))
async def opening(message: Message):
await set_events(message, True)
@router.message(Command('stop_bot'))
async def closing(message: Message):
await set_events(message, False)

117
code/handlers/stats.py Normal file
View file

@ -0,0 +1,117 @@
"""Статистика чата и участников: за всё время, за месяц, топ и итоги месяца"""
import re
from datetime import date
from aiogram import Router
from aiogram.filters import Command, CommandObject
from aiogram.types import Message
import db
from nlp import clean_name, genitive, h
from .common import count, in_group
router = Router()
MONTHS = ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь",
"ноябрь", "декабрь"]
def stat_lines(total, month):
return (f'<b>- За всё время</b> / <b>За месяц -</b>\n'
f'<b>>></b> Сообщений в базе <b>[ {total[0]} / {month[0]} ]</b>\n\n'
f'<b>></b> Ответов <b>- [ {total[1]} / {month[1]} ]</b>\n'
f'<b>></b> Команд <b>- [ {total[2]} / {month[2]} ]</b>\n'
f'<b>></b> Ссылок <b>- [ {total[3]} / {month[3]} ]</b>\n'
f'<b>></b> Стикеров <b>- [ {total[5]} / {month[5]} ]</b>\n'
f'<b>></b> Медиа файлов <b>- [ {total[4]} / {month[4]} ]</b>\n'
f'<b>></b> Голос/Кружочки <b>- [ {total[6]} / {month[6]} ]</b>')
def month_title(period):
year, month = period.split("-")
return f"{MONTHS[int(month) - 1]} {year}"
def parse_period(arg):
"""«2026-09», «прошлый» или ничего (текущий месяц)"""
today = date.today()
if not arg:
return db.month_key(today)
arg = arg.strip().lower()
if arg in ("прошлый", "prev", "last"):
first = today.replace(day=1)
return db.month_key(date(first.year - (first.month == 1), (first.month - 2) % 12 + 1, 1))
if re.fullmatch(r"\d{4}-\d{2}", arg) and 1 <= int(arg[5:]) <= 12:
return arg
return None
@router.message(Command('stat_group'))
async def stat_group(message: Message):
if not in_group(message):
return
await count(message, 3, 1)
total = await db.chat_stats(message.chat.id, "all")
month = await db.chat_stats(message.chat.id, db.month_key())
await message.answer(f'<b>| СТАТИСТИКА ГРУППЫ |</b>\n\n'
f'<b>>></b> В целом сообщений <b>[ {message.message_id} ]</b>\n\n' + stat_lines(total, month))
@router.message(Command('stat_user'))
async def stat_user(message: Message):
if not in_group(message):
return
await count(message, 3, 1)
total = await db.member_stats(message.chat.id, message.from_user.id, "all")
month = await db.member_stats(message.chat.id, message.from_user.id, db.month_key())
name = genitive(clean_name(message.from_user.first_name).replace(" ", "")) or message.from_user.first_name
await message.answer(f'<b>| СТАТИСТИКА {h(name.upper())} |</b>\n\n' + stat_lines(total, month))
@router.message(Command('top'))
async def top(message: Message, command: CommandObject):
"""Самые активные участники по сообщениям: за месяц или за всё время (/top all)"""
if not in_group(message):
return
await count(message, 3, 1)
all_time = (command.args or "").strip().lower() in ("all", "всё", "все")
period = "all" if all_time else db.month_key()
rows = await db.top(message.chat.id, period, "mes", 10)
if not rows:
await message.answer("Статистики пока нет!")
return
title = "за всё время" if all_time else f"за {month_title(period)}"
# без ссылок-упоминаний: иначе каждый из топа получил бы уведомление
lines = [f"<b>{i}.</b> {h(name.title())} — <b>{value}</b>" for i, (_, name, value) in enumerate(rows, 1)]
await message.answer(f"<b>| ТОП УЧАСТНИКОВ {title.upper()} |</b>\n\n" + "\n".join(lines),
disable_notification=True)
CATEGORIES = [("mes", "Больше всех писали"), ("rep", "Больше всех отвечали"), ("sti", "Короли стикеров"),
("voi", "Больше всех голосовых и кружочков"), ("med", "Больше всех медиа")]
@router.message(Command('month'))
async def month_results(message: Message, command: CommandObject):
"""Итоги месяца — только по команде: топ-3 в каждой категории"""
if not in_group(message):
return
await count(message, 3, 1)
period = parse_period(command.args)
if period is None:
await message.reply("Укажите месяц в формате <b>2026-09</b> или <b>прошлый</b>")
return
blocks = []
for field, title in CATEGORIES:
rows = await db.top(message.chat.id, period, field, 3)
if rows:
blocks.append(f"<b>{title}:</b>\n" + "\n".join(
f"{i}. {h(name.title())} — {value}" for i, (_, name, value) in enumerate(rows, 1)))
if not blocks:
await message.answer(f"За {month_title(period)} статистики нет!")
return
totals = await db.chat_stats(message.chat.id, period)
await message.answer(f"<b>| ИТОГИ: {month_title(period).upper()} |</b>\n\n"
f"Сообщений в чате: <b>{totals[0]}</b>\n\n" + "\n\n".join(blocks))

86
code/handlers/voice.py Normal file
View file

@ -0,0 +1,86 @@
"""Голосовые: /recognize и поиск имён в голосовых (Google Web Speech, выключается в /settings)"""
import asyncio
import logging
import os
import tempfile
import soundfile as sf
import speech_recognition as sr
from aiogram import Bot, F, Router
from aiogram.enums import ContentType
from aiogram.filters import Command
from aiogram.types import Message
import db
from nlp import find_names, h
from .common import count, in_group
from .mentions import mention_names
router = Router()
def recognize_file(oga_path):
"""Голосовое (OGG/Opus) → текст. Синхронно — вызывается в отдельном потоке"""
data, samplerate = sf.read(oga_path)
wav_path = oga_path[:-4] + ".wav"
sf.write(wav_path, data, samplerate)
r = sr.Recognizer()
with sr.AudioFile(wav_path) as source:
r.pause_threshold = 100
audio = r.listen(source)
return r.recognize_google(audio, language='ru-RU')
async def recognize_voice(bot: Bot, file_id):
"""Распознавание без блокировки бота, файлы — во временной папке на запрос"""
with tempfile.TemporaryDirectory() as tmp:
oga = os.path.join(tmp, "voice.oga")
file = await bot.get_file(file_id)
await bot.download_file(file.file_path, oga)
try:
return await asyncio.to_thread(recognize_file, oga)
except sr.UnknownValueError: # речь не разобрана
return None
except Exception:
logging.exception("Ошибка распознавания голосового")
return None
@router.message(Command("recognize"))
async def recognise(message: Message, bot: Bot):
if in_group(message):
await count(message, 3, 1)
voice = message.reply_to_message.voice if message.reply_to_message else None
if not voice:
await message.reply("Ответьте этой командой на голосовое сообщение!")
return
mes = await message.reply_to_message.reply("Распознавание.....")
query = await recognize_voice(bot, voice.file_id)
if query:
await mes.edit_text(f'<b>{h(message.reply_to_message.from_user.first_name)} сказал(a)</b> "{h(query)}"')
else:
await mes.edit_text("Распознать сообщение не удалось!")
@router.message(F.content_type.in_([ContentType.VOICE, ContentType.VIDEO_NOTE]))
async def voice_message(message: Message, bot: Bot):
if not in_group(message):
return
await count(message, 7, 1)
# имена в голосовом: голос уходит в Google, поэтому в каждом чате можно выключить
if not (message.voice and message.voice.duration <= 60):
return
settings = await db.settings(message.chat.id)
if not (settings["voice_names"] and settings["mentions_enabled"]):
return
query = await recognize_voice(bot, message.voice.file_id)
if not query:
return
found = find_names(query, set((await db.chat_names(message.chat.id)).keys()))
text = await mention_names(bot, message, found) if found else None
if text:
await message.reply(text)

View file

@ -1,26 +0,0 @@
from aiogram import Bot, Dispatcher
from aiogram.enums.parse_mode import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.bot import DefaultBotProperties
import os
import base
import enchant
import pymorphy3
bot = Bot(token=base.TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN))
dp = Dispatcher(storage=MemoryStorage())
morph = pymorphy3.MorphAnalyzer()
engl_dict = enchant.Dict("en_US")
import sql
os.makedirs('../db', exist_ok=True)
db = sql.Base('../db/base.db')
du = sql.User('../db/users.db')
dg = sql.Group('../db/groups.db')
dm = sql.Month('../db/month.db')

146
code/nlp.py Normal file
View file

@ -0,0 +1,146 @@
"""Обработка текста: имена, неправильная раскладка, переворот, кирпичный язык"""
import re
from html import escape
from string import digits, punctuation
import enchant
import pymorphy3
from base import ALB, ERR, ERR_, TRU, TRU_
morph = pymorphy3.MorphAnalyzer()
engl_dict = enchant.Dict("en_US")
def h(text):
"""Экранирование для HTML-разметки сообщений"""
return escape(str(text), quote=False)
def mention(user_id, name):
return f'<a href="tg://user?id={int(user_id)}">{h(str(name).title())}</a>'
def join_names(items):
"""«а», «а и б», «а, б и в»"""
if len(items) <= 1:
return "".join(items)
return ", ".join(items[:-1]) + " и " + items[-1]
def clean_name(name):
"""Имя участника для упоминаний: нижний регистр, без знаков препинания"""
return re.sub(r'[^\w\s]', '', (name or "").lower()).strip()
def words_normal(text):
"""Слова сообщения в начальной форме"""
return [morph.parse(w)[0].normal_form for w in re.sub(r'[^\w\s]', '', text.lower()).split()]
def find_names(text, names):
"""Имена участников, упомянутые в тексте (в любой форме), без повторов"""
found = []
for word in words_normal(text):
if word in names and word not in found:
found.append(word)
return found
def inflect_past(word):
"""Глагол в прошедшем времени; если pymorphy3 не справился — как есть"""
form = morph.parse(word.lower())[0].inflect({'past', 'sing', 'indc'})
return form.word if form else word
def genitive(name):
form = morph.parse(name)[0].inflect({"gent"})
return form.word if form else name
def has_url(text):
return "http" in text.lower()
def wrong_layout(text):
"""Сообщение целиком набрано латиницей в русской раскладке (и это не английские слова)"""
if not text or text[0] == '/' or all(c in digits or c in punctuation or c.isspace() for c in text):
return False
if any(not c.isspace() and c not in ERR and c not in ERR_ and c not in digits for c in text):
return False
words = [w for w in re.sub(r'[^\w\s]', '', text.lower()).split() if len(w) > 1]
return not any(engl_dict.check(w) for w in words)
def lang_form(text, smbl='г'):
for i in range(len(text)):
word = [str(j) + str(smbl) + str(j).lower() if j in ALB else str(j) for j in text[i]]
text[i] = ''.join(word)
return ' '.join(text)
def translator(words):
itg = []
for word in words:
raw_word = []
for symbol in word:
if symbol in ERR:
count = 0
for _ in ERR:
if symbol == _:
raw_word.append(TRU[count])
break
count += 1
elif symbol in ERR_:
count = 0
for _ in ERR_:
if symbol == _:
raw_word.append(TRU_[count])
break
count += 1
itg.append("".join(raw_word))
return " ".join(itg)
def revers(message, var):
no_pct = re.sub(r'[^\w\s]', '', message)
if var:
sml, pnc, pct, prf = [i for i in message] + [str(0)], [], [], False
for i in sml:
if i in punctuation or i == ' ':
pnc.append(i)
prf, flag = True if sml.index(i) == 0 else False, False
else:
flag = True
if flag and len(pnc) > 0:
pct.append(''.join(pnc))
pnc = []
wrd, rev, txt = no_pct.split(), [], []
for i in wrd:
word, up, itg = [i[-1 - l].lower() for l in range(len(i))], [], []
for _ in i:
up.append(True if _ in TRU_ else False)
for j in range(len(up)):
itg.append(word[j].upper() if up[j] else word[j])
rev.append(''.join(itg))
if prf:
for i in range(len(pct)):
txt = (txt + [pct[i]]) if (i + 1) == len(pct) and len(pct) > len(rev) else (txt + [pct[i], rev[i]])
return ''.join(txt)
else:
for i in range(len(rev)):
if i > 0:
txt.append(pct[i - 1])
txt.append(rev[i])
if (i + 1) == len(rev) and len(pct) == len(rev):
txt.append(pct[i])
return ''.join(txt)
else:
sml = [i for i in message]
return ''.join([sml[-1 - i] for i in range(len(sml))])

73
code/schema.sql Normal file
View file

@ -0,0 +1,73 @@
-- Схема AboBot в PostgreSQL. Применяется при каждом запуске (всё IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS chats (
id BIGINT PRIMARY KEY, -- id чата в Telegram
short_id SERIAL UNIQUE, -- внутренний номер («Группа #N» в Metabase)
title TEXT,
events_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- текстовые ивенты (/start_bot, /stop_bot)
mentions_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- упоминания по имени в сообщениях
voice_names BOOLEAN NOT NULL DEFAULT TRUE, -- поиск имён в голосовых (уходят в Google)
delete_service BOOLEAN NOT NULL DEFAULT TRUE, -- удаление служебных сообщений
last_all_at TIMESTAMPTZ, -- последний /all (ограничение в больших чатах)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- бот в чате: статус Telegram (member, administrator, restricted, left, kicked) и права;
-- если бота исключили, данные чата остаются, меняется только статус
bot_status TEXT,
bot_rights JSONB,
bot_status_at TIMESTAMPTZ, -- когда статус изменился
checked_at TIMESTAMPTZ, -- когда статус последний раз сверялся с Telegram
member_count INTEGER, -- участников по Telegram на момент сверки
chat_type TEXT
);
-- история статуса бота в чатах: добавили, повысили до админа, ограничили, исключили
CREATE TABLE IF NOT EXISTS bot_status_history (
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL REFERENCES chats (id) ON DELETE CASCADE ON UPDATE CASCADE,
status TEXT NOT NULL,
rights JSONB,
source TEXT NOT NULL, -- update (событие Telegram) или check (сверка)
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY, -- id пользователя в Telegram
short_id SERIAL UNIQUE
);
CREATE TABLE IF NOT EXISTS members (
chat_id BIGINT NOT NULL REFERENCES chats (id) ON DELETE CASCADE ON UPDATE CASCADE,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '', -- имя для упоминаний (в нижнем регистре)
custom_name BOOLEAN NOT NULL DEFAULT FALSE, -- задано командой /edit, не обновляется само
last_message_at TIMESTAMPTZ,
left_at TIMESTAMPTZ, -- вышел из чата (статистика сохраняется)
PRIMARY KEY (chat_id, user_id)
);
-- период: 'all' — за всё время, 'YYYY-MM' — месяц; месяцы хранятся, а не обнуляются
CREATE TABLE IF NOT EXISTS chat_stats (
chat_id BIGINT NOT NULL REFERENCES chats (id) ON DELETE CASCADE ON UPDATE CASCADE,
period TEXT NOT NULL,
mes INTEGER NOT NULL DEFAULT 0, rep INTEGER NOT NULL DEFAULT 0, com INTEGER NOT NULL DEFAULT 0,
url INTEGER NOT NULL DEFAULT 0, med INTEGER NOT NULL DEFAULT 0, sti INTEGER NOT NULL DEFAULT 0,
voi INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_id, period)
);
CREATE TABLE IF NOT EXISTS member_stats (
chat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
period TEXT NOT NULL,
mes INTEGER NOT NULL DEFAULT 0, rep INTEGER NOT NULL DEFAULT 0, com INTEGER NOT NULL DEFAULT 0,
url INTEGER NOT NULL DEFAULT 0, med INTEGER NOT NULL DEFAULT 0, sti INTEGER NOT NULL DEFAULT 0,
voi INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_id, user_id, period),
FOREIGN KEY (chat_id, user_id) REFERENCES members (chat_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS member_stats_period ON member_stats (chat_id, period);
-- бот в чате сейчас (для статистики по действующим группам)
CREATE OR REPLACE VIEW active_chats AS
SELECT * FROM chats WHERE bot_status IN ('member', 'administrator', 'restricted');

View file

@ -1,189 +0,0 @@
import re
from string import digits, punctuation
from base import ERR, ERR_, TRU, TRU_, ALB
# одни и те же соединения с базами, что и у обработчиков (раньше здесь открывались вторые)
from init import db, du, dg, dm
MD_SPECIAL = "_*`["
def md(text):
"""Экранирование для Markdown вне разметки: «_» или «*» в имени или тексте иначе ломают отправку"""
return "".join("\\" + ch if ch in MD_SPECIAL else ch for ch in str(text))
def plain(text):
"""Текст внутри *жирного* или [ссылки]: экранирование там не работает, спецсимволы убираются"""
return "".join(ch for ch in str(text) if ch not in MD_SPECIAL + "]")
def join_names(items):
"""«а», «а и б», «а, б и в»"""
if len(items) <= 1:
return "".join(items)
return ", ".join(items[:-1]) + " и " + items[-1]
def upd_stat(user_id, group_id, var_id, name, mes=False):
name = re.sub(r'[^\w\s]', '', name.lower())
if not du.user_exists(user_id):
du.add_user(user_id)
user_id = du.get_user_id(user_id)
if not du.group_exists(group_id):
du.add_group(group_id)
group_id = du.get_group_id(group_id)
if not db.group_exists(group_id):
db.add_group(group_id)
dg.created_group(group_id)
if not dg.user_exists(user_id, group_id):
dg.add_user(group_id, user_id, name)
db.update_stat(group_id, var_id)
dg.update_stat(user_id, group_id, var_id)
if not db.group_exists_month(group_id):
db.add_group_month(group_id)
dm.created_group(group_id)
dm.add_user(group_id, user_id)
else:
if not dm.user_exists(user_id, group_id):
dm.add_user(group_id, user_id)
db.update_month_stat(group_id, var_id)
dm.update_stat(user_id, group_id, var_id)
if mes:
db.update_month_stat(group_id, 1)
dg.update_stat(user_id, group_id, 1)
dm.update_stat(user_id, group_id, 1)
db.update_stat(group_id, 1)
if not db.edit_user_exists(user_id):
dg.update_name(user_id, group_id, name)
def lang_form(text, smbl='г'):
for i in range(len(text)):
word = [str(j) + str(smbl) + str(j).lower() if j in ALB else str(j) for j in text[i]]
text[i] = ''.join(word)
return ' '.join(text)
def notice(name, all_users, group_id, author):
names = [i[0] for i in dg.all_names(group_id)]
ids = [du.get_first_user_id(i[0]) for i in dg.all_ids(group_id)]
if all_users:
name = dg.user_name(name, group_id)
usr = [f"[{plain(names[i].title())}](tg://user?id={str(ids[i])})" for i in range(len(names)) if names[i] != name.lower()]
if len(usr) < 2:
raise ValueError("в группе меньше трёх участников")
return f'{join_names(usr)} вас вызывает {md(name.title())}'
else:
no_copy = []
for i in name:
if i not in no_copy:
no_copy.append(i)
if len(no_copy) > 1:
# раньше при двух именах, одно из которых — автор, падало на usr[-2]
usr = [f"[{plain(_.title())}](tg://user?id={str(ids[names.index(_)])})" for _ in no_copy if int(author) != ids[names.index(_)]]
if len(usr) > 1:
return f"{join_names(usr)} вас упомянули)"
if usr:
return f"{usr[0]}, тебя упомянули)"
return None
else:
usr = f"[{plain(no_copy[0].title())}](tg://user?id={str(ids[names.index(no_copy[0])])})"
return f"{usr}, тебя упомянули)"
def checker(signs, words, group_id, user_id, name):
if len([z for z in signs if (z in digits) or (z in punctuation)]) == len(signs) or signs[0] == '/':
return int('-1')
if len(signs) > 4:
for x in range(3, len(signs)):
http = "".join([signs[x - 3], signs[x - 2], signs[x - 1], signs[x]])
if 'http' in http:
upd_stat(user_id, group_id, 4, name)
return int('-1')
count, raw = 0, 0
for word in words:
for symbol in word:
raw += 1
if (symbol in ERR) or (symbol in ERR_) or (symbol in digits):
count += 1
return len(signs) - raw + count
def translator(words):
itg = []
for word in words:
raw_word = []
for symbol in word:
if symbol in ERR:
count = 0
for _ in ERR:
if symbol == _:
raw_word.append(TRU[count])
break
count += 1
elif symbol in ERR_:
count = 0
for _ in ERR_:
if symbol == _:
raw_word.append(TRU_[count])
break
count += 1
itg.append("".join(raw_word))
return " ".join(itg)
def revers(message, var):
no_pct = re.sub(r'[^\w\s]', '', message)
if var:
sml, pnc, pct, prf = [i for i in message] + [str(0)], [], [], False
for i in sml:
if i in punctuation or i == ' ':
pnc.append(i)
prf, flag = True if sml.index(i) == 0 else False, False
else:
flag = True
if flag and len(pnc) > 0:
pct.append(''.join(pnc))
pnc = []
wrd, rev, txt = no_pct.split(), [], []
for i in wrd:
word, up, itg = [i[-1 - l].lower() for l in range(len(i))], [], []
for _ in i:
up.append(True if _ in TRU_ else False)
for j in range(len(up)):
itg.append(word[j].upper() if up[j] else word[j])
rev.append(''.join(itg))
if prf:
for i in range(len(pct)):
txt = (txt + [pct[i]]) if (i + 1) == len(pct) and len(pct) > len(rev) else (txt + [pct[i], rev[i]])
return ''.join(txt)
else:
for i in range(len(rev)):
if i > 0:
txt.append(pct[i - 1])
txt.append(rev[i])
if (i + 1) == len(rev) and len(pct) == len(rev):
txt.append(pct[i])
return ''.join(txt)
else:
sml = [i for i in message]
return ''.join([sml[-1 - i] for i in range(len(sml))])

View file

@ -1,362 +0,0 @@
import logging
import sqlite3
# номер счётчика в обработчиках → колонка таблицы статистики
COLUMNS = {1: "mes", 2: "rep", 3: "com", 4: "url", 5: "med", 6: "sti", 7: "voi"}
# Таблица на каждый чат — так устроены рабочие базы бота; имя таблицы — внутренний числовой id чата,
# поэтому перед подстановкой в SQL он приводится к int
def table(group_id):
return f"[{int(group_id)}]"
class Base:
def __init__(self, database):
"""Подключаемся к БД и сохраняем курсор соединения"""
self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor()
# на новой установке таблиц ещё нет
stat_columns = ", ".join(f"{c} INTEGER" for c in COLUMNS.values())
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS work (group_id INTEGER NOT NULL, "
"state BOOLEAN NOT NULL DEFAULT (True))")
self.cursor.execute(f"CREATE TABLE IF NOT EXISTS stat (group_id INTEGER NOT NULL, {stat_columns})")
self.cursor.execute(f"CREATE TABLE IF NOT EXISTS month (group_id INTEGER NOT NULL, {stat_columns})")
self.cursor.execute("CREATE TABLE IF NOT EXISTS edit (user_id INTEGER NOT NULL)")
self.cursor.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
def get_meta(self, key):
with self.connection:
row = self.cursor.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
return row[0] if row else None
def set_meta(self, key, value):
with self.connection:
self.cursor.execute("INSERT INTO meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
def reset_month(self):
"""Обнуляем месячную статистику групп"""
with self.connection:
self.cursor.execute("UPDATE month SET " + ", ".join(f"{c} = 0" for c in COLUMNS.values()))
def increment(self, name, group_id, var_id):
"""Счётчик +1 одним запросом: раньше SELECT и UPDATE с ручным +1 теряли сообщения,
пришедшие одновременно"""
column = COLUMNS[var_id]
try:
with self.connection:
self.cursor.execute(f"UPDATE {name} SET {column} = {column} + 1 WHERE group_id = ?", (group_id,))
except sqlite3.Error:
# статистика не должна мешать остальной обработке сообщения
logging.exception("Не удалось обновить статистику %s", name)
# КОМАНДЫ
def add_group(self, group_id):
"""Добавляем нового пользователя"""
with self.connection:
self.cursor.execute("INSERT INTO `work` (`group_id`) VALUES(?)", (group_id,))
self.cursor.execute("INSERT INTO `stat` (`group_id`, `mes`, `rep`, `com`, `url`, `med`, `sti`, "
"`voi`) VALUES(?,?,?,?,?,?,?,?)", (group_id, 0, 0, 0, 0, 0, 0, 0))
return
def group_exists(self, group_id):
"""Проверяем, есть ли уже пользователь в базе"""
with self.connection:
result = self.cursor.execute('SELECT * FROM `work` WHERE `group_id` = ?', (group_id,)).fetchall()
return bool(len(result))
# ТАБЛИЦА STAT
def update_stat(self, group_id, var_id):
"""Обновляем статистику"""
self.increment("stat", group_id, var_id)
def stat_group(self, group_id):
"""Получение данных пользователя"""
with self.connection:
self.cursor.execute("SELECT * FROM `stat` WHERE `group_id` = ?", (group_id,))
data = self.cursor.fetchone()
return data[1:]
# ТАБЛИЦА MONTH
def group_exists_month(self, group_id):
"""Проверяем, есть ли уже пользователь в базе"""
with self.connection:
result = self.cursor.execute('SELECT * FROM `month` WHERE `group_id` = ?', (group_id,)).fetchall()
return bool(len(result))
def add_group_month(self, group_id):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute("INSERT INTO `month` (`group_id`, `mes`, `rep`, `com`, `url`, `med`, `sti`, "
"`voi`) VALUES(?,?,?,?,?,?,?,?)", (group_id, 0, 0, 0, 0, 0, 0, 0))
def update_month_stat(self, group_id, var_id):
"""Обновляем статистику"""
self.increment("month", group_id, var_id)
def month_stat_group(self, group_id):
"""Получение данных пользователя"""
with self.connection:
self.cursor.execute("SELECT * FROM `month` WHERE `group_id` = ?", (group_id,))
data = self.cursor.fetchone()
return data[1:]
# ТАБЛИЦА EDIT
def add_edit_user(self, user_id):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute("INSERT INTO `edit` (`user_id`) VALUES(?)", (user_id,))
def edit_user_exists(self, user_id):
"""Проверяем, есть ли уже пользователь в базе"""
with self.connection:
result = self.cursor.execute('SELECT * FROM `edit` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def del_edit_user(self, user_id):
"""Удаление пользователя"""
with self.connection:
return self.cursor.execute('DELETE FROM `edit` WHERE `user_id` = ?', (user_id,))
# ТАБЛИЦА WORK
def check_status(self, group_id):
"""Получаем статус"""
with self.connection:
return self.cursor.execute('SELECT `state` FROM `work` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
def update_status(self, group_id):
"""Обновляем статус"""
with self.connection:
state = self.cursor.execute('SELECT `state` FROM `work` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `work` SET `state` = ? WHERE `group_id` = ?", (not state, group_id))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()
class User:
def __init__(self, database):
"""Подключаемся к БД и сохраняем курсор соединения"""
self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor()
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"user_id INTEGER NOT NULL)")
self.cursor.execute("CREATE TABLE IF NOT EXISTS \"groups\" (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"group_id INTEGER NOT NULL)")
# СВЯЗКА ПОЛЬЗОВАТЕЛЯ
def user_exists(self, user_id):
"""Проверяем, есть ли уже пользователь в базе"""
with self.connection:
result = self.cursor.execute('SELECT * FROM `users` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def add_user(self, user_id):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute("INSERT INTO `users` (`user_id`) VALUES(?)", (user_id,))
def get_user_id(self, user_id):
"""Получаем короткое айди юзера"""
with self.connection:
return self.cursor.execute('SELECT `id` FROM `users` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_first_user_id(self, user_id):
"""Получаем длинное айди юзера"""
with self.connection:
return self.cursor.execute('SELECT `user_id` FROM `users` WHERE `id` = ?', (user_id,)).fetchone()[0]
# СВЯЗКА ГРУППЫ
def group_exists(self, group_id):
"""Проверяем, есть ли уже группа в базе"""
with self.connection:
result = self.cursor.execute('SELECT * FROM `groups` WHERE `group_id` = ?', (group_id,)).fetchall()
return bool(len(result))
def add_group(self, group_id):
"""Добавляем новую группу в таблицу"""
with self.connection:
return self.cursor.execute("INSERT INTO `groups` (`group_id`) VALUES(?)", (group_id,))
def get_group_id(self, group_id):
"""Получаем короткое айди юзера"""
with self.connection:
return self.cursor.execute('SELECT `id` FROM `groups` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
def get_first_group_id(self, group_id):
"""Получаем длинное айди юзера"""
with self.connection:
return self.cursor.execute('SELECT `group_id` FROM `groups` WHERE `id` = ?', (group_id,)).fetchone()[0]
def update_group_id(self, from_id, to_id):
"""Заменяем на новый айди"""
with self.connection:
return self.cursor.execute("UPDATE `groups` SET `group_id` = ? WHERE `id` = ?", (to_id, from_id))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()
class Group:
def __init__(self, database):
"""Подключаемся к БД и сохраняем курсор соединения"""
self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor()
# КОМАНДЫ
def created_group(self, group_id):
"""Создаём новую таблицу"""
with self.connection:
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS {table(group_id)} (
user_id INTEGER NOT NULL,
first_name STRING,
mes INTEGER,
rep INTEGER,
com INTEGER,
url INTEGER,
med INTEGER,
sti INTEGER,
voi INTEGER);""")
def add_user(self, group_id, user_id, name):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute(f"INSERT INTO {table(group_id)} (`user_id`, `first_name`, `mes`, `rep`, `com`, `url`, "
f"`med`, `sti`, `voi`) VALUES(?,?,?,?,?,?,?,?,?)", (user_id, name, 0, 0, 0, 0, 0,
0, 0))
def all_names(self, group_id):
"""Список имён"""
with self.connection:
return self.cursor.execute(f'SELECT `first_name` FROM {table(group_id)}').fetchall()
def all_ids(self, group_id):
"""Список айди"""
with self.connection:
return self.cursor.execute(f'SELECT `user_id` FROM {table(group_id)}').fetchall()
def update_stat(self, user_id, group_id, var_id):
"""Обновляем статистику"""
column = COLUMNS[var_id]
try:
with self.connection:
self.cursor.execute(f"UPDATE {table(group_id)} SET {column} = {column} + 1 WHERE user_id = ?",
(user_id,))
except sqlite3.Error:
logging.exception("Не удалось обновить статистику участника")
def user_exists(self, user_id, group_id):
"""Проверяем, есть ли уже пользователь в базе"""
with self.connection:
result = self.cursor.execute(f'SELECT * FROM {table(group_id)} WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def update_name(self, user_id, group_id, name):
"""Обновляем имя пользователя"""
with self.connection:
return self.cursor.execute(f"UPDATE {table(group_id)} SET `first_name` = ? WHERE `user_id` = ?", (name, user_id))
def user_name(self, user_id, group_id):
"""Получаем имя пользователя по айди"""
with self.connection:
return self.cursor.execute(f"SELECT `first_name` FROM {table(group_id)} WHERE `user_id` = ?", (user_id,)).fetchone()[0]
def stat_user(self, user_id, group_id):
"""Получение данных пользователя"""
with self.connection:
self.cursor.execute(f"SELECT * FROM {table(group_id)} WHERE `user_id` = ?", (user_id,))
data = self.cursor.fetchone()
return data[2:]
def del_user(self, group_id, user_id):
"""Удаление пользователя"""
with self.connection:
return self.cursor.execute(f'DELETE FROM {table(group_id)} WHERE `user_id` = ?', (user_id,))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()
class Month:
def __init__(self, database):
"""Подключаемся к БД и сохраняем курсор соединения"""
self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor()
def reset_all(self):
"""Обнуляем месячную статистику участников во всех чатах"""
with self.connection:
tables = [r[0] for r in self.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
for name in tables:
if name.lstrip("-").isdigit():
self.cursor.execute(f"UPDATE {table(name)} SET " + ", ".join(f"{c} = 0" for c in COLUMNS.values()))
# КОМАНДЫ
def update_stat(self, user_id, group_id, var_id):
"""Обновляем статистику"""
column = COLUMNS[var_id]
try:
with self.connection:
self.cursor.execute(f"UPDATE {table(group_id)} SET {column} = {column} + 1 WHERE user_id = ?",
(user_id,))
except sqlite3.Error:
logging.exception("Не удалось обновить статистику участника")
def created_group(self, group_id):
"""Создаём новую таблицу"""
with self.connection:
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS {table(group_id)} (
user_id INTEGER NOT NULL,
mes INTEGER,
rep INTEGER,
com INTEGER,
url INTEGER,
med INTEGER,
sti INTEGER,
voi INTEGER);""")
def add_user(self, group_id, user_id):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute(f"INSERT INTO {table(group_id)} (`user_id`, `mes`, `rep`, `com`, `url`, `med`, "
f"`sti`, `voi`) VALUES(?,?,?,?,?,?,?,?)", (user_id, 0, 0, 0, 0, 0, 0, 0))
def all_ids(self, group_id):
"""Список айди"""
with self.connection:
return self.cursor.execute(f'SELECT `user_id` FROM {table(group_id)}').fetchall()
def user_exists(self, user_id, group_id):
"""Проверяем, есть ли уже пользователь в базе"""
with self.connection:
result = self.cursor.execute(f'SELECT * FROM {table(group_id)} WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def stat_user(self, user_id, group_id):
"""Получение данных пользователя"""
with self.connection:
self.cursor.execute(f"SELECT * FROM {table(group_id)} WHERE `user_id` = ?", (user_id,))
data = self.cursor.fetchone()
return data[1:]
def del_user(self, group_id, user_id):
"""Удаление пользователя"""
with self.connection:
return self.cursor.execute(f'DELETE FROM {table(group_id)} WHERE `user_id` = ?', (user_id,))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()

View file

@ -3,9 +3,26 @@ services:
build: . build: .
image: ghcr.io/edeev/chatping_abobot:latest image: ghcr.io/edeev/chatping_abobot:latest
env_file: .env env_file: .env
environment:
DATABASE_URL: postgresql://abobot:abobot@db:5432/abobot
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: abobot
POSTGRES_PASSWORD: abobot
POSTGRES_DB: abobot
volumes: volumes:
- db:/app/db - pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U abobot"]
interval: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
volumes: volumes:
db: pgdata:

View file

4
pytest.ini Normal file
View file

@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = tests

View file

@ -1,3 +1,4 @@
-r requirements.txt -r requirements.txt
pytest==8.4.2 pytest==8.4.2
pytest-asyncio==1.4.0
ruff==0.14.0 ruff==0.14.0

View file

@ -1,4 +1,5 @@
aiogram==3.23.0 aiogram==3.23.0
asyncpg==0.31.0
pymorphy3==2.0.6 pymorphy3==2.0.6
pymorphy3-dicts-ru==2.4.417150.4580142 pymorphy3-dicts-ru==2.4.417150.4580142
pyenchant==3.2.2 pyenchant==3.2.2

119
scripts/migrate_sqlite.py Normal file
View file

@ -0,0 +1,119 @@
"""Перенос данных AboBot из четырёх баз SQLite в PostgreSQL.
python scripts/migrate_sqlite.py --sqlite-dir /path/to/db --dsn postgresql://…
Старые базы: users.db (users, groups — внутренние номера ↔ id Telegram), base.db (work, stat, edit),
groups.db (таблица на каждый чат со статистикой участников), month.db (месячная статистика).
Месячная статистика не переносится: в старой версии она ни разу не обнулялась и фактически
дублировала статистику за всё время; новая копится по месяцам с момента переноса.
"""
import argparse
import asyncio
import os
import sqlite3
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
import db # noqa: E402
FIELDS = db.STAT_FIELDS
async def migrate(sqlite_dir, dsn, force=False):
users_db = sqlite3.connect(os.path.join(sqlite_dir, "users.db"))
base_db = sqlite3.connect(os.path.join(sqlite_dir, "base.db"))
groups_db = sqlite3.connect(os.path.join(sqlite_dir, "groups.db"))
pool = await db.connect(dsn)
async with pool.acquire() as conn:
if await conn.fetchval("SELECT count(*) FROM chats"):
if not force:
raise SystemExit("В PostgreSQL уже есть данные — перенос остановлен (--force очистит таблицы)")
await conn.execute("TRUNCATE chats, users, members, chat_stats, member_stats, bot_status_history "
"RESTART IDENTITY CASCADE")
async with conn.transaction():
# пользователи и чаты с прежними внутренними номерами
users = {}
for short, tg in users_db.execute("SELECT id, user_id FROM users ORDER BY id"):
if tg not in users.values():
users[short] = tg
await conn.executemany("INSERT INTO users (id, short_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[(tg, short) for short, tg in users.items()])
states = dict(base_db.execute("SELECT group_id, state FROM work"))
chats = {}
for short, tg in users_db.execute('SELECT id, group_id FROM "groups" ORDER BY id'):
if tg not in chats.values():
chats[short] = tg
await conn.executemany(
"INSERT INTO chats (id, short_id, events_enabled) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[(tg, short, bool(states.get(short, True))) for short, tg in chats.items()])
for table in ("users", "chats"):
await conn.execute(f"SELECT setval(pg_get_serial_sequence('{table}', 'short_id'), "
f"GREATEST((SELECT max(short_id) FROM {table}), 1))")
# статистика чатов за всё время
chat_rows = []
for row in base_db.execute(f"SELECT group_id, {', '.join(FIELDS)} FROM stat"):
if row[0] in chats:
chat_rows.append((chats[row[0]], *[v or 0 for v in row[1:]]))
await conn.executemany(
f"INSERT INTO chat_stats (chat_id, period, {', '.join(FIELDS)}) "
f"VALUES ($1, 'all', {', '.join(f'${i}' for i in range(2, 9))}) ON CONFLICT DO NOTHING", chat_rows)
# участники и их статистика: таблица groups.db на каждый чат
custom = {r[0] for r in base_db.execute("SELECT user_id FROM edit")}
tables = [r[0] for r in groups_db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
members, stats, skipped = {}, {}, 0
for table in tables:
if not table.lstrip("-").isdigit() or int(table) not in chats:
continue
chat_id = chats[int(table)]
for row in groups_db.execute(f"SELECT user_id, first_name, {', '.join(FIELDS)} FROM [{table}]"):
# SQLite хранит тип по значению: имя «69» лежит числом
user_short, name = row[0], "" if row[1] is None else str(row[1])
if user_short not in users:
skipped += 1
continue
key = (chat_id, users[user_short])
members.setdefault(key, (name, user_short in custom))
old = stats.get(key, (0,) * len(FIELDS))
stats[key] = tuple(a + (b or 0) for a, b in zip(old, row[2:], strict=True))
await conn.executemany("INSERT INTO members (chat_id, user_id, name, custom_name) VALUES ($1, $2, $3, $4)",
[(c, u, n, cu) for (c, u), (n, cu) in members.items()])
await conn.executemany(
f"INSERT INTO member_stats (chat_id, user_id, period, {', '.join(FIELDS)}) "
f"VALUES ($1, $2, 'all', {', '.join(f'${i}' for i in range(3, 10))})",
[(c, u, *s) for (c, u), s in stats.items()])
report = {
"users": (len(users), await conn.fetchval("SELECT count(*) FROM users")),
"chats": (len(chats), await conn.fetchval("SELECT count(*) FROM chats")),
"members": (len(members), await conn.fetchval("SELECT count(*) FROM members")),
"chat messages": (sum(r[1] for r in chat_rows),
await conn.fetchval("SELECT coalesce(sum(mes), 0) FROM chat_stats WHERE period = 'all'")),
"member messages": (sum(s[0] for s in stats.values()),
await conn.fetchval("SELECT coalesce(sum(mes), 0) FROM member_stats WHERE period = 'all'")),
}
await db.close()
ok = all(a == b for a, b in report.values())
for key, (src, dst) in report.items():
print(f"{key:16} SQLite {src:>9} PostgreSQL {dst:>9} {'ok' if src == dst else 'MISMATCH'}")
print(f"участников без пользователя в users.db (пропущено): {skipped}")
return ok
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--sqlite-dir", required=True)
parser.add_argument("--dsn", default=os.getenv("DATABASE_URL"))
parser.add_argument("--force", action="store_true", help="очистить таблицы PostgreSQL перед переносом")
args = parser.parse_args()
sys.exit(0 if asyncio.run(migrate(args.sqlite_dir, args.dsn, args.force)) else 1)
if __name__ == "__main__":
main()

View file

@ -1,11 +1,42 @@
import os import os
import sys import sys
# бот открывает базы по путям ../db относительно папки code — запускаем как в проде, но во временной папке import asyncpg
import tempfile import pytest
ROOT = tempfile.mkdtemp()
os.makedirs(os.path.join(ROOT, "code"))
os.chdir(os.path.join(ROOT, "code"))
os.environ.setdefault("BOT_TOKEN", "123456:TEST") os.environ.setdefault("BOT_TOKEN", "123456:TEST")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
import db # noqa: E402
DSN = os.environ.get("TEST_DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/abobot_test")
@pytest.fixture
async def pool():
"""Чистая схема на каждый тест"""
conn = await asyncpg.connect(DSN)
await conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
await conn.close()
p = await db.connect(DSN)
yield p
await db.close()
class FakeBot:
"""Минимум Bot API для обработчиков: размер чата и права"""
def __init__(self, size=10, admins=()):
self.size, self.admins, self.id = size, set(admins), 999
async def get_chat_member_count(self, chat_id):
return self.size
async def get_chat_member(self, chat_id, user_id):
from types import SimpleNamespace
return SimpleNamespace(status="administrator" if user_id in self.admins else "member")
@pytest.fixture
def fake_bot():
return FakeBot

49
tests/test_db.py Normal file
View file

@ -0,0 +1,49 @@
from datetime import date
import db
async def test_counters_all_time_and_month(pool):
await db.count(-1, 10, [1, 3], "маша", "Чат", day=date(2026, 10, 6))
await db.count(-1, 10, [1], "маша", day=date(2026, 10, 7))
await db.count(-1, 10, [1], "маша", day=date(2026, 11, 1))
assert (await db.member_stats(-1, 10, "all"))[:3] == (3, 0, 1)
assert (await db.member_stats(-1, 10, "2026-10"))[0] == 2
assert (await db.member_stats(-1, 10, "2026-11"))[0] == 1 # месяцы хранятся, а не обнуляются
assert (await db.chat_stats(-1, "all"))[0] == 3
async def test_custom_name_survives_and_left_member(pool):
await db.count(-1, 10, [1], "мария")
await db.set_custom_name(-1, 10, "маша")
await db.count(-1, 10, [1], "мария")
assert await db.chat_names(-1) == {"маша": 10}
await db.member_left(-1, 10)
assert await db.chat_names(-1) == {}
assert (await db.member_stats(-1, 10, "all"))[0] == 2 # статистика ушедшего остаётся
async def test_chat_migration_keeps_stats(pool):
await db.count(-1, 10, [1], "маша")
await db.migrate_chat(-1, -1001)
assert (await db.member_stats(-1001, 10, "all"))[0] == 1
assert not await db.chat_exists(-1)
async def test_bot_status_history(pool):
assert await db.set_bot_status(-5, "member", {"can_send_messages": True}, "update", member_count=40)
assert not await db.set_bot_status(-5, "member", {"can_send_messages": True}, "check") # без изменений
assert await db.set_bot_status(-5, "administrator", {"can_delete_messages": True}, "update")
assert await db.set_bot_status(-5, "kicked", None, "check")
history = await db.pool.fetch("SELECT status, source FROM bot_status_history WHERE chat_id = -5 ORDER BY id")
assert [(r["status"], r["source"]) for r in history] == [
("member", "update"), ("administrator", "update"), ("kicked", "check")]
assert await db.chats_to_check() == [] # из исключённого чата не проверяем
assert await db.pool.fetchval("SELECT count(*) FROM active_chats") == 0
async def test_top(pool):
for uid, name, n in ((1, "а", 3), (2, "б", 5), (3, "в", 1)):
for _ in range(n):
await db.count(-1, uid, [1], name)
assert [(n, v) for _, n, v in await db.top(-1, "all", "mes", 2)] == [("б", 5), ("а", 3)]

69
tests/test_handlers.py Normal file
View file

@ -0,0 +1,69 @@
from datetime import date
import pytest
import db
import nlp
from handlers import common, mentions, stats
def test_chunks_respect_limit():
parts = [f'<a href="tg://user?id={i}">Участник{i}</a>' for i in range(500)]
messages = mentions.chunks(parts, " вас вызывает Маша", limit=4096)
assert len(messages) > 1 and all(len(m) <= 4096 for m in messages)
assert messages[-1].endswith(" вас вызывает Маша")
assert sum(m.count("tg://user") for m in messages) == 500
class Msg:
def __init__(self, chat_id, user_id):
from types import SimpleNamespace
self.chat = SimpleNamespace(id=chat_id)
self.from_user = SimpleNamespace(id=user_id)
async def test_mentions_and_big_chat_cooldown(pool, fake_bot):
await db.count(-1, 1, [1], "маша")
await db.count(-1, 2, [1], "петя")
small, big = fake_bot(size=10), fake_bot(size=500)
text = await mentions.mention_in_text(small, Msg(-1, 2), "позови Машу")
assert text == '<a href="tg://user?id=1">Маша</a>, тебя упомянули)'
assert await mentions.mention_in_text(small, Msg(-1, 1), "я Маша") is None # себя не зовём
mentions._last_mention.clear()
common._size_cache.clear() # размер чата кэшируется на 10 минут
assert await mentions.mention_names(big, Msg(-1, 2), ["маша"])
assert await mentions.mention_names(big, Msg(-1, 2), ["маша"]) is None # в большом чате — раз в минуту
common._size_cache.clear()
assert await mentions.mention_names(small, Msg(-1, 2), ["маша"]) # в маленьком — всегда
async def test_mentions_can_be_disabled(pool, fake_bot):
await db.count(-1, 1, [1], "маша")
await db.toggle_setting(-1, "mentions_enabled")
assert await mentions.mention_in_text(fake_bot(), Msg(-1, 2), "Маша") is None
def test_parse_period():
assert stats.parse_period("2026-09") == "2026-09"
assert stats.parse_period("2026-13") is None
assert stats.parse_period(None) == db.month_key(date.today())
@pytest.mark.parametrize("text,expected", [("ghbdtn vbh", True), ("hello world", False), ("http://x.ru", True),
("/help", False), ("привет", False), ("123 !!", False)])
def test_wrong_layout(text, expected):
assert nlp.wrong_layout(text) is expected
def test_nlp_helpers():
assert nlp.translator(["ghbdtn"]) == "привет"
assert nlp.revers("Привет, мир!", True) == "Тевирп, рим!"
assert nlp.lang_form(["кот"]) == "когот"
assert nlp.mention(5, "иван_<b>") == '<a href="tg://user?id=5">Иван_&lt;B&gt;</a>'
assert nlp.find_names("позови Машу и Петю, Машу!", {"маша", "петя"}) == ["маша", "петя"]
def test_bot_imports():
import bot # noqa: F401

56
tests/test_migration.py Normal file
View file

@ -0,0 +1,56 @@
import os
import sqlite3
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
import db # noqa: E402
import migrate_sqlite # noqa: E402
from conftest import DSN # noqa: E402
def make_old_dbs(path):
users = sqlite3.connect(path / "users.db")
users.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL);
CREATE TABLE "groups" (id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL);
INSERT INTO users (user_id) VALUES (111), (222);
INSERT INTO "groups" (group_id) VALUES (-100), (-200);
""")
users.commit()
base = sqlite3.connect(path / "base.db")
base.executescript("""
CREATE TABLE work (group_id INTEGER NOT NULL, state BOOLEAN NOT NULL DEFAULT (True));
CREATE TABLE stat (group_id INTEGER NOT NULL, mes INTEGER, rep INTEGER, com INTEGER, url INTEGER,
med INTEGER, sti INTEGER, voi INTEGER);
CREATE TABLE edit (user_id INTEGER NOT NULL);
INSERT INTO work VALUES (1, 1), (2, 0);
INSERT INTO stat VALUES (1, 50, 5, 1, 0, 2, 3, 4), (2, 7, 0, 0, 0, 0, 0, 0);
INSERT INTO edit VALUES (2);
""")
base.commit()
groups = sqlite3.connect(path / "groups.db")
groups.executescript("""
CREATE TABLE [1] (user_id INTEGER, first_name STRING, mes INTEGER, rep INTEGER, com INTEGER, url INTEGER,
med INTEGER, sti INTEGER, voi INTEGER);
CREATE TABLE [2] (user_id INTEGER, first_name STRING, mes INTEGER, rep INTEGER, com INTEGER, url INTEGER,
med INTEGER, sti INTEGER, voi INTEGER);
INSERT INTO [1] VALUES (1, 'маша', 30, 5, 1, 0, 2, 3, 4), (2, 'котик', 20, 0, 0, 0, 0, 0, 0);
INSERT INTO [2] VALUES (2, 'котик', 7, 0, 0, 0, 0, 0, 0), (9, 'призрак', 1, 0, 0, 0, 0, 0, 0);
""")
groups.commit()
async def test_migration(pool, tmp_path):
make_old_dbs(tmp_path)
await db.close()
assert await migrate_sqlite.migrate(str(tmp_path), DSN, force=True)
await db.connect(DSN)
assert await db.pool.fetchval("SELECT short_id FROM chats WHERE id = -200") == 2
assert await db.get_setting(-200, "events_enabled") is False
assert (await db.member_stats(-100, 111, "all"))[0] == 30
assert await db.chat_names(-100) == {"маша": 111, "котик": 222}
assert await db.pool.fetchval("SELECT custom_name FROM members WHERE chat_id = -100 AND user_id = 222")
# новый пользователь получает следующий внутренний номер
await db.ensure_user(333)
assert await db.pool.fetchval("SELECT short_id FROM users WHERE id = 333") == 3

View file

@ -1,65 +0,0 @@
import script
from init import du, dg
def test_md_and_plain():
assert script.md("ivan_petrov *x*") == "ivan\\_petrov \\*x\\*"
assert script.plain("[a]_b*") == "ab"
def test_join_names():
assert script.join_names(["а"]) == "а"
assert script.join_names(["а", "б"]) == "а и б"
assert script.join_names(["а", "б", "в"]) == "а, б и в"
def test_translator_fixes_wrong_layout():
assert script.translator(["ghbdtn", "vbh"]) == "привет мир"
def test_revers_and_lang_form():
assert script.revers("Привет, мир!", True) == "Тевирп, рим!"
assert script.lang_form(["кот"]) == "когот"
def test_stats_counters_and_mentions():
chat, author, other, third = -100500, 1, 2, 3
script.upd_stat(author, chat, 1, "Автор")
script.upd_stat(other, chat, 1, "Иван_")
script.upd_stat(other, chat, 1, "Иван_")
script.upd_stat(third, chat, 2, "Пётр")
group_id = du.get_group_id(chat)
ivan = du.get_user_id(other)
assert dg.stat_user(ivan, group_id)[0] == 2 # два сообщения — счётчик атомарный
# упоминание: спецсимволы Markdown из имени внутри ссылки убираются, автор себя не упоминает
text = script.notice(["иван_", "автор"], False, group_id, author)
assert text == "[Иван](tg://user?id=2), тебя упомянули)"
assert script.notice(["автор"], False, group_id, author).startswith("[Автор]")
every = script.notice(du.get_user_id(author), True, group_id, author)
assert "[Иван](tg://user?id=2) и [Пётр](tg://user?id=3) вас вызывает Автор" == every
def test_bot_imports():
import bot # noqa: F401 — обработчики и роутер собираются без ошибок
def test_month_reset():
from datetime import date
import bot
from init import db, dm
chat, user = -200300, 10
script.upd_stat(user, chat, 1, "Маша")
group_id = du.get_group_id(chat)
member = du.get_user_id(user)
assert bot.reset_month_if_needed(date(2026, 10, 6)) is False # первый запуск — только запомнить
assert dm.stat_user(member, group_id)[0] == 1
assert bot.reset_month_if_needed(date(2026, 10, 20)) is False # тот же месяц
assert bot.reset_month_if_needed(date(2026, 11, 1)) is True
assert dm.stat_user(member, group_id)[0] == 0 and db.month_stat_group(group_id)[0] == 0
assert dg.stat_user(member, group_id)[0] == 1 # статистика за всё время не трогается