From a2a0adcf8e8a77368dc32746c468542b7f7c2823 Mon Sep 17 00:00:00 2001 From: Egor Deev Date: Tue, 6 Oct 2026 11:16:00 +0000 Subject: [PATCH] =?UTF-8?q?AboBot=204.0:=20PostgreSQL,=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=80=D0=BE=D1=83=D1=82=D0=B5=D1=80=D0=B0=D0=BC,=20HT?= =?UTF-8?q?ML-=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=82=D0=BA=D0=B0,=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit База — PostgreSQL (asyncpg) вместо четырёх файлов SQLite с таблицей на каждый чат: chats, users, members, chat_stats и member_stats с полем «период» — месяцы хранятся, а не обнуляются; счётчики растут одним запросом без блокировки бота. scripts/migrate_sqlite.py переносит старые базы со сверкой сумм (проверено на копии рабочих баз: 20 694 пользователя, 45 чатов, 3 142 038 сообщений — совпадает). Учёт бота в чатах: статус и права бота (событие my_chat_member и сверка с Telegram при запуске и раз в 6 часов), история изменений в bot_status_history; из чатов, где бота исключили, данные не удаляются — меняется только статус. Большие чаты (больше 100 участников): /all — только для админов, раз в 5 минут и только писавшие за 30 дней; упоминание одного человека по имени — не чаще раза в минуту. В любом чате /all делится на сообщения до 4096 символов (раньше в чате на 14 тысяч падал). Новое: /settings (упоминания, имена в голосовых, ивенты, удаление служебных сообщений — меняют админы), /top, /month (итоги месяца по команде). /stop_bot раньше ничего не выключал — флаг не проверялся; теперь работает. Участник, вышедший из чата, больше не упоминается, но его статистика сохраняется. Ошибки — в технический чат DEBUG_CHAT_ID. Код: handlers.py разбит на роутеры, Markdown заменён на HTML (экранирование через html.escape), тесты на PostgreSQL (18), CI на Python 3.10 и 3.12. --- .env.example | 4 + .github/workflows/ci.yml | 18 +- .gitignore | 1 - Dockerfile | 5 +- code/base.py | 8 +- code/bot.py | 73 ++++-- code/config.py | 10 + code/db.py | 199 ++++++++++++++ code/handlers.py | 534 -------------------------------------- code/handlers/__init__.py | 4 + code/handlers/chat.py | 126 +++++++++ code/handlers/common.py | 44 ++++ code/handlers/events.py | 140 ++++++++++ code/handlers/help.py | 73 ++++++ code/handlers/mentions.py | 123 +++++++++ code/handlers/settings.py | 70 +++++ code/handlers/stats.py | 117 +++++++++ code/handlers/voice.py | 86 ++++++ code/init.py | 26 -- code/nlp.py | 146 +++++++++++ code/schema.sql | 73 ++++++ code/script.py | 189 -------------- code/sql.py | 362 -------------------------- compose.yaml | 21 +- db/.gitkeep | 0 pytest.ini | 4 + requirements-dev.txt | 1 + requirements.txt | 1 + scripts/migrate_sqlite.py | 119 +++++++++ tests/conftest.py | 41 ++- tests/test_db.py | 49 ++++ tests/test_handlers.py | 69 +++++ tests/test_migration.py | 56 ++++ tests/test_script.py | 65 ----- 34 files changed, 1633 insertions(+), 1224 deletions(-) create mode 100644 code/config.py create mode 100644 code/db.py delete mode 100644 code/handlers.py create mode 100644 code/handlers/__init__.py create mode 100644 code/handlers/chat.py create mode 100644 code/handlers/common.py create mode 100644 code/handlers/events.py create mode 100644 code/handlers/help.py create mode 100644 code/handlers/mentions.py create mode 100644 code/handlers/settings.py create mode 100644 code/handlers/stats.py create mode 100644 code/handlers/voice.py delete mode 100644 code/init.py create mode 100644 code/nlp.py create mode 100644 code/schema.sql delete mode 100644 code/script.py delete mode 100644 code/sql.py delete mode 100644 db/.gitkeep create mode 100644 pytest.ini create mode 100644 scripts/migrate_sqlite.py create mode 100644 tests/test_db.py create mode 100644 tests/test_handlers.py create mode 100644 tests/test_migration.py delete mode 100644 tests/test_script.py diff --git a/.env.example b/.env.example index 7fcca50..65b1bc2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ # Токен бота от @BotFather BOT_TOKEN=123456:your-token +# База PostgreSQL (в compose.yaml задаётся автоматически) +DATABASE_URL=postgresql://abobot:abobot@localhost:5432/abobot +# Технический чат для сообщений об ошибках (необязательно) +DEBUG_CHAT_ID= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b77a23..0d9cf66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 780d67e..06c00d0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ __pycache__/ *.pyc .pytest_cache/ -db/*.db diff --git a/Dockerfile b/Dockerfile index 4723f55..9952a72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/code/base.py b/code/base.py index 4a33162..19bbbeb 100644 --- a/code/base.py +++ b/code/base.py @@ -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 = ["побить", "отмудохать", "избить", "уебать", "отметелить"] diff --git a/code/bot.py b/code/bot.py index 64b6245..94a10c7 100644 --- a/code/bot.py +++ b/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"AboBot: ошибка\n
{tb.replace('<', '<')}
") + 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 \ No newline at end of file + pass diff --git a/code/config.py b/code/config.py new file mode 100644 index 0000000..53fe2ff --- /dev/null +++ b/code/config.py @@ -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 # упоминание одного человека в большом чате — не чаще, с diff --git a/code/db.py b/code/db.py new file mode 100644 index 0000000..40a2645 --- /dev/null +++ b/code/db.py @@ -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] diff --git a/code/handlers.py b/code/handlers.py deleted file mode 100644 index df48665..0000000 --- a/code/handlers.py +++ /dev/null @@ -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/_<случайное число>, при ошибке файлы оставались)""" - 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 \ No newline at end of file diff --git a/code/handlers/__init__.py b/code/handlers/__init__.py new file mode 100644 index 0000000..39b8721 --- /dev/null +++ b/code/handlers/__init__.py @@ -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] diff --git a/code/handlers/chat.py b/code/handlers/chat.py new file mode 100644 index 0000000..929ae69 --- /dev/null +++ b/code/handlers/chat.py @@ -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 = ('Привет группа {title}!\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 diff --git a/code/handlers/common.py b/code/handlers/common.py new file mode 100644 index 0000000..5b64584 --- /dev/null +++ b/code/handlers/common.py @@ -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 diff --git a/code/handlers/events.py b/code/handlers/events.py new file mode 100644 index 0000000..28925ee --- /dev/null +++ b/code/handlers/events.py @@ -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"Число [ {random.randint(min(num1, num2), max(num1, num2))} ]") + 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"{h(text_to_voice)}") + 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)} > " + f"{h(translator(words))}") diff --git a/code/handlers/help.py b/code/handlers/help.py new file mode 100644 index 0000000..22dbd1c --- /dev/null +++ b/code/handlers/help.py @@ -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 = ('- Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, ' + 'ивентами и удобными функциями, которые облегчают использование чата)\n\n' + '- Также прошу если вам понравился бот, оставить отзыв о его использовании на ' + 'команду /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( + '| АВТОР |\n\n>> Этот бот, как бы это не печально звучало, но одна из лучших' + ' моих работ и если кого-нибудь у меня получится действительно достойный продукт,' + ' вы сможете о нём узнать в моём телеграм канале @programium') + + +@router.callback_query(F.data == "fun") +async def function(call: CallbackQuery): + await call.message.answer( + '| ФУНКЦИИ |\n\n1. Возможность перевода случайно написанного текста на ' + 'транслите\n2. Ведение обширной статистики сообщений\n3. Упоминание участника ' + 'при написании его имени в чате\n4. Автоматическое удаление системных сообщений') + + +@router.callback_query(F.data == "com") +async def commands(call: CallbackQuery): + await call.message.answer( + '| КОМАНДЫ |\n\n/all - упомянуть всех в чате\n/help - полный список функций\n' + '/recognize - транскрипция отмеченного голосового сообщения в текст\n' + '/stat_group - полная статистика группы\n/stat_user - полная статистика ' + 'отправителя\n/top - самые активные участники (/top all - за всё время)\n' + '/month - итоги месяца (/month 2026-09 - за любой месяц)\n' + '/edit и /back_edit - первая команда даёт возможность ' + 'сменить имя для упоминаний на любое слово, а вторая для возврата динамического ' + 'имени\n/settings - настройки бота в чате\n' + '/start_bot и /stop_bot - возможность отключения текстовых ивентов') + + +@router.callback_query(F.data == "even") +async def events(call: CallbackQuery): + await call.message.answer( + '| ИВЕНТЫ |\n\n"Чмокнуть" - сделать кому-нибудь приятно\n"Отмудохать" - ' + 'выместить злость на кого-нибудь\n"Число от ... до ..." - случайное значение из ' + 'диапозона\n"Подраться с ..." - повод кого-нибудь побить\n"Переведи (..) - ..." ' + '- перевод слова на керпичный язык\n"Дай блять совет!" - даёт рандомный охуенный ' + 'совет\n"Переверни - ..." - переворачивает слова в предложении\n"Озвучь - ..." ' + '- озвучивает написанный текст') diff --git a/code/handlers/mentions.py b/code/handlers/mentions.py new file mode 100644 index 0000000..a114a06 --- /dev/null +++ b/code/handlers/mentions.py @@ -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())}, вы не устанавливали постоянное имя!') diff --git a/code/handlers/settings.py b/code/handlers/settings.py new file mode 100644 index 0000000..b92b2a7 --- /dev/null +++ b/code/handlers/settings.py @@ -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('| НАСТРОЙКИ ЧАТА |\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) diff --git a/code/handlers/stats.py b/code/handlers/stats.py new file mode 100644 index 0000000..fddb5ae --- /dev/null +++ b/code/handlers/stats.py @@ -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'- За всё время / За месяц -\n' + f'>> Сообщений в базе [ {total[0]} / {month[0]} ]\n\n' + f'> Ответов - [ {total[1]} / {month[1]} ]\n' + f'> Команд - [ {total[2]} / {month[2]} ]\n' + f'> Ссылок - [ {total[3]} / {month[3]} ]\n' + f'> Стикеров - [ {total[5]} / {month[5]} ]\n' + f'> Медиа файлов - [ {total[4]} / {month[4]} ]\n' + f'> Голос/Кружочки - [ {total[6]} / {month[6]} ]') + + +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'| СТАТИСТИКА ГРУППЫ |\n\n' + f'>> В целом сообщений [ {message.message_id} ]\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'| СТАТИСТИКА {h(name.upper())} |\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"{i}. {h(name.title())} — {value}" for i, (_, name, value) in enumerate(rows, 1)] + await message.answer(f"| ТОП УЧАСТНИКОВ {title.upper()} |\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("Укажите месяц в формате 2026-09 или прошлый") + return + + blocks = [] + for field, title in CATEGORIES: + rows = await db.top(message.chat.id, period, field, 3) + if rows: + blocks.append(f"{title}:\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"| ИТОГИ: {month_title(period).upper()} |\n\n" + f"Сообщений в чате: {totals[0]}\n\n" + "\n\n".join(blocks)) diff --git a/code/handlers/voice.py b/code/handlers/voice.py new file mode 100644 index 0000000..52c0a7a --- /dev/null +++ b/code/handlers/voice.py @@ -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'{h(message.reply_to_message.from_user.first_name)} сказал(a) "{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) diff --git a/code/init.py b/code/init.py deleted file mode 100644 index 1d6698f..0000000 --- a/code/init.py +++ /dev/null @@ -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') \ No newline at end of file diff --git a/code/nlp.py b/code/nlp.py new file mode 100644 index 0000000..c599f80 --- /dev/null +++ b/code/nlp.py @@ -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'{h(str(name).title())}' + + +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))]) \ No newline at end of file diff --git a/code/schema.sql b/code/schema.sql new file mode 100644 index 0000000..b565117 --- /dev/null +++ b/code/schema.sql @@ -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'); diff --git a/code/script.py b/code/script.py deleted file mode 100644 index dc3578f..0000000 --- a/code/script.py +++ /dev/null @@ -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))]) \ No newline at end of file diff --git a/code/sql.py b/code/sql.py deleted file mode 100644 index 16ec067..0000000 --- a/code/sql.py +++ /dev/null @@ -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() diff --git a/compose.yaml b/compose.yaml index b5a1717..8064394 100644 --- a/compose.yaml +++ b/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: diff --git a/db/.gitkeep b/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..64baef7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt index b065da0..81089b6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ -r requirements.txt pytest==8.4.2 +pytest-asyncio==1.4.0 ruff==0.14.0 diff --git a/requirements.txt b/requirements.txt index 2ef520a..7870f9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/migrate_sqlite.py b/scripts/migrate_sqlite.py new file mode 100644 index 0000000..fbde1ba --- /dev/null +++ b/scripts/migrate_sqlite.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 982ccfd..416f4d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..95ab844 --- /dev/null +++ b/tests/test_db.py @@ -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)] diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..a09dc25 --- /dev/null +++ b/tests/test_handlers.py @@ -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'Участник{i}' 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 == 'Маша, тебя упомянули)' + 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>' + assert nlp.find_names("позови Машу и Петю, Машу!", {"маша", "петя"}) == ["маша", "петя"] + + +def test_bot_imports(): + import bot # noqa: F401 diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..5d86f80 --- /dev/null +++ b/tests/test_migration.py @@ -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 diff --git a/tests/test_script.py b/tests/test_script.py deleted file mode 100644 index a0895cd..0000000 --- a/tests/test_script.py +++ /dev/null @@ -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 # статистика за всё время не трогается