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