mirror of
https://github.com/EDeev/chatping_abobot.git
synced 2026-10-07 20:49:45 +03:00
Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 737b3b57af | |||
| af372fbdf0 | |||
| a2a0adcf8e |
37 changed files with 1741 additions and 1282 deletions
|
|
@ -1,2 +1,6 @@
|
|||
# Токен бота от @BotFather
|
||||
BOT_TOKEN=123456:your-token
|
||||
# База PostgreSQL (в compose.yaml задаётся автоматически)
|
||||
DATABASE_URL=postgresql://abobot:abobot@localhost:5432/abobot
|
||||
# Технический чат для сообщений об ошибках (необязательно)
|
||||
DEBUG_CHAT_ID=
|
||||
|
|
|
|||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -8,12 +8,26 @@ on:
|
|||
jobs:
|
||||
check:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
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: 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
|
||||
|
|
|
|||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
|
|
@ -19,17 +19,11 @@ jobs:
|
|||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: dcr.deev.su
|
||||
username: ${{ secrets.ZOT_USERNAME }}
|
||||
password: ${{ secrets.ZOT_PASSWORD }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/edeev/chatping_abobot
|
||||
dcr.deev.su/edeev/chatping_abobot
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,4 +2,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
db/*.db
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
|
||||
COPY code/ code/
|
||||
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
|
||||
VOLUME ["/app/db"]
|
||||
|
||||
# пути к базам и картинкам в коде — относительно папки code/ (../db, ../data)
|
||||
# пути к картинкам в коде — относительно папки code/ (../data)
|
||||
WORKDIR /app/code
|
||||
CMD ["python", "bot.py"]
|
||||
|
|
|
|||
78
README.en.md
78
README.en.md
|
|
@ -18,16 +18,22 @@ The bot speaks Russian.
|
|||
**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)
|
||||
|
||||
**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
|
||||
|
||||
- **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
|
||||
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,
|
||||
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
|
||||
with a dictionary (pyenchant) and left alone.
|
||||
- **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;
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
```bash
|
||||
git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot
|
||||
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`.
|
||||
The SQLite databases are created on first start.
|
||||
Prebuilt image: `docker pull ghcr.io/edeev/chatping_abobot` or `docker pull git.deev.su/edeev/chatping_abobot`.
|
||||
Tables are created on first start (`code/schema.sql`).
|
||||
|
||||
Without Docker you need Python 3.12, the enchant system library and an English dictionary
|
||||
(`apt install libenchant-2-2 hunspell-en-us`). Then: `pip install -r requirements.txt` and
|
||||
`cd code && BOT_TOKEN=… python bot.py`.
|
||||
Without Docker you need:
|
||||
- Python 3.10+;
|
||||
- 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
|
||||
|
||||
```
|
||||
code/bot.py entry point and monthly statistics reset
|
||||
code/handlers.py message, command, voice and event handlers
|
||||
code/script.py statistics, name lookup, keyboard layout, text reversal
|
||||
code/sql.py four SQLite databases: groups, users, all-time and monthly statistics
|
||||
data/ event pictures and the greeting
|
||||
code/bot.py entry point, error reports to a tech chat, checking the bot's status in groups
|
||||
code/handlers/ routers: help, settings, stats, mentions, voice, events, chat (bot in groups, service messages)
|
||||
code/db.py PostgreSQL queries
|
||||
code/schema.sql schema: chats, users, members, chat_stats and member_stats by period, bot_status_history
|
||||
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
|
||||
built. Counters are incremented in a single query, so simultaneous messages are not lost. Speech recognition
|
||||
and synthesis (Google Web Speech and gTTS) run in a separate thread, so the bot never freezes.
|
||||
All-time and current-month counters grow in a single query (`INSERT … ON CONFLICT DO UPDATE`), so
|
||||
simultaneous messages are not lost. Database calls are asynchronous and speech recognition and synthesis
|
||||
(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
|
||||
|
||||
|
|
@ -78,14 +103,17 @@ pip install -r requirements-dev.txt
|
|||
ruff check --select E9,F,B code tests && pytest
|
||||
```
|
||||
|
||||
What the tests cover:
|
||||
- statistics counters;
|
||||
- mentions and `/all`;
|
||||
- the monthly reset;
|
||||
- keyboard layout, reversal and escaping;
|
||||
- loading of the bot's handlers.
|
||||
The tests need PostgreSQL (`TEST_DATABASE_URL`). What they cover:
|
||||
- counters by period;
|
||||
- mentions and big-chat limits;
|
||||
- splitting `/all`;
|
||||
- the bot's status in groups and its history;
|
||||
- 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
|
||||
|
||||
|
|
|
|||
82
README.md
82
README.md
|
|
@ -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)
|
||||
|
||||
**Стек:** Python 3.12 · aiogram 3 · pymorphy3 · pyenchant · SpeechRecognition · gTTS · SQLite · Docker
|
||||
**Стек:** Python 3.10+ · aiogram 3 · PostgreSQL (asyncpg) · pymorphy3 · pyenchant · SpeechRecognition · gTTS · Docker
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Упоминания по имени.** Слова сообщения приводятся к начальной форме (pymorphy3) и сравниваются с
|
||||
именами участников, поэтому участницу Машу позовут и «Маша», и «позови Машу», и «с Машей». Своё имя
|
||||
для упоминаний можно задать командой `/edit`. Имена ищутся и в голосовых до минуты.
|
||||
- **`/all`** — упомянуть всех участников.
|
||||
- **Статистика за всё время и за месяц** (`/stat_group`, `/stat_user`): сообщения, ответы, команды,
|
||||
ссылки, медиа, стикеры, голосовые и кружочки. Месячная обнуляется в начале месяца.
|
||||
- **`/all`** — упомянуть всех участников. Длинный список делится на несколько сообщений.
|
||||
- **Большие чаты (больше 100 участников).**
|
||||
- `/all` — только для администраторов, не чаще раза в 5 минут и только для писавших за 30 дней.
|
||||
- Одного человека по имени бот зовёт не чаще раза в минуту.
|
||||
- **Статистика за всё время и по месяцам** (`/stat_group`, `/stat_user`): сообщения, ответы, команды,
|
||||
ссылки, медиа, стикеры, голосовые и кружочки. Месяцы хранятся, поэтому `/month 2026-09` покажет итоги
|
||||
любого месяца, а `/top` — самых активных за месяц или за всё время (`/top all`).
|
||||
- **`/settings`** — переключатели для чата: упоминания по имени, поиск имён в голосовых (голос уходит в
|
||||
Google), текстовые ивенты, удаление служебных сообщений. Менять могут администраторы.
|
||||
- **Неправильная раскладка.** Сообщение вида `ghbdtn` бот повторит как «привет». Английские слова он
|
||||
отличает по словарю (pyenchant) и не трогает.
|
||||
- **Голос.** `/recognize` ответом на голосовое — расшифровка; «Озвучь - текст» — голосовое из текста.
|
||||
|
|
@ -36,37 +42,56 @@ Telegram-бот для групповых чатов. Он:
|
|||
- «Подраться с …»;
|
||||
- «Чмокнуть» и другие действия с картинками.
|
||||
|
||||
Отключаются командой `/stop_bot`.
|
||||
Отключаются командой `/stop_bot` или в `/settings`.
|
||||
- Удаление служебных сообщений (вход, выход, смена названия и фото) — если у бота права администратора.
|
||||
- **Учёт бота в группах.** Бот записывает, в каких группах состоит и с какими правами: события Telegram
|
||||
плюс сверка при запуске и раз в 6 часов. История изменений хранится. Если бота исключили, статистика
|
||||
группы не удаляется — меняется только отметка, что бота там больше нет.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot
|
||||
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`.
|
||||
Базы SQLite создаются при первом запуске.
|
||||
Готовый образ: `docker pull ghcr.io/edeev/chatping_abobot` или `docker pull git.deev.su/edeev/chatping_abobot`.
|
||||
Таблицы создаются при первом запуске (`code/schema.sql`).
|
||||
|
||||
Без Docker нужны Python 3.12, системная библиотека enchant и английский словарь
|
||||
(`apt install libenchant-2-2 hunspell-en-us`). Затем: `pip install -r requirements.txt` и
|
||||
`cd code && BOT_TOKEN=… python bot.py`.
|
||||
Без Docker нужны:
|
||||
- Python 3.10+;
|
||||
- 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/handlers.py обработчики сообщений, команд, голосовых и ивентов
|
||||
code/script.py учёт статистики, поиск имён, раскладка, переворот текста
|
||||
code/sql.py четыре базы SQLite: группы, пользователи, статистика за всё время и за месяц
|
||||
data/ картинки ивентов и приветствие
|
||||
code/bot.py запуск, отправка ошибок в технический чат, сверка статуса бота в группах
|
||||
code/handlers/ роутеры: help, settings, stats, mentions, voice, events, chat (бот в группах, служебные сообщения)
|
||||
code/db.py запросы к PostgreSQL
|
||||
code/schema.sql схема: chats, users, members, chat_stats и member_stats по периодам, bot_status_history
|
||||
code/nlp.py имена в начальной форме, раскладка, переворот, экранирование
|
||||
scripts/ перенос данных из SQLite
|
||||
data/ картинки ивентов и приветствие
|
||||
```
|
||||
|
||||
Статистика участников хранится в отдельной таблице на каждый чат — так устроены рабочие базы бота.
|
||||
Счётчики увеличиваются одним запросом, поэтому одновременные сообщения не теряются. Распознавание и синтез
|
||||
речи (Google Web Speech и gTTS) идут в отдельном потоке, чтобы бот не замирал.
|
||||
Счётчики за всё время и за текущий месяц растут одним запросом (`INSERT … ON CONFLICT DO UPDATE`),
|
||||
поэтому одновременные сообщения не теряются. Запросы к базе асинхронные, а распознавание и синтез речи
|
||||
(Google Web Speech и gTTS) идут в отдельном потоке — бот не замирает. Разметка сообщений — HTML,
|
||||
пользовательский текст экранируется.
|
||||
|
||||
## Разработка
|
||||
|
||||
|
|
@ -75,14 +100,17 @@ pip install -r requirements-dev.txt
|
|||
ruff check --select E9,F,B code tests && pytest
|
||||
```
|
||||
|
||||
Что проверяют тесты:
|
||||
- счётчики статистики;
|
||||
- упоминания и `/all`;
|
||||
- обнуление месячной статистики;
|
||||
- раскладку, переворот и экранирование;
|
||||
- что обработчики бота загружаются.
|
||||
Тестам нужен PostgreSQL (`TEST_DATABASE_URL`). Что они проверяют:
|
||||
- счётчики по периодам;
|
||||
- упоминания и ограничения больших чатов;
|
||||
- деление `/all` на сообщения;
|
||||
- статус бота в группах и историю;
|
||||
- перенос из SQLite;
|
||||
- раскладку и экранирование.
|
||||
|
||||
Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `dcr.deev.su`.
|
||||
CI прогоняет их на Python 3.10 и 3.12.
|
||||
|
||||
Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `git.deev.su`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import os
|
||||
|
||||
# ДАННЫЕ БОТА (из переменных окружения)
|
||||
TOKEN = os.getenv("BOT_TOKEN", "XXXXXXXXXX") # @chat_abobot
|
||||
DEBAG = os.getenv("DEBUG_CHAT_ID", "") # Технический чат
|
||||
|
||||
TEX_GROUP = os.getenv("TECH_GROUP_ID", "")
|
||||
# Списки для ивентов и исправления раскладки (токены и id чатов — в config.py)
|
||||
|
||||
# ОБРАЩЕНИЯ К ПОЛЬЗОВАТЕЛЯМ
|
||||
KILL_LIST = ["побить", "отмудохать", "избить", "уебать", "отметелить"]
|
||||
|
|
|
|||
73
code/bot.py
73
code/bot.py
|
|
@ -1,49 +1,66 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from datetime import date
|
||||
import traceback
|
||||
|
||||
from init import bot, dp, db, dm
|
||||
from handlers import router
|
||||
from aiogram import Bot, Dispatcher
|
||||
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):
|
||||
"""Статистика «за месяц» раньше нигде не обнулялась и копилась с момента создания таблиц.
|
||||
При первом запуске месяц только запоминается — обнуление со следующей смены месяца"""
|
||||
key = (today or date.today()).strftime("%Y-%m")
|
||||
saved = db.get_meta("month")
|
||||
if saved == key:
|
||||
return False
|
||||
if saved is not None:
|
||||
db.reset_month()
|
||||
dm.reset_all()
|
||||
logging.info("Месячная статистика обнулена: %s → %s", saved, key)
|
||||
db.set_meta("month", key)
|
||||
return saved is not None
|
||||
def create_dispatcher():
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
for router in routers:
|
||||
dp.include_router(router)
|
||||
|
||||
@dp.errors()
|
||||
async def on_error(event: ErrorEvent, bot: Bot):
|
||||
"""Ошибка обработчика — в лог и в технический чат"""
|
||||
logging.exception("Ошибка обработки", exc_info=event.exception)
|
||||
if config.DEBUG_CHAT_ID:
|
||||
tb = "".join(traceback.format_exception(event.exception))[-3500:]
|
||||
try:
|
||||
await bot.send_message(config.DEBUG_CHAT_ID, f"<b>AboBot: ошибка</b>\n<pre>{tb.replace('<', '<')}</pre>")
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
return dp
|
||||
|
||||
|
||||
async def month_watcher():
|
||||
async def periodic_check(bot: Bot):
|
||||
while True:
|
||||
try:
|
||||
reset_month_if_needed()
|
||||
await check_all(bot)
|
||||
except Exception:
|
||||
logging.exception("Не удалось обнулить месячную статистику")
|
||||
await asyncio.sleep(600)
|
||||
logging.exception("Сверка статуса бота в чатах")
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
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)
|
||||
watcher = asyncio.create_task(month_watcher())
|
||||
checker = asyncio.create_task(periodic_check(bot))
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||
finally:
|
||||
watcher.cancel()
|
||||
checker.cancel()
|
||||
await db.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try: asyncio.run(main())
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("Бот остановлен")
|
||||
pass
|
||||
pass
|
||||
|
|
|
|||
10
code/config.py
Normal file
10
code/config.py
Normal 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
199
code/db.py
Normal 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]
|
||||
534
code/handlers.py
534
code/handlers.py
|
|
@ -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
|
||||
4
code/handlers/__init__.py
Normal file
4
code/handlers/__init__.py
Normal 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
126
code/handlers/chat.py
Normal 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
44
code/handlers/common.py
Normal 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
140
code/handlers/events.py
Normal 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
73
code/handlers/help.py
Normal 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
123
code/handlers/mentions.py
Normal 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
70
code/handlers/settings.py
Normal 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
117
code/handlers/stats.py
Normal 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
86
code/handlers/voice.py
Normal 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)
|
||||
26
code/init.py
26
code/init.py
|
|
@ -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
146
code/nlp.py
Normal 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
73
code/schema.sql
Normal 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');
|
||||
189
code/script.py
189
code/script.py
|
|
@ -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))])
|
||||
362
code/sql.py
362
code/sql.py
|
|
@ -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()
|
||||
21
compose.yaml
21
compose.yaml
|
|
@ -3,9 +3,26 @@ services:
|
|||
build: .
|
||||
image: ghcr.io/edeev/chatping_abobot:latest
|
||||
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:
|
||||
- db:/app/db
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U abobot"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db:
|
||||
pgdata:
|
||||
|
|
|
|||
4
pytest.ini
Normal file
4
pytest.ini
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
testpaths = tests
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
-r requirements.txt
|
||||
pytest==8.4.2
|
||||
pytest-asyncio==1.4.0
|
||||
ruff==0.14.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
aiogram==3.23.0
|
||||
asyncpg==0.31.0
|
||||
pymorphy3==2.0.6
|
||||
pymorphy3-dicts-ru==2.4.417150.4580142
|
||||
pyenchant==3.2.2
|
||||
|
|
|
|||
119
scripts/migrate_sqlite.py
Normal file
119
scripts/migrate_sqlite.py
Normal 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()
|
||||
|
|
@ -1,11 +1,42 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
# бот открывает базы по путям ../db относительно папки code — запускаем как в проде, но во временной папке
|
||||
import tempfile
|
||||
import asyncpg
|
||||
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")
|
||||
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
49
tests/test_db.py
Normal 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
69
tests/test_handlers.py
Normal 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">Иван_<B></a>'
|
||||
assert nlp.find_names("позови Машу и Петю, Машу!", {"маша", "петя"}) == ["маша", "петя"]
|
||||
|
||||
|
||||
def test_bot_imports():
|
||||
import bot # noqa: F401
|
||||
56
tests/test_migration.py
Normal file
56
tests/test_migration.py
Normal 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
|
||||
|
|
@ -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 # статистика за всё время не трогается
|
||||
Loading…
Add table
Reference in a new issue