1
0
Fork 0
mirror of https://github.com/EDeev/chatping_abobot.git synced 2026-10-08 04:59:42 +03:00

Compare commits

...

5 commits

Author SHA1 Message Date
9123f57968 Зависимости, тесты, CI, Docker, лицензия MIT и README на русском и английском
requirements.txt раньше не устанавливался (sqlite3, несуществующий enchant==1.6.6) и тянул
неиспользуемые asyncio-mqtt, pyaudio, pydub. README: распознавание — SpeechRecognition,
а не gTTS; число пользователей — по базе бота.
2026-10-06 01:10:52 +00:00
f5441bdde6 Исправлены упоминания, экранирование и блокировка бота
- ответ на текст в неправильной раскладке упоминал автора ссылкой tg://user_id?id= —
  такой нет, упоминание не работало; теперь tg://user?id=;
- имена и текст пользователей подставлялись в Markdown как есть: «_» или «*» в имени
  ломали отправку статистики, ивентов и упоминаний;
- каждое голосовое в группе распознавалось через Google прямо в обработчике, и бот на это
  время не отвечал никому; распознавание, озвучка и запрос совета — в отдельном потоке,
  у запроса совета есть таймаут;
- временные файлы голосовых и озвучки — во временной папке на запрос (раньше общий
  voice.ogg и файлы со случайным номером, остававшиеся при ошибке);
- падения: упоминание двух имён, одно из которых — автор; сообщение из одних знаков
  препинания; глагол, который pymorphy3 не может поставить в прошедшее время;
- script.py берёт соединения с базами из init.py, а не открывает вторые;
- токен — из BOT_TOKEN.
2026-10-06 01:10:52 +00:00
e4786d8b68 Статистика «за месяц» обнуляется в начале месяца
Раньше месячные счётчики нигде не сбрасывались — ни в коде, ни на сервере — и копились с
момента создания таблиц. При первом запуске месяц только запоминается (в таблице meta),
обнуление — со следующей смены месяца.
2026-10-06 01:10:52 +00:00
e00b035265 Статистика: атомарные счётчики, схема баз создаётся при первом запуске
Счётчики увеличивались через SELECT и UPDATE с ручным +1 — одновременные сообщения
терялись. Теперь UPDATE … SET mes = mes + 1. Имя таблицы чата приводится к int перед
подстановкой в SQL. Таблицы work/stat/month/edit/users/groups создаются, если их нет, —
пустые базы убраны из репозитория (схема та же; проверено на копии рабочих баз).
2026-10-06 01:10:51 +00:00
4cd8470087 Переводы строк приведены к LF, удалено чужое голосовое из data/voices
1_7047.oga — голосовое сообщение участника чата, оставшееся от отладки распознавания.
2026-10-06 00:51:37 +00:00
26 changed files with 868 additions and 610 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
.env
db
tests
__pycache__

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# Токен бота от @BotFather
BOT_TOKEN=123456:your-token

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
* text=auto eol=lf
*.jpg binary
*.ogg binary
*.oga binary
*.db binary

19
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- 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: pytest -q

42
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Docker
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
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}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
.pytest_cache/
db/*.db

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# словарь английского для распознавания текста в неправильной раскладке
RUN apt-get update \
&& apt-get install -y --no-install-recommends libenchant-2-2 hunspell-en-us \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
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
USER app
VOLUME ["/app/db"]
# пути к базам и картинкам в коде — относительно папки code/ (../db, ../data)
WORKDIR /app/code
CMD ["python", "bot.py"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Egor Deev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

103
README.en.md Normal file
View file

@ -0,0 +1,103 @@
# AboBot
[Русский](README.md) · **English**
[![CI](https://github.com/EDeev/chatping_abobot/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/chatping_abobot/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/chatping_abobot/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/chatping_abobot/actions/workflows/docker.yml)
[![License](https://img.shields.io/github/license/EDeev/chatping_abobot)](LICENSE)
A Telegram bot for group chats. It:
- pings a member when their name comes up in a message or a voice note;
- keeps statistics for the chat and every member;
- fixes text typed in the wrong keyboard layout;
- transcribes and voices messages;
- runs playful events.
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
## 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-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.
- **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
voice note.
- **Events:**
- random numbers;
- reversing text;
- a language game;
- mock fights;
- picture reactions.
Turned off with `/stop_bot`.
- Deleting service messages (joins, leaves, title and photo changes) when the bot is an admin.
## 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
```
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.
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`.
## 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
```
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.
## Development
```bash
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 Docker image is built on `v*` tags and published to GitHub Packages and `dcr.deev.su`.
## License
MIT — see [LICENSE](LICENSE).
## Author
**Egor Deev** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ If you find this project useful, give it a star on GitHub!</sub>
<p><sub>Made with ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

199
README.md
View file

@ -1,153 +1,100 @@
# 🤖 ABOBOT - Telegram Group Management Bot
# AboBot
![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)
![Aiogram](https://img.shields.io/badge/Aiogram-3.x-green.svg)
![SQLite](https://img.shields.io/badge/SQLite-3-orange.svg)
![License](https://img.shields.io/badge/License-MIT-yellow.svg)
**Русский** · [English](README.en.md)
Многофункциональный Telegram-бот для управления групповыми чатами с расширенной аналитикой и интерактивными возможностями.
[![CI](https://github.com/EDeev/chatping_abobot/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/chatping_abobot/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/chatping_abobot/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/chatping_abobot/actions/workflows/docker.yml)
[![License](https://img.shields.io/github/license/EDeev/chatping_abobot)](LICENSE)
## 🎯 Основные возможности
Telegram-бот для групповых чатов. Он:
- зовёт участника, когда его имя звучит в сообщении или в голосовом;
- ведёт статистику чата и каждого участника;
- исправляет текст, набранный в неправильной раскладке;
- распознаёт и озвучивает голосовые;
- устраивает шуточные ивенты.
### 📊 Система аналитики
- **Комплексная статистика** - детальный учёт активности участников
- **Временная сегментация** - анализ данных за месяц и весь период
- **Многомерные метрики** - сообщения, ответы, команды, медиафайлы, голосовые
**Статус:** личный проект, работает с 2021 года · бот [@chat_abobot](https://t.me/chat_abobot) · в базе
бота больше 20 000 пользователей из 45 чатов (октябрь 2026)
### 👥 Управление участниками
- **Интеллектуальные упоминания** - автоматическое обнаружение имён в сообщениях
- **Динамическое именование** - морфологическая обработка и нормализация
- **Групповые вызовы** - команда `/all` для оповещения всех участников
**Стек:** Python 3.12 · aiogram 3 · pymorphy3 · pyenchant · SpeechRecognition · gTTS · SQLite · Docker
### 🔧 Утилиты и инструменты
- **Транслитерация** - автоматический перевод с латиницы на кириллицу
- **Транскрипция** - конвертация голосовых сообщений в текст (gTTS)
- **Текстовые трансформации** - реверс слов и предложений
- **Генерация голоса** - озвучивание текстовых сообщений
## Возможности
### 🎮 Интерактивные функции
- **Игровые механики** - симуляция драк и взаимодействий
- **Рандомизация** - генерация случайных чисел в диапазоне
- **Советник** - интеграция с API для получения рандомных советов
- **Упоминания по имени.** Слова сообщения приводятся к начальной форме (pymorphy3) и сравниваются с
именами участников, поэтому участницу Машу позовут и «Маша», и «позови Машу», и «с Машей». Своё имя
для упоминаний можно задать командой `/edit`. Имена ищутся и в голосовых до минуты.
- **`/all`** — упомянуть всех участников.
- **Статистика за всё время и за месяц** (`/stat_group`, `/stat_user`): сообщения, ответы, команды,
ссылки, медиа, стикеры, голосовые и кружочки. Месячная обнуляется в начале месяца.
- **Неправильная раскладка.** Сообщение вида `ghbdtn` бот повторит как «привет». Английские слова он
отличает по словарю (pyenchant) и не трогает.
- **Голос.** `/recognize` ответом на голосовое — расшифровка; «Озвучь - текст» — голосовое из текста.
- **Ивенты:**
- «Число от 1 до 100»;
- «Переверни - …»;
- «Переведи - …» — на кирпичный язык;
- «Подраться с …»;
- «Чмокнуть» и другие действия с картинками.
## 🏗️ Архитектура системы
Отключаются командой `/stop_bot`.
- Удаление служебных сообщений (вход, выход, смена названия и фото) — если у бота права администратора.
### Технологический стек
```
Backend Framework: aiogram (Async Telegram Bot API)
Database Engine: SQLite (многотабличная структура)
NLP Processing: pymorphy3 (морфологический анализ)
Speech Recognition: speech_recognition + gTTS
Async Framework: asyncio (асинхронная обработка)
```
## Запуск
### Структура базы данных
```
📁 Databases
├── base.db # Основная статистика групп
├── users.db # Маппинг пользователей
├── groups.db # Индивидуальная статистика участников
└── month.db # Временная сегментация данных
```
### Модульная организация
```
📁 Project Structure
├── bot.py # Точка входа и инициализация
├── handlers.py # Обработчики событий и команд
├── script.py # Бизнес-логика и алгоритмы
├── sql.py # Абстракция базы данных
├── init.py # Конфигурация и зависимости
└── base.py # Константы и настройки
```
## 🚀 Быстрый старт
### Установка зависимостей
```bash
pip install -r requirements.txt
git clone https://github.com/EDeev/chatping_abobot.git && cd chatping_abobot
cp .env.example .env # BOT_TOKEN от @BotFather
docker compose up -d
```
### Конфигурация
1. Получите токен бота у [@BotFather](https://t.me/BotFather)
2. Обновите `TOKEN` в `code/base.py`
3. Настройте права администратора для автоудаления системных сообщений
Готовый образ: `docker pull ghcr.io/edeev/chatping_abobot` или `docker pull dcr.deev.su/edeev/chatping_abobot`.
Базы SQLite создаются при первом запуске.
Без 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`.
## Как устроено
```
code/bot.py запуск и ежемесячное обнуление статистики
code/handlers.py обработчики сообщений, команд, голосовых и ивентов
code/script.py учёт статистики, поиск имён, раскладка, переворот текста
code/sql.py четыре базы SQLite: группы, пользователи, статистика за всё время и за месяц
data/ картинки ивентов и приветствие
```
Статистика участников хранится в отдельной таблице на каждый чат — так устроены рабочие базы бота.
Счётчики увеличиваются одним запросом, поэтому одновременные сообщения не теряются. Распознавание и синтез
речи (Google Web Speech и gTTS) идут в отдельном потоке, чтобы бот не замирал.
## Разработка
### Запуск
```bash
cd code
python bot.py
pip install -r requirements-dev.txt
ruff check --select E9,F,B code tests && pytest
```
## 📋 Команды
Что проверяют тесты:
- счётчики статистики;
- упоминания и `/all`;
- обнуление месячной статистики;
- раскладку, переворот и экранирование;
- что обработчики бота загружаются.
| Команда | Функционал |
|---------|------------|
| `/start`, `/help` | Интерактивное меню с описанием возможностей |
| `/all` | Упоминание всех участников группы |
| `/stat_group` | Детальная аналитика группы |
| `/stat_user` | Персональная статистика пользователя |
| `/recognize` | Транскрипция голосового сообщения |
| `/edit <имя>` | Установка кастомного имени для упоминаний |
| `/back_edit` | Возврат к динамическому именованию |
| `/start_bot` | Активация текстовых событий |
| `/stop_bot` | Деактивация текстовых событий |
Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `dcr.deev.su`.
## 🎲 Текстовые события
## Лицензия
### Интерактивные команды
- **"Чмокнуть [имя]"** - позитивное взаимодействие
- **"Отмудохать [имя]"** - игровая агрессия
- **"Подраться с [имя]"** - симуляция боя с рандомным исходом
- **"Число от X до Y"** - генерация случайного числа
- **"Переведи (символ) - текст"** - трансформация в "кирпичный" язык
- **"Переверни - текст"** - реверс слов с сохранением пунктуации
- **"Озвучь - текст"** - генерация голосового сообщения
MIT — см. [LICENSE](LICENSE).
## 🔍 Технические особенности
## Автор
### Алгоритмы обработки
- **Морфологический анализ** - приведение имён к начальной форме
- **Детекция транслита** - эвристический алгоритм определения раскладки
- **Парсинг голоса** - интеграция Google Speech Recognition
- **Обработка пунктуации** - сохранение форматирования при трансформациях
### Оптимизации производительности
- **Асинхронная архитектура** - неблокирующая обработка запросов
- **Кэширование соединений** - переиспользование подключений к БД
- **Ленивая загрузка** - подгрузка данных по требованию
## 📈 Метрики и аналитика
Система ведёт учёт следующих параметров:
- Общее количество сообщений
- Количество ответов на сообщения
- Использование команд бота
- Отправка медиафайлов и стикеров
- Голосовые сообщения и видеокружки
- Размещение ссылок
## 🛠️ Требования к системе
- Python 3.8+
- Операционная система: Linux/Windows/macOS
- RAM: минимум 512MB
- Свободное место: 100MB для логов и медиафайлов
## 📄 Лицензия
Проект распространяется под лицензией MIT.
## 👨‍💻 Автор
**Деев Егор Викторович** - Backend Developer
- GitHub: [@EDeev](https://github.com/EDeev)
- Email: egor@deev.space
- Telegram: [@Egor_Deev](https://t.me/Egor_Deev)
**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ Если проект оказался полезным, поставьте звезду на GitHub!</sub>
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
<sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
<p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

View file

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

View file

@ -1,14 +1,43 @@
import asyncio
import logging
from datetime import date
from init import bot, dp
from init import bot, dp, db, dm
from handlers import router
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
async def month_watcher():
while True:
try:
reset_month_if_needed()
except Exception:
logging.exception("Не удалось обнулить месячную статистику")
await asyncio.sleep(600)
async def main() -> None:
dp.include_router(router)
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types(), skip_updates=True)
watcher = asyncio.create_task(month_watcher())
try:
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
finally:
watcher.cancel()
if __name__ == '__main__':

View file

@ -3,17 +3,60 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
from aiogram.filters import Command, CommandStart
from aiogram.enums import ContentType
import logging, random, requests, os, re
import speech_recognition as sr
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
from init import *
from script import *
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):
@ -26,7 +69,7 @@ async def notification(message: Message):
await bot.send_message(
message.chat.id,
f'*Привет группа {message.chat.title}!*\n\n'
f'*Привет группа {plain(message.chat.title)}!*\n\n'
f'Все функции вы можете узнать по команде /help! Для того, чтобы '
f'уведомления по имени и команда /all нормально функционировали, '
f'необходимо чтобы каждый участник группы написал хотя бы одно '
@ -147,47 +190,20 @@ async def every(message: Message):
# КОМАНДА RECOGNIZE
@router.message(Command("recognize"))
async def recognise(message: Message):
user_id = du.get_user_id(message.from_user.id)
if not (message.reply_to_message and message.reply_to_message.voice):
await message.reply("Ответьте этой командой на голосовое сообщение!")
return
if message.reply_to_message and message.reply_to_message.voice:
num = random.randint(1000, 9999)
audio = f"../data/voices/{user_id}_{num}.oga"
file_id = message.reply_to_message.voice.file_id
file = await bot.get_file(file_id)
file_path = file.file_path
await bot.download_file(file_path, audio)
data, samplerate = sf.read(audio)
os.remove(audio)
audio = f"../data/voices/{user_id}_{num}.wav"
sf.write(audio, data, samplerate)
af = sr.AudioFile(audio)
r = sr.Recognizer()
with af as source:
r.pause_threshold = 100
source = r.listen(source)
try:
mes = await bot.send_message(
chat_id=message.chat.id,
text="Распознавание.....",
reply_to_message_id=message.reply_to_message.message_id
)
try:
query = r.recognize_google(source, language='ru-RU')
os.remove(audio)
await mes.edit_text(f'*{message.reply_to_message.from_user.first_name} сказал(a)* "{query}"')
except Exception:
try:
os.remove(audio)
except Exception:
pass
await mes.edit_text("Распознать сообщение не удалось!")
except Exception as e:
print(repr(e))
pass
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'))
@ -231,7 +247,7 @@ async def stat_user(message: Message):
name = name.word
await message.answer(
text=f'*| СТАТИСТИКА {name.upper()} |*\n\n'
text=f'*| СТАТИСТИКА {plain(name.upper())} |*\n\n'
f'*- За всё время* / *За месяц -*\n'
f'*>>* Сообщений в базе *[ {group[0]} / {month[0]} ]*\n\n'
f'*>* Ответов *- [ {group[1]} / {month[1]} ]*\n'
@ -263,9 +279,9 @@ async def update(message: Message):
db.add_edit_user(user_id)
dg.update_name(user_id, group_id, name)
await message.reply(f'{name.title()}, ваше имя было успешно изменено)')
await message.reply(f'{md(name.title())}, ваше имя было успешно изменено)')
else:
await message.reply(f'{message.from_user.first_name.lower().title()}, такое имя уже присутствует в чате!')
await message.reply(f'{md(message.from_user.first_name.lower().title())}, такое имя уже присутствует в чате!')
else:
await message.reply('Вы не правильно ввели имя! Имя должно быть '
'из одного слова и идти сразу после команды!')
@ -282,9 +298,9 @@ async def update_return(message: Message):
db.del_edit_user(user_id)
dg.update_name(user_id, group_id, name)
await message.reply(f'{name.title()}, вы успешно вернулись к динамическому изменению имени)')
await message.reply(f'{md(name.title())}, вы успешно вернулись к динамическому изменению имени)')
else:
await message.reply(f'{name.title()}, вы не устанавливали постоянное имя!')
await message.reply(f'{md(name.title())}, вы не устанавливали постоянное имя!')
# ВКЛЮЧЕНИЕ ТЕКСТОВЫХ ИВЕНТОВ
@router.message(Command('start_bot'))
@ -361,47 +377,21 @@ 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:
if message.voice.duration <= 60:
user_id = du.get_user_id(message.from_user.id)
# упоминание по имени, произнесённому в голосовом
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
num = random.randint(1000, 9999)
audio = f"../data/voices/{user_id}_{num}.oga"
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]
file_id = message.voice.file_id
file = await bot.get_file(file_id)
file_path = file.file_path
await bot.download_file(file_path, audio)
data, samplerate = sf.read(audio)
os.remove(audio)
audio = f"../data/voices/{user_id}_{num}.wav"
sf.write(audio, data, samplerate)
af = sr.AudioFile(audio)
r = sr.Recognizer()
with af as source:
r.pause_threshold = 100
source = r.listen(source)
try:
query = r.recognize_google(source, language='ru-RU')
os.remove(audio)
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]
names = [_ for _ in first_form if _ in list(map(lambda x: x[0], dg.all_names(du.get_group_id(group_id))))]
if names:
await message.reply(notice(names, False, du.get_group_id(group_id), message.from_user.id))
return
except Exception:
try:
os.remove(audio)
except Exception:
pass
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)
@ -431,7 +421,8 @@ async def send_events(message: Message):
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))))]
if len(words) >= 2:
# сообщение только из знаков препинания: дальше нужны слова (раньше — IndexError на unsigned[0])
if len(words) >= 2 and unsigned:
# ПОЛЕЗНЫЕ ФУНКЦИИ
if len(words) == 5 and "число от" in low_mes:
try:
@ -445,29 +436,32 @@ async def send_events(message: Message):
if unsigned[0] == 'переведи':
if "переведи - " in low_mes:
await message.answer(lang_form([words[_] for _ in range(len(words)) if _ > 1]))
elif f"переведи ({unsigned[1]}) - " 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(lang_form([words[_] for _ in range(len(words)) if _ > 2], unsigned[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(revers(message.text[12:], True))
await message.answer(md(revers(message.text[12:], True)))
elif "переверни полностью - " in low_mes:
await message.answer(revers(message.text[22:], False))
await message.answer(md(revers(message.text[22:], False)))
return
if unsigned[0] == 'озвучь' and "озвучь - " in low_mes:
text_to_voice = message.text[9:]
tts = gTTS(text_to_voice, lang='ru')
tts.save('../data/voices/voice.ogg')
await bot.send_voice(
chat_id=group_id,
voice=FSInputFile('../data/voices/voice.ogg'),
caption=f"*{text_to_voice}*"
)
os.remove('../data/voices/voice.ogg')
# синтез — в отдельном потоке и во временный файл (раньше общий 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
# РАНДОМ ИВЕНТЫ
@ -477,14 +471,14 @@ async def send_events(message: Message):
if random.randint(0, 1) == 0:
await message.answer_photo(
photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"),
caption=f"{message.from_user.first_name}, ты был унижен {text.title()}"
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"{message.from_user.first_name}, ты победил в драке "
f"с {text.title()}, {base.VAR_WIN[random.randint(0, 1)]}"
caption=f"{md(message.from_user.first_name)}, ты победил в драке "
f"с {md(text.title())}, {base.VAR_WIN[random.randint(0, 1)]}"
)
# ИВЕНТ ВЗАИМОДЕЙСТВИЯ
@ -493,11 +487,10 @@ async def send_events(message: Message):
lst = [words[i] for i in range(len(words)) if i != 0]
text = " ".join(lst)
slv = morph.parse(words[0].lower())[0]
await message.answer_photo(
photo=FSInputFile(f"../data/tmok/({random.randint(1, 4)}).jpg"),
caption=f"{message.from_user.first_name} "
f"{slv.inflect({'past', 'sing', 'indc'}).word} {text}"
caption=f"{md(message.from_user.first_name)} "
f"{inflect_past(words[0])} {md(text)}"
)
for word in base.KILL_LIST:
@ -505,36 +498,37 @@ async def send_events(message: Message):
lst = [words[i] for i in range(len(words)) if i != 0]
text = " ".join(lst)
slv = morph.parse(words[0].lower())[0]
await message.answer_photo(
photo=FSInputFile(f"../data/kill/({random.randint(1, 6)}).jpg"),
caption=f"{message.from_user.first_name} "
f"{slv.inflect({'past', 'sing', 'indc'}).word} {text}"
caption=f"{md(message.from_user.first_name)} "
f"{inflect_past(words[0])} {md(text)}"
)
if (unsigned[0] in base.QUAT_LIST[0] and
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:
word = requests.get('http://fucking-great-advice.ru/api/random').json()
await message.reply(word["text"])
except:
pass
# без таймаута и в обработчике запрос мог надолго заморозить бота
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:
try:
await message.reply(notice(names, False, du.get_group_id(group_id), user_id))
except Exception:
pass
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(
f"[{message.from_user.first_name}](tg://user_id?id={user_id}) *>* {translator(words)}"
# раньше tg://user_id?id= — такой ссылки нет, упоминание не работало
f"[{plain(message.from_user.first_name)}](tg://user?id={user_id}) *>* {md(translator(words))}"
)
return

View file

@ -4,7 +4,11 @@ from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.bot import DefaultBotProperties
import base, enchant, pymorphy3
import os
import base
import enchant
import pymorphy3
bot = Bot(token=base.TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN))
dp = Dispatcher(storage=MemoryStorage())
@ -15,6 +19,7 @@ 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')

View file

@ -1,12 +1,28 @@
from base import ERR, ERR_, TRU, TRU_, ALB
import re
from string import digits, punctuation
import re, sql
# инициализируем соединение с БД
db = sql.Base('../db/base.db')
du = sql.User('../db/users.db')
dg = sql.Group('../db/groups.db')
dm = sql.Month('../db/month.db')
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):
@ -63,23 +79,26 @@ def notice(name, all_users, group_id, author):
if all_users:
name = dg.user_name(name, group_id)
usr = [f"[{names[i].title()}](tg://user?id={str(ids[i])})" for i in range(len(names)) if names[i] != name.lower()]
usr.append(f'{usr[-2]} и {usr[-1]}')
del usr[-2], usr[-2]
return f'{", ".join(usr)} вас вызывает {name.title()}'
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 = [f"[{_.title()}](tg://user?id={str(ids[names.index(_)])})" for _ in no_copy if int(author) != ids[names.index(_)]]
usr.append(f'{usr[-2]} и {usr[-1]}')
del usr[-2], usr[-2]
return f"{', '.join(usr)} вас упомянули)"
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"[{no_copy[0].title()}](tg://user?id={str(ids[names.index(no_copy[0])])})"
usr = f"[{plain(no_copy[0].title())}](tg://user?id={str(ids[names.index(no_copy[0])])})"
return f"{usr}, тебя упомянули)"
@ -142,7 +161,7 @@ def revers(message, var):
pct.append(''.join(pnc))
pnc = []
wrd, rev, lst, txt = no_pct.split(), [], [], []
wrd, rev, txt = no_pct.split(), [], []
for i in wrd:
word, up, itg = [i[-1 - l].lower() for l in range(len(i))], [], []

View file

@ -1,5 +1,16 @@
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):
@ -7,55 +18,66 @@ class Base:
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(f"INSERT INTO `work` (`group_id`) VALUES(?)", (group_id,))
self.cursor.execute(f"INSERT INTO `stat` (`group_id`, `mes`, `rep`, `com`, `url`, `med`, `sti`, "
f"`voi`) VALUES(?,?,?,?,?,?,?,?)", (group_id, 0, 0, 0, 0, 0, 0, 0))
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(f'SELECT * FROM `work` WHERE `group_id` = ?', (group_id,)).fetchall()
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):
"""Обновляем статистику"""
try:
with self.connection:
self.cursor.execute(f"SELECT * FROM `stat` WHERE `group_id` = ?", (group_id,))
if var_id == 1:
return self.cursor.execute(f"UPDATE `stat` SET `mes` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[1] + 1), group_id))
elif var_id == 2:
return self.cursor.execute(f"UPDATE `stat` SET `rep` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[2] + 1), group_id))
elif var_id == 3:
return self.cursor.execute(f"UPDATE `stat` SET `com` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[3] + 1), group_id))
elif var_id == 4:
return self.cursor.execute(f"UPDATE `stat` SET `url` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[4] + 1), group_id))
elif var_id == 5:
return self.cursor.execute(f"UPDATE `stat` SET `med` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[5] + 1), group_id))
elif var_id == 6:
return self.cursor.execute(f"UPDATE `stat` SET `sti` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[6] + 1), group_id))
elif var_id == 7:
return self.cursor.execute(f"UPDATE `stat` SET `voi` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[7] + 1), group_id))
except Exception as e:
print(repr(e))
self.increment("stat", group_id, var_id)
def stat_group(self, group_id):
"""Получение данных пользователя"""
with self.connection:
self.cursor.execute(f"SELECT * FROM `stat` WHERE `group_id` = ?", (group_id,))
self.cursor.execute("SELECT * FROM `stat` WHERE `group_id` = ?", (group_id,))
data = self.cursor.fetchone()
return data[1:]
@ -74,37 +96,12 @@ class Base:
def update_month_stat(self, group_id, var_id):
"""Обновляем статистику"""
try:
with self.connection:
self.cursor.execute(f"SELECT * FROM `month` WHERE `group_id` = ?", (group_id,))
if var_id == 1:
return self.cursor.execute(f"UPDATE `month` SET `mes` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[1] + 1), group_id))
elif var_id == 2:
return self.cursor.execute(f"UPDATE `month` SET `rep` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[2] + 1), group_id))
elif var_id == 3:
return self.cursor.execute(f"UPDATE `month` SET `com` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[3] + 1), group_id))
elif var_id == 4:
return self.cursor.execute(f"UPDATE `month` SET `url` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[4] + 1), group_id))
elif var_id == 5:
return self.cursor.execute(f"UPDATE `month` SET `med` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[5] + 1), group_id))
elif var_id == 6:
return self.cursor.execute(f"UPDATE `month` SET `sti` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[6] + 1), group_id))
elif var_id == 7:
return self.cursor.execute(f"UPDATE `month` SET `voi` = ? WHERE `group_id` = ?",
((self.cursor.fetchone()[7] + 1), group_id))
except Exception as e:
print(repr(e))
self.increment("month", group_id, var_id)
def month_stat_group(self, group_id):
"""Получение данных пользователя"""
with self.connection:
self.cursor.execute(f"SELECT * FROM `month` WHERE `group_id` = ?", (group_id,))
self.cursor.execute("SELECT * FROM `month` WHERE `group_id` = ?", (group_id,))
data = self.cursor.fetchone()
return data[1:]
@ -123,19 +120,19 @@ class Base:
def del_edit_user(self, user_id):
"""Удаление пользователя"""
with self.connection:
return self.cursor.execute(f'DELETE FROM `edit` WHERE `user_id` = ?', (user_id,))
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(f'SELECT `state` FROM `work` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
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(f'SELECT `state` FROM `work` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
return self.cursor.execute(f"UPDATE `work` SET `state` = ? WHERE `group_id` = ?", (not state, group_id))
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):
@ -149,27 +146,33 @@ class User:
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(f'SELECT * FROM `users` WHERE `user_id` = ?', (user_id,)).fetchall()
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(f"INSERT INTO `users` (`user_id`) VALUES(?)", (user_id,))
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(f'SELECT `id` FROM `users` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
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(f'SELECT `user_id` FROM `users` WHERE `id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute('SELECT `user_id` FROM `users` WHERE `id` = ?', (user_id,)).fetchone()[0]
# СВЯЗКА ГРУППЫ
def group_exists(self, group_id):
@ -186,12 +189,12 @@ class User:
def get_group_id(self, group_id):
"""Получаем короткое айди юзера"""
with self.connection:
return self.cursor.execute(f'SELECT `id` FROM `groups` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
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(f'SELECT `group_id` FROM `groups` WHERE `id` = ?', (group_id,)).fetchone()[0]
return self.cursor.execute('SELECT `group_id` FROM `groups` WHERE `id` = ?', (group_id,)).fetchone()[0]
def update_group_id(self, from_id, to_id):
"""Заменяем на новый айди"""
@ -214,7 +217,7 @@ class Group:
def created_group(self, group_id):
"""Создаём новую таблицу"""
with self.connection:
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS [{group_id}] (
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS {table(group_id)} (
user_id INTEGER NOT NULL,
first_name STRING,
mes INTEGER,
@ -228,76 +231,57 @@ class Group:
def add_user(self, group_id, user_id, name):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute(f"INSERT INTO `{group_id}` (`user_id`, `first_name`, `mes`, `rep`, `com`, `url`, "
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 `{group_id}`').fetchall()
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 `{group_id}`').fetchall()
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"SELECT * FROM `{group_id}` WHERE `user_id` = ?", (user_id,))
if var_id == 1:
return self.cursor.execute(f"UPDATE `{group_id}` SET `mes` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[2] + 1), user_id))
elif var_id == 2:
return self.cursor.execute(f"UPDATE `{group_id}` SET `rep` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[3] + 1), user_id))
elif var_id == 3:
return self.cursor.execute(f"UPDATE `{group_id}` SET `com` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[4] + 1), user_id))
elif var_id == 4:
return self.cursor.execute(f"UPDATE `{group_id}` SET `url` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[5] + 1), user_id))
elif var_id == 5:
return self.cursor.execute(f"UPDATE `{group_id}` SET `med` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[6] + 1), user_id))
elif var_id == 6:
return self.cursor.execute(f"UPDATE `{group_id}` SET `sti` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[7] + 1), user_id))
elif var_id == 7:
return self.cursor.execute(f"UPDATE `{group_id}` SET `voi` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[8] + 1), user_id))
except Exception as e:
print(repr(e))
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 `{group_id}` WHERE `user_id` = ?', (user_id,)).fetchall()
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 `{group_id}` SET `first_name` = ? WHERE `user_id` = ?", (name, user_id))
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 `{group_id}` WHERE `user_id` = ?", (user_id,)).fetchone()[0]
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 `{group_id}` WHERE `user_id` = ?", (user_id,))
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 `{group_id}` WHERE `user_id` = ?', (user_id,))
return self.cursor.execute(f'DELETE FROM {table(group_id)} WHERE `user_id` = ?', (user_id,))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
@ -311,40 +295,29 @@ class Month:
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"SELECT * FROM `{group_id}` WHERE `user_id` = ?", (user_id,))
if var_id == 1:
return self.cursor.execute(f"UPDATE `{group_id}` SET `mes` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[1] + 1), user_id))
elif var_id == 2:
return self.cursor.execute(f"UPDATE `{group_id}` SET `rep` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[2] + 1), user_id))
elif var_id == 3:
return self.cursor.execute(f"UPDATE `{group_id}` SET `com` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[3] + 1), user_id))
elif var_id == 4:
return self.cursor.execute(f"UPDATE `{group_id}` SET `url` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[4] + 1), user_id))
elif var_id == 5:
return self.cursor.execute(f"UPDATE `{group_id}` SET `med` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[5] + 1), user_id))
elif var_id == 6:
return self.cursor.execute(f"UPDATE `{group_id}` SET `sti` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[6] + 1), user_id))
elif var_id == 7:
return self.cursor.execute(f"UPDATE `{group_id}` SET `voi` = ? WHERE `user_id` = ?",
((self.cursor.fetchone()[7] + 1), user_id))
except Exception as e:
print(repr(e))
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 [{group_id}] (
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS {table(group_id)} (
user_id INTEGER NOT NULL,
mes INTEGER,
rep INTEGER,
@ -357,31 +330,31 @@ class Month:
def add_user(self, group_id, user_id):
"""Добавляем нового пользователя"""
with self.connection:
return self.cursor.execute(f"INSERT INTO `{group_id}` (`user_id`, `mes`, `rep`, `com`, `url`, `med`, "
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 `{group_id}`').fetchall()
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 `{group_id}` WHERE `user_id` = ?', (user_id,)).fetchall()
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 `{group_id}` WHERE `user_id` = ?", (user_id,))
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 `{group_id}` WHERE `user_id` = ?', (user_id,))
return self.cursor.execute(f'DELETE FROM {table(group_id)} WHERE `user_id` = ?', (user_id,))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):

11
compose.yaml Normal file
View file

@ -0,0 +1,11 @@
services:
bot:
build: .
image: ghcr.io/edeev/chatping_abobot:latest
env_file: .env
volumes:
- db:/app/db
restart: unless-stopped
volumes:
db:

Binary file not shown.

Binary file not shown.

View file

Binary file not shown.

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
ruff==0.14.0

View file

@ -1,34 +1,8 @@
# Core Framework Dependencies
aiogram==3.10.0
asyncio-mqtt==0.16.1
# Database Management
sqlite3
# Natural Language Processing
pymorphy3==1.2.1
aiogram==3.23.0
pymorphy3==2.0.6
pymorphy3-dicts-ru==2.4.417150.4580142
enchant==1.6.6
# Speech Recognition & Synthesis
SpeechRecognition==3.10.4
gTTS==2.5.1
soundfile==0.12.1
# HTTP Client for API Integration
requests==2.31.0
# Audio Processing Dependencies
pyaudio==0.2.14
pydub==0.25.1
# System Dependencies (if needed)
# Note: Some packages may require system-level installation
# Ubuntu/Debian: sudo apt-get install python3-enchant libenchant-2-2
# macOS: brew install enchant portaudio
# Windows: Download pyaudio wheel from unofficial binaries
# Development Dependencies (Optional)
# pytest==8.2.2
# black==24.4.2
# flake8==7.1.0
pyenchant==3.2.2
SpeechRecognition==3.14.4
gTTS==2.5.4
soundfile==0.13.1
requests==2.32.5

11
tests/conftest.py Normal file
View file

@ -0,0 +1,11 @@
import os
import sys
# бот открывает базы по путям ../db относительно папки code — запускаем как в проде, но во временной папке
import tempfile
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"))

65
tests/test_script.py Normal file
View file

@ -0,0 +1,65 @@
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 # статистика за всё время не трогается