mirror of
https://github.com/EDeev/chatping_abobot.git
synced 2026-10-07 20:49:45 +03:00
AboBot 4.0: PostgreSQL, обработчики по роутерам, HTML-разметка, новые команды
База — PostgreSQL (asyncpg) вместо четырёх файлов SQLite с таблицей на каждый чат: chats, users, members, chat_stats и member_stats с полем «период» — месяцы хранятся, а не обнуляются; счётчики растут одним запросом без блокировки бота. scripts/migrate_sqlite.py переносит старые базы со сверкой сумм (проверено на копии рабочих баз: 20 694 пользователя, 45 чатов, 3 142 038 сообщений — совпадает). Учёт бота в чатах: статус и права бота (событие my_chat_member и сверка с Telegram при запуске и раз в 6 часов), история изменений в bot_status_history; из чатов, где бота исключили, данные не удаляются — меняется только статус. Большие чаты (больше 100 участников): /all — только для админов, раз в 5 минут и только писавшие за 30 дней; упоминание одного человека по имени — не чаще раза в минуту. В любом чате /all делится на сообщения до 4096 символов (раньше в чате на 14 тысяч падал). Новое: /settings (упоминания, имена в голосовых, ивенты, удаление служебных сообщений — меняют админы), /top, /month (итоги месяца по команде). /stop_bot раньше ничего не выключал — флаг не проверялся; теперь работает. Участник, вышедший из чата, больше не упоминается, но его статистика сохраняется. Ошибки — в технический чат DEBUG_CHAT_ID. Код: handlers.py разбит на роутеры, Markdown заменён на HTML (экранирование через html.escape), тесты на PostgreSQL (18), CI на Python 3.10 и 3.12.
This commit is contained in:
parent
9123f57968
commit
a2a0adcf8e
34 changed files with 1633 additions and 1224 deletions
|
|
@ -1,2 +1,6 @@
|
||||||
# Токен бота от @BotFather
|
# Токен бота от @BotFather
|
||||||
BOT_TOKEN=123456:your-token
|
BOT_TOKEN=123456:your-token
|
||||||
|
# База PostgreSQL (в compose.yaml задаётся автоматически)
|
||||||
|
DATABASE_URL=postgresql://abobot:abobot@localhost:5432/abobot
|
||||||
|
# Технический чат для сообщений об ошибках (необязательно)
|
||||||
|
DEBUG_CHAT_ID=
|
||||||
|
|
|
||||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -8,12 +8,26 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.12"]
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: abobot_test
|
||||||
|
ports: ["5432:5432"]
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/abobot_test
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: ${{ matrix.python-version }}
|
||||||
- run: sudo apt-get update -qq && sudo apt-get install -y -qq libenchant-2-2 hunspell-en-us
|
- run: sudo apt-get update -qq && sudo apt-get install -y -qq libenchant-2-2 hunspell-en-us
|
||||||
- run: pip install -r requirements-dev.txt
|
- run: pip install -r requirements-dev.txt
|
||||||
- run: ruff check --select E9,F,B code tests
|
- run: ruff check --select E9,F,B code tests scripts
|
||||||
- run: pytest -q
|
- run: pytest -q
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,4 +2,3 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
db/*.db
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY code/ code/
|
COPY code/ code/
|
||||||
COPY data/ data/
|
COPY data/ data/
|
||||||
RUN useradd --create-home --uid 1000 app && mkdir -p db && chown -R app:app /app
|
RUN useradd --create-home --uid 1000 app && chown -R app:app /app
|
||||||
USER app
|
USER app
|
||||||
VOLUME ["/app/db"]
|
|
||||||
|
|
||||||
# пути к базам и картинкам в коде — относительно папки code/ (../db, ../data)
|
# пути к картинкам в коде — относительно папки code/ (../data)
|
||||||
WORKDIR /app/code
|
WORKDIR /app/code
|
||||||
CMD ["python", "bot.py"]
|
CMD ["python", "bot.py"]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
import os
|
# Списки для ивентов и исправления раскладки (токены и id чатов — в config.py)
|
||||||
|
|
||||||
# ДАННЫЕ БОТА (из переменных окружения)
|
|
||||||
TOKEN = os.getenv("BOT_TOKEN", "XXXXXXXXXX") # @chat_abobot
|
|
||||||
DEBAG = os.getenv("DEBUG_CHAT_ID", "") # Технический чат
|
|
||||||
|
|
||||||
TEX_GROUP = os.getenv("TECH_GROUP_ID", "")
|
|
||||||
|
|
||||||
# ОБРАЩЕНИЯ К ПОЛЬЗОВАТЕЛЯМ
|
# ОБРАЩЕНИЯ К ПОЛЬЗОВАТЕЛЯМ
|
||||||
KILL_LIST = ["побить", "отмудохать", "избить", "уебать", "отметелить"]
|
KILL_LIST = ["побить", "отмудохать", "избить", "уебать", "отметелить"]
|
||||||
|
|
|
||||||
73
code/bot.py
73
code/bot.py
|
|
@ -1,49 +1,66 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
import traceback
|
||||||
|
|
||||||
from init import bot, dp, db, dm
|
from aiogram import Bot, Dispatcher
|
||||||
from handlers import router
|
from aiogram.client.bot import DefaultBotProperties
|
||||||
|
from aiogram.enums.parse_mode import ParseMode
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.types import ErrorEvent
|
||||||
|
|
||||||
|
import config
|
||||||
|
import db
|
||||||
|
from handlers import routers
|
||||||
|
from handlers.chat import check_all
|
||||||
|
|
||||||
|
CHECK_INTERVAL = 6 * 3600 # сверка статуса бота в чатах
|
||||||
|
|
||||||
|
|
||||||
def reset_month_if_needed(today=None):
|
def create_dispatcher():
|
||||||
"""Статистика «за месяц» раньше нигде не обнулялась и копилась с момента создания таблиц.
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
При первом запуске месяц только запоминается — обнуление со следующей смены месяца"""
|
for router in routers:
|
||||||
key = (today or date.today()).strftime("%Y-%m")
|
dp.include_router(router)
|
||||||
saved = db.get_meta("month")
|
|
||||||
if saved == key:
|
@dp.errors()
|
||||||
return False
|
async def on_error(event: ErrorEvent, bot: Bot):
|
||||||
if saved is not None:
|
"""Ошибка обработчика — в лог и в технический чат"""
|
||||||
db.reset_month()
|
logging.exception("Ошибка обработки", exc_info=event.exception)
|
||||||
dm.reset_all()
|
if config.DEBUG_CHAT_ID:
|
||||||
logging.info("Месячная статистика обнулена: %s → %s", saved, key)
|
tb = "".join(traceback.format_exception(event.exception))[-3500:]
|
||||||
db.set_meta("month", key)
|
try:
|
||||||
return saved is not None
|
await bot.send_message(config.DEBUG_CHAT_ID, f"<b>AboBot: ошибка</b>\n<pre>{tb.replace('<', '<')}</pre>")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
return dp
|
||||||
|
|
||||||
|
|
||||||
async def month_watcher():
|
async def periodic_check(bot: Bot):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
reset_month_if_needed()
|
await check_all(bot)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Не удалось обнулить месячную статистику")
|
logging.exception("Сверка статуса бота в чатах")
|
||||||
await asyncio.sleep(600)
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
dp.include_router(router)
|
bot = Bot(token=config.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||||
|
await db.connect(config.DATABASE_URL)
|
||||||
|
dp = create_dispatcher()
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
watcher = asyncio.create_task(month_watcher())
|
checker = asyncio.create_task(periodic_check(bot))
|
||||||
try:
|
try:
|
||||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||||
finally:
|
finally:
|
||||||
watcher.cancel()
|
checker.cancel()
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
try:
|
||||||
try: asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("Бот остановлен")
|
pass
|
||||||
pass
|
|
||||||
|
|
|
||||||
10
code/config.py
Normal file
10
code/config.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN", "XXXXXXXXXX") # @chat_abobot
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://abobot:abobot@localhost:5432/abobot")
|
||||||
|
DEBUG_CHAT_ID = os.getenv("DEBUG_CHAT_ID", "") # технический чат для ошибок
|
||||||
|
|
||||||
|
BIG_CHAT = 100 # с какого числа участников действуют ограничения
|
||||||
|
ALL_COOLDOWN = 300 # /all в большом чате — не чаще, с
|
||||||
|
ALL_ACTIVE_DAYS = 30 # /all в большом чате зовёт тех, кто писал за столько дней
|
||||||
|
MENTION_COOLDOWN = 60 # упоминание одного человека в большом чате — не чаще, с
|
||||||
199
code/db.py
Normal file
199
code/db.py
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""Работа с PostgreSQL. Счётчики увеличиваются одним запросом (INSERT … ON CONFLICT DO UPDATE)"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
COLUMNS = {1: "mes", 2: "rep", 3: "com", 4: "url", 5: "med", 6: "sti", 7: "voi"}
|
||||||
|
STAT_FIELDS = ("mes", "rep", "com", "url", "med", "sti", "voi")
|
||||||
|
SETTINGS = ("events_enabled", "mentions_enabled", "voice_names", "delete_service")
|
||||||
|
|
||||||
|
pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def month_key(day=None):
|
||||||
|
return (day or date.today()).strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
async def connect(dsn):
|
||||||
|
global pool
|
||||||
|
pool = await asyncpg.create_pool(dsn, min_size=1, max_size=5)
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), "schema.sql"), encoding="utf-8") as f:
|
||||||
|
await pool.execute(f.read())
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close():
|
||||||
|
if pool:
|
||||||
|
await pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ЧАТЫ И УЧАСТНИКИ
|
||||||
|
async def ensure_chat(chat_id, title=None):
|
||||||
|
await pool.execute(
|
||||||
|
"INSERT INTO chats (id, title) VALUES ($1, $2) "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET title = COALESCE(EXCLUDED.title, chats.title)", chat_id, title)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_exists(chat_id):
|
||||||
|
return await pool.fetchval("SELECT EXISTS (SELECT 1 FROM chats WHERE id = $1)", chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_chat(old_id, new_id):
|
||||||
|
"""Группа стала супергруппой — у неё новый id; связанные таблицы обновятся каскадом"""
|
||||||
|
await pool.execute("UPDATE chats SET id = $2 WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM chats WHERE id = $2)",
|
||||||
|
old_id, new_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_user(user_id):
|
||||||
|
await pool.execute("INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING", user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(chat_id, name):
|
||||||
|
assert name in SETTINGS
|
||||||
|
value = await pool.fetchval(f"SELECT {name} FROM chats WHERE id = $1", chat_id)
|
||||||
|
return True if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_setting(chat_id, name):
|
||||||
|
assert name in SETTINGS
|
||||||
|
return await pool.fetchval(f"UPDATE chats SET {name} = NOT {name} WHERE id = $1 RETURNING {name}", chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def settings(chat_id):
|
||||||
|
row = await pool.fetchrow(f"SELECT {', '.join(SETTINGS)} FROM chats WHERE id = $1", chat_id)
|
||||||
|
return dict(row) if row else dict.fromkeys(SETTINGS, True)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_names(chat_id):
|
||||||
|
"""Имена действующих участников: {имя: id}"""
|
||||||
|
rows = await pool.fetch("SELECT name, user_id FROM members WHERE chat_id = $1 AND left_at IS NULL AND name <> ''",
|
||||||
|
chat_id)
|
||||||
|
return {r["name"]: r["user_id"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def active_members(chat_id, days=None):
|
||||||
|
"""Действующие участники: [(id, имя)]; days — только писавшие за последние N дней"""
|
||||||
|
query = "SELECT user_id, name FROM members WHERE chat_id = $1 AND left_at IS NULL AND name <> ''"
|
||||||
|
args = [chat_id]
|
||||||
|
if days:
|
||||||
|
query += " AND last_message_at > now() - make_interval(days => $2)"
|
||||||
|
args.append(days)
|
||||||
|
return [(r["user_id"], r["name"]) for r in await pool.fetch(query + " ORDER BY name", *args)]
|
||||||
|
|
||||||
|
|
||||||
|
async def member_count(chat_id):
|
||||||
|
return await pool.fetchval("SELECT count(*) FROM members WHERE chat_id = $1 AND left_at IS NULL", chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def member_name(chat_id, user_id):
|
||||||
|
return await pool.fetchval("SELECT name FROM members WHERE chat_id = $1 AND user_id = $2", chat_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_custom_name(chat_id, user_id, name):
|
||||||
|
await pool.execute("UPDATE members SET name = $3, custom_name = TRUE WHERE chat_id = $1 AND user_id = $2",
|
||||||
|
chat_id, user_id, name)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_custom_name(chat_id, user_id, name):
|
||||||
|
"""Возвращает True, если своё имя было задано"""
|
||||||
|
return await pool.fetchval(
|
||||||
|
"UPDATE members SET name = $3, custom_name = FALSE WHERE chat_id = $1 AND user_id = $2 AND custom_name "
|
||||||
|
"RETURNING TRUE", chat_id, user_id, name) or False
|
||||||
|
|
||||||
|
|
||||||
|
async def member_left(chat_id, user_id):
|
||||||
|
await pool.execute("UPDATE members SET left_at = now() WHERE chat_id = $1 AND user_id = $2", chat_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_all(chat_id):
|
||||||
|
await pool.execute("UPDATE chats SET last_all_at = now() WHERE id = $1", chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def last_all(chat_id):
|
||||||
|
return await pool.fetchval("SELECT last_all_at FROM chats WHERE id = $1", chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
# СТАТИСТИКА
|
||||||
|
async def count(chat_id, user_id, var_ids, name, title=None, day=None):
|
||||||
|
"""Учёт события: участник появляется в чате (имя обновляется, если не задано своё),
|
||||||
|
счётчики var_ids растут за всё время и за текущий месяц — одной транзакцией"""
|
||||||
|
periods = ("all", month_key(day))
|
||||||
|
columns = [COLUMNS[v] for v in var_ids]
|
||||||
|
increments = ", ".join(f"{c} = s.{c} + EXCLUDED.{c}" for c in columns)
|
||||||
|
values = ", ".join("1" for _ in columns)
|
||||||
|
column_list = ", ".join(columns)
|
||||||
|
|
||||||
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO chats (id, title) VALUES ($1, $2) "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET title = COALESCE(EXCLUDED.title, chats.title)", chat_id, title)
|
||||||
|
await conn.execute("INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING", user_id)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO members (chat_id, user_id, name, last_message_at) VALUES ($1, $2, $3, now()) "
|
||||||
|
"ON CONFLICT (chat_id, user_id) DO UPDATE SET last_message_at = now(), left_at = NULL, "
|
||||||
|
"name = CASE WHEN members.custom_name THEN members.name ELSE EXCLUDED.name END",
|
||||||
|
chat_id, user_id, name)
|
||||||
|
for period in periods:
|
||||||
|
await conn.execute(
|
||||||
|
f"INSERT INTO chat_stats AS s (chat_id, period, {column_list}) VALUES ($1, $2, {values}) "
|
||||||
|
f"ON CONFLICT (chat_id, period) DO UPDATE SET {increments}", chat_id, period)
|
||||||
|
await conn.execute(
|
||||||
|
f"INSERT INTO member_stats AS s (chat_id, user_id, period, {column_list}) VALUES ($1, $2, $3, {values}) "
|
||||||
|
f"ON CONFLICT (chat_id, user_id, period) DO UPDATE SET {increments}", chat_id, user_id, period)
|
||||||
|
|
||||||
|
|
||||||
|
def _stats(row):
|
||||||
|
return tuple(row[f] for f in STAT_FIELDS) if row else (0,) * len(STAT_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_stats(chat_id, period):
|
||||||
|
return _stats(await pool.fetchrow("SELECT * FROM chat_stats WHERE chat_id = $1 AND period = $2", chat_id, period))
|
||||||
|
|
||||||
|
|
||||||
|
async def member_stats(chat_id, user_id, period):
|
||||||
|
return _stats(await pool.fetchrow("SELECT * FROM member_stats WHERE chat_id = $1 AND user_id = $2 AND period = $3",
|
||||||
|
chat_id, user_id, period))
|
||||||
|
|
||||||
|
|
||||||
|
async def top(chat_id, period, field="mes", limit=10):
|
||||||
|
"""Лучшие участники по счётчику: [(id, имя, значение)]"""
|
||||||
|
assert field in STAT_FIELDS
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"SELECT s.user_id, m.name, s.{field} AS value FROM member_stats s "
|
||||||
|
f"JOIN members m USING (chat_id, user_id) WHERE s.chat_id = $1 AND s.period = $2 AND s.{field} > 0 "
|
||||||
|
f"ORDER BY s.{field} DESC, m.name LIMIT $3", chat_id, period, limit)
|
||||||
|
return [(r["user_id"], r["name"], r["value"]) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# БОТ В ЧАТАХ
|
||||||
|
IN_CHAT = ("member", "administrator", "restricted", "creator")
|
||||||
|
|
||||||
|
|
||||||
|
async def set_bot_status(chat_id, status, rights, source, member_count=None, chat_type=None, title=None):
|
||||||
|
"""Записывает статус бота в чате; в историю — только если статус или права изменились"""
|
||||||
|
rights_json = None if rights is None else json.dumps(rights, sort_keys=True)
|
||||||
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO chats (id, title) VALUES ($1, $2) "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET title = COALESCE(EXCLUDED.title, chats.title)", chat_id, title)
|
||||||
|
changed = await conn.fetchval(
|
||||||
|
"SELECT bot_status IS DISTINCT FROM $2 OR bot_rights IS DISTINCT FROM $3::jsonb FROM chats WHERE id = $1",
|
||||||
|
chat_id, status, rights_json)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE chats SET bot_status = $2, bot_rights = $3::jsonb, checked_at = now(), "
|
||||||
|
"bot_status_at = CASE WHEN $4 THEN now() ELSE bot_status_at END, "
|
||||||
|
"member_count = COALESCE($5, member_count), chat_type = COALESCE($6, chat_type) WHERE id = $1",
|
||||||
|
chat_id, status, rights_json, changed, member_count, chat_type)
|
||||||
|
if changed:
|
||||||
|
await conn.execute("INSERT INTO bot_status_history (chat_id, status, rights, source) "
|
||||||
|
"VALUES ($1, $2, $3::jsonb, $4)", chat_id, status, rights_json, source)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def chats_to_check():
|
||||||
|
"""Чаты для сверки: все, где бот считается состоящим, и те, где статус неизвестен"""
|
||||||
|
rows = await pool.fetch("SELECT id FROM chats WHERE bot_status IS NULL OR bot_status = ANY($1::text[])",
|
||||||
|
list(IN_CHAT))
|
||||||
|
return [r["id"] for r in rows]
|
||||||
534
code/handlers.py
534
code/handlers.py
|
|
@ -1,534 +0,0 @@
|
||||||
from aiogram import F, Router
|
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
|
|
||||||
from aiogram.filters import Command, CommandStart
|
|
||||||
from aiogram.enums import ContentType
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import soundfile as sf
|
|
||||||
import speech_recognition as sr
|
|
||||||
from gtts import gTTS
|
|
||||||
|
|
||||||
import base
|
|
||||||
from init import bot, db, du, dg, dm, morph, engl_dict
|
|
||||||
from script import upd_stat, lang_form, notice, checker, translator, revers, md, plain
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
def recognize_file(oga_path):
|
|
||||||
"""Голосовое (OGG/Opus) → текст через Google Web Speech. Синхронно — вызывается в отдельном потоке"""
|
|
||||||
data, samplerate = sf.read(oga_path)
|
|
||||||
wav_path = oga_path[:-4] + ".wav"
|
|
||||||
sf.write(wav_path, data, samplerate)
|
|
||||||
|
|
||||||
r = sr.Recognizer()
|
|
||||||
with sr.AudioFile(wav_path) as source:
|
|
||||||
r.pause_threshold = 100
|
|
||||||
audio = r.listen(source)
|
|
||||||
return r.recognize_google(audio, language='ru-RU')
|
|
||||||
|
|
||||||
|
|
||||||
async def recognize_voice(file_id):
|
|
||||||
"""Распознавание без блокировки бота: раньше каждое голосовое в группе распознавалось прямо
|
|
||||||
в обработчике, и бот на это время не отвечал никому. Файлы — во временной папке на запрос
|
|
||||||
(раньше ../data/voices/<id>_<случайное число>, при ошибке файлы оставались)"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
oga = os.path.join(tmp, "voice.oga")
|
|
||||||
file = await bot.get_file(file_id)
|
|
||||||
await bot.download_file(file.file_path, oga)
|
|
||||||
try:
|
|
||||||
return await asyncio.to_thread(recognize_file, oga)
|
|
||||||
except sr.UnknownValueError: # речь не разобрана
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Ошибка распознавания голосового")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def inflect_past(word):
|
|
||||||
"""Глагол в прошедшем времени; если pymorphy3 не справился — как есть"""
|
|
||||||
form = morph.parse(word.lower())[0].inflect({'past', 'sing', 'indc'})
|
|
||||||
return form.word if form else word
|
|
||||||
|
|
||||||
# НОВЫЕ УЧАСТНИКИ ГРУППЫ
|
|
||||||
@router.message(F.content_type == ContentType.NEW_CHAT_MEMBERS)
|
|
||||||
async def notification(message: Message):
|
|
||||||
if not du.group_exists(message.chat.id):
|
|
||||||
du.add_group(message.chat.id)
|
|
||||||
|
|
||||||
group_id = du.get_group_id(message.chat.id)
|
|
||||||
db.add_group(group_id)
|
|
||||||
dg.created_group(group_id)
|
|
||||||
|
|
||||||
await bot.send_message(
|
|
||||||
message.chat.id,
|
|
||||||
f'*Привет группа {plain(message.chat.title)}!*\n\n'
|
|
||||||
f'Все функции вы можете узнать по команде /help! Для того, чтобы '
|
|
||||||
f'уведомления по имени и команда /all нормально функционировали, '
|
|
||||||
f'необходимо чтобы каждый участник группы написал хотя бы одно '
|
|
||||||
f'сообщение в чат! Также для того, чтобы системные сообщения удалялись '
|
|
||||||
f'автоматически, необходимо боту выдать права администратора!'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
await message.delete()
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
# ОБНОВЛЕНИЕ АЙДИ ГРУППЫ
|
|
||||||
@router.message(F.content_type.in_([ContentType.MIGRATE_TO_CHAT_ID, ContentType.MIGRATE_FROM_CHAT_ID]))
|
|
||||||
async def chat_reload(message: Message):
|
|
||||||
if du.group_exists(message.migrate_from_chat_id):
|
|
||||||
du.update_group_id(du.get_group_id(message.migrate_from_chat_id), message.migrate_to_chat_id)
|
|
||||||
|
|
||||||
# КОМАНДЫ HELP И START
|
|
||||||
@router.message(CommandStart())
|
|
||||||
@router.message(Command('help'))
|
|
||||||
async def helps(message: Message):
|
|
||||||
if message.chat.id > 0:
|
|
||||||
user_id = message.from_user.id
|
|
||||||
if not du.user_exists(user_id):
|
|
||||||
du.add_user(user_id)
|
|
||||||
else:
|
|
||||||
group_id, user_id = message.chat.id, message.from_user.id
|
|
||||||
if not du.user_exists(user_id):
|
|
||||||
du.add_user(user_id)
|
|
||||||
if not du.group_exists(group_id):
|
|
||||||
du.add_group(group_id)
|
|
||||||
upd_stat(user_id, group_id, 3, message.from_user.first_name, True)
|
|
||||||
|
|
||||||
buttons = [
|
|
||||||
[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
|
|
||||||
InlineKeyboardButton(text="ИВЕНТЫ", callback_data="even")],
|
|
||||||
[InlineKeyboardButton(text="АВТОР", callback_data="auth"),
|
|
||||||
InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun")]
|
|
||||||
]
|
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await bot.send_voice(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
voice=FSInputFile('../data/tts.ogg'),
|
|
||||||
caption='*-* Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, '
|
|
||||||
'ивентами и удобными функциями, которые облегчают использование чата)\n\n'
|
|
||||||
'*-* Также прошу если вам понравился бот, оставить отзыв о его использовании на '
|
|
||||||
'команду /report)',
|
|
||||||
reply_markup=keyboard
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await bot.send_message(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
text='*-* Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, '
|
|
||||||
'ивентами и удобными функциями, которые облегчают использование чата)\n\n'
|
|
||||||
'*-* Также прошу если вам понравился бот, оставить отзыв о его использовании на '
|
|
||||||
'команду /report)',
|
|
||||||
reply_markup=keyboard
|
|
||||||
)
|
|
||||||
|
|
||||||
# INLINE КЛАВИАТУРА ОБРАБОТЧИКИ
|
|
||||||
@router.callback_query(F.data == "auth")
|
|
||||||
async def author(call: CallbackQuery):
|
|
||||||
await call.message.answer(
|
|
||||||
text='*| АВТОР |*\n\n*>>* Этот бот, как бы это не печально звучало, но одна из лучших'
|
|
||||||
' моих работ и если кого-нибудь у меня получится действительно достойный продукт,'
|
|
||||||
' вы сможете о нём узнать в моём телеграм канале *@programium*'
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "fun")
|
|
||||||
async def function(call: CallbackQuery):
|
|
||||||
await call.message.answer(
|
|
||||||
text='*| ФУНКЦИИ |*\n\n*1.* Возможность перевода случайно написанного текста на '
|
|
||||||
'транслите\n*2.* Ведение обширной статистики сообщений\n*3.* Упоминание участника '
|
|
||||||
'при написании его имени в чате\n*4.* Автоматическое удаление системных сообщений'
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "com")
|
|
||||||
async def commands(call: CallbackQuery):
|
|
||||||
await call.message.answer(
|
|
||||||
text='*| КОМАНДЫ |*\n\n*/all* - упомянуть всех в чате\n*/help* - полный список функций\n'
|
|
||||||
'*/recognize* - транскрипция отмеченного голосового сообщения в текст\n'
|
|
||||||
'*/stat_group* - полная статистика группы\n*/stat_user* - полная статистика '
|
|
||||||
'отправителя\n*/edit и /back_edit* - первая команда даёт возможность '
|
|
||||||
'сменить имя для упоминаний на любое слово, а вторая для возврата динамического '
|
|
||||||
'имени\n*/start_bot и /stop_bot* - возможность отключения текстовых ивентов'
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "even")
|
|
||||||
async def events(call: CallbackQuery):
|
|
||||||
await call.message.answer(
|
|
||||||
text='*| ИВЕНТЫ |*\n\n*"Чмокнуть"* - сделать кому-нибудь приятно\n*"Отмудохать"* - '
|
|
||||||
'выместить злость на кого-нибудь\n*"Число от ... до ..."* - случайное значение из '
|
|
||||||
'диапозона\n*"Подраться с ..."* - повод кого-нибудь побить\n*"Переведи (..) - ..."* '
|
|
||||||
'- перевод слова на керпичный язык\n*"Дай блять совет!"* - даёт рандомный охуенный '
|
|
||||||
'совет\n*"Переверни - ..."* - переворачивает слова в предложении\n*"Озвучь - ..."* '
|
|
||||||
'- озвучивает написанный текст'
|
|
||||||
)
|
|
||||||
|
|
||||||
# КОМАНДА ALL
|
|
||||||
@router.message(Command('all'))
|
|
||||||
async def every(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
try:
|
|
||||||
await message.reply(
|
|
||||||
notice(du.get_user_id(message.from_user.id), True,
|
|
||||||
du.get_group_id(message.chat.id), message.from_user.id)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await message.reply('В группе состоит менее 3х человек, из-за чего команда не работает!')
|
|
||||||
else:
|
|
||||||
await message.reply('Эта команда предназначена для вызова в чате!')
|
|
||||||
|
|
||||||
# КОМАНДА RECOGNIZE
|
|
||||||
@router.message(Command("recognize"))
|
|
||||||
async def recognise(message: Message):
|
|
||||||
if not (message.reply_to_message and message.reply_to_message.voice):
|
|
||||||
await message.reply("Ответьте этой командой на голосовое сообщение!")
|
|
||||||
return
|
|
||||||
|
|
||||||
mes = await bot.send_message(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
text="Распознавание.....",
|
|
||||||
reply_to_message_id=message.reply_to_message.message_id
|
|
||||||
)
|
|
||||||
query = await recognize_voice(message.reply_to_message.voice.file_id)
|
|
||||||
if query:
|
|
||||||
await mes.edit_text(f'*{plain(message.reply_to_message.from_user.first_name)} сказал(a)* "{md(query)}"')
|
|
||||||
else:
|
|
||||||
await mes.edit_text("Распознать сообщение не удалось!")
|
|
||||||
|
|
||||||
# СТАТИСТИКА ГРУППЫ
|
|
||||||
@router.message(Command('stat_group'))
|
|
||||||
async def stat_group(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
group_id = du.get_group_id(message.chat.id)
|
|
||||||
|
|
||||||
group = db.stat_group(group_id)
|
|
||||||
month = db.month_stat_group(group_id)
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
text=f'*| СТАТИСТИКА ГРУППЫ |*\n\n'
|
|
||||||
f'*>>* В целом сообщений *[ {message.message_id} ]*\n\n'
|
|
||||||
f'*- За всё время* / *За месяц -*\n'
|
|
||||||
f'*>>* Сообщений в базе *[ {group[0]} / {month[0]} ]*\n\n'
|
|
||||||
f'*>* Ответов *- [ {group[1]} / {month[1]} ]*\n'
|
|
||||||
f'*>* Команд *- [ {group[2]} / {month[2]} ]*\n'
|
|
||||||
f'*>* Ссылок *- [ {group[3]} / {month[3]} ]*\n'
|
|
||||||
f'*>* Стикеров *- [ {group[5]} / {month[5]} ]*\n'
|
|
||||||
f'*>* Медиа файлов *- [ {group[4]} / {month[4]} ]*\n'
|
|
||||||
f'*>* Голос/Кружочки *- [ {group[6]} / {month[6]} ]*'
|
|
||||||
)
|
|
||||||
|
|
||||||
# СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ
|
|
||||||
@router.message(Command('stat_user'))
|
|
||||||
async def stat_user(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
user_id, group_id = du.get_user_id(message.from_user.id), du.get_group_id(message.chat.id)
|
|
||||||
|
|
||||||
group = dg.stat_user(user_id, group_id)
|
|
||||||
month = dm.stat_user(user_id, group_id)
|
|
||||||
|
|
||||||
namer = "".join(re.sub(r'[^\w\s]', '', message.from_user.first_name).split())
|
|
||||||
name = morph.parse(namer)[0].inflect({"gent"})
|
|
||||||
|
|
||||||
if name is None:
|
|
||||||
name = namer
|
|
||||||
else:
|
|
||||||
name = name.word
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
text=f'*| СТАТИСТИКА {plain(name.upper())} |*\n\n'
|
|
||||||
f'*- За всё время* / *За месяц -*\n'
|
|
||||||
f'*>>* Сообщений в базе *[ {group[0]} / {month[0]} ]*\n\n'
|
|
||||||
f'*>* Ответов *- [ {group[1]} / {month[1]} ]*\n'
|
|
||||||
f'*>* Команд *- [ {group[2]} / {month[2]} ]*\n'
|
|
||||||
f'*>* Ссылок *- [ {group[3]} / {month[3]} ]*\n'
|
|
||||||
f'*>* Стикеров *- [ {group[5]} / {month[5]} ]*\n'
|
|
||||||
f'*>* Медиа файлов *- [ {group[4]} / {month[4]} ]*\n'
|
|
||||||
f'*>* Голос/Кружочки *- [ {group[6]} / {month[6]} ]*'
|
|
||||||
)
|
|
||||||
|
|
||||||
# РЕДАКТИРОВАНИЕ ИМЕНИ
|
|
||||||
@router.message(Command('edit'))
|
|
||||||
async def update(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
user_id, group_id = du.get_user_id(message.from_user.id), du.get_group_id(message.chat.id)
|
|
||||||
|
|
||||||
text_edit = re.sub(r'[^\w\s]', '', message.text.lower()).split()
|
|
||||||
if len(text_edit) == 2:
|
|
||||||
name = morph.parse(text_edit[1])[0]
|
|
||||||
|
|
||||||
if name is None:
|
|
||||||
name = text_edit[1]
|
|
||||||
else:
|
|
||||||
name = name.normal_form
|
|
||||||
|
|
||||||
if name not in list(map(lambda x: x[0], dg.all_names(group_id))):
|
|
||||||
if not db.edit_user_exists(user_id):
|
|
||||||
db.add_edit_user(user_id)
|
|
||||||
|
|
||||||
dg.update_name(user_id, group_id, name)
|
|
||||||
await message.reply(f'{md(name.title())}, ваше имя было успешно изменено)')
|
|
||||||
else:
|
|
||||||
await message.reply(f'{md(message.from_user.first_name.lower().title())}, такое имя уже присутствует в чате!')
|
|
||||||
else:
|
|
||||||
await message.reply('Вы не правильно ввели имя! Имя должно быть '
|
|
||||||
'из одного слова и идти сразу после команды!')
|
|
||||||
|
|
||||||
# ВОЗВРАТ К ДИНАМИЧЕСКОМУ ИМЕНИ
|
|
||||||
@router.message(Command('back_edit'))
|
|
||||||
async def update_return(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
user_id, group_id = du.get_user_id(message.from_user.id), du.get_group_id(message.chat.id)
|
|
||||||
name = re.sub(r'[^\w\s]', '', message.from_user.first_name.lower())
|
|
||||||
|
|
||||||
if db.edit_user_exists(user_id):
|
|
||||||
db.del_edit_user(user_id)
|
|
||||||
dg.update_name(user_id, group_id, name)
|
|
||||||
|
|
||||||
await message.reply(f'{md(name.title())}, вы успешно вернулись к динамическому изменению имени)')
|
|
||||||
else:
|
|
||||||
await message.reply(f'{md(name.title())}, вы не устанавливали постоянное имя!')
|
|
||||||
|
|
||||||
# ВКЛЮЧЕНИЕ ТЕКСТОВЫХ ИВЕНТОВ
|
|
||||||
@router.message(Command('start_bot'))
|
|
||||||
async def opening(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
|
|
||||||
group_id = du.get_group_id(message.chat.id)
|
|
||||||
if db.check_status(group_id):
|
|
||||||
await message.answer("У вас уже включены текстовые ивенты!")
|
|
||||||
else:
|
|
||||||
db.update_status(group_id)
|
|
||||||
await message.answer("Текстовые ивенты включены!")
|
|
||||||
|
|
||||||
# ОТКЛЮЧЕНИЕ ТЕКСТОВЫХ ИВЕНТОВ
|
|
||||||
@router.message(Command('stop_bot'))
|
|
||||||
async def closing(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 3, message.from_user.first_name, True)
|
|
||||||
|
|
||||||
group_id = du.get_group_id(message.chat.id)
|
|
||||||
if db.check_status(group_id):
|
|
||||||
db.update_status(group_id)
|
|
||||||
await message.answer("Текстовые ивенты отключены!")
|
|
||||||
else:
|
|
||||||
await message.answer("У вас уже отключены текстовые ивенты!")
|
|
||||||
|
|
||||||
# УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЕЙ АВТОМАТИЧЕСКИ
|
|
||||||
@router.message(F.content_type == ContentType.LEFT_CHAT_MEMBER)
|
|
||||||
async def delete(message: Message):
|
|
||||||
if du.user_exists(message.left_chat_member.id):
|
|
||||||
user_id, group_id = du.get_user_id(message.left_chat_member.id), du.get_group_id(message.chat.id)
|
|
||||||
|
|
||||||
dg.del_user(group_id, user_id)
|
|
||||||
if db.group_exists_month(group_id):
|
|
||||||
if dm.user_exists(user_id, group_id):
|
|
||||||
dm.del_user(group_id, user_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await message.delete()
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
# УДАЛЕНИЕ ТЕХНИЧЕСКИХ СООБЩЕНИЙ
|
|
||||||
@router.message(F.content_type.in_([
|
|
||||||
ContentType.NEW_CHAT_TITLE,
|
|
||||||
ContentType.NEW_CHAT_PHOTO,
|
|
||||||
ContentType.PINNED_MESSAGE,
|
|
||||||
ContentType.VIDEO_CHAT_ENDED,
|
|
||||||
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED
|
|
||||||
]))
|
|
||||||
async def chat_events(message: Message):
|
|
||||||
try:
|
|
||||||
await message.delete()
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
# МЕДИА ФАЙЛЫ
|
|
||||||
@router.message(F.content_type.in_([
|
|
||||||
ContentType.LOCATION,
|
|
||||||
ContentType.CONTACT,
|
|
||||||
ContentType.VIDEO,
|
|
||||||
ContentType.PHOTO,
|
|
||||||
ContentType.AUDIO,
|
|
||||||
ContentType.DOCUMENT
|
|
||||||
]))
|
|
||||||
async def media(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 5, message.from_user.first_name, True)
|
|
||||||
|
|
||||||
# ГОЛОСОВЫЕ СООБЩЕНИЯ И КРУЖОЧКИ
|
|
||||||
@router.message(F.content_type.in_([ContentType.VOICE, ContentType.VIDEO_NOTE]))
|
|
||||||
async def voice(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 7, message.from_user.first_name, True)
|
|
||||||
|
|
||||||
# упоминание по имени, произнесённому в голосовом
|
|
||||||
if message.voice and message.chat.id < 0 and message.voice.duration <= 60:
|
|
||||||
query = await recognize_voice(message.voice.file_id)
|
|
||||||
if not query:
|
|
||||||
return
|
|
||||||
|
|
||||||
group_id = du.get_group_id(message.chat.id)
|
|
||||||
unsigned = re.sub(r'[^\w\s]', '', query.lower()).split()
|
|
||||||
first_form = [morph.parse(i)[0].normal_form for i in unsigned]
|
|
||||||
chat_names = [x[0] for x in dg.all_names(group_id)]
|
|
||||||
names = [_ for _ in first_form if _ in chat_names]
|
|
||||||
|
|
||||||
text = notice(names, False, group_id, message.from_user.id) if names else None
|
|
||||||
if text:
|
|
||||||
await message.reply(text)
|
|
||||||
|
|
||||||
# СТИКЕРЫ
|
|
||||||
@router.message(F.content_type == ContentType.STICKER)
|
|
||||||
async def stick(message: Message):
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(message.from_user.id, message.chat.id, 6, message.from_user.first_name, True)
|
|
||||||
|
|
||||||
# ТЕКСТОВЫЕ ИВЕНТЫ
|
|
||||||
@router.message(F.content_type == ContentType.TEXT)
|
|
||||||
async def send_events(message: Message):
|
|
||||||
group_id, user_id, name = message.chat.id, message.from_user.id, message.from_user.first_name
|
|
||||||
|
|
||||||
# ОБНОВЛЕНИЕ СТАТИСТИКИ
|
|
||||||
if message.chat.id < 0:
|
|
||||||
upd_stat(user_id, group_id, 1, name) # СООБЩЕНИЙ
|
|
||||||
if len(message.text) > 1 and message.text[0] == '/':
|
|
||||||
upd_stat(user_id, group_id, 3, name) # КОМАНД
|
|
||||||
if message.reply_to_message:
|
|
||||||
upd_stat(user_id, group_id, 2, name) # ОТВЕТОВ НА СООБЩЕНИЯ
|
|
||||||
|
|
||||||
# ПЕРЕМЕННЫЕ
|
|
||||||
low_mes = message.text.lower()
|
|
||||||
words = message.text.split()
|
|
||||||
unsigned = re.sub(r'[^\w\s]', '', low_mes).split()
|
|
||||||
first_form = [morph.parse(i)[0].normal_form for i in unsigned]
|
|
||||||
|
|
||||||
if message.chat.id < 0:
|
|
||||||
names = [_ for _ in first_form if _ in list(map(lambda x: x[0], dg.all_names(du.get_group_id(group_id))))]
|
|
||||||
|
|
||||||
# сообщение только из знаков препинания: дальше нужны слова (раньше — IndexError на unsigned[0])
|
|
||||||
if len(words) >= 2 and unsigned:
|
|
||||||
# ПОЛЕЗНЫЕ ФУНКЦИИ
|
|
||||||
if len(words) == 5 and "число от" in low_mes:
|
|
||||||
try:
|
|
||||||
num1, num2 = int(low_mes.split()[2]), int(low_mes.split()[4])
|
|
||||||
await message.answer(
|
|
||||||
text=f"Число *[ {random.randint(num1, num2)} ]*"
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
if unsigned[0] == 'переведи':
|
|
||||||
if "переведи - " in low_mes:
|
|
||||||
await message.answer(md(lang_form([words[_] for _ in range(len(words)) if _ > 1])))
|
|
||||||
elif len(unsigned) > 1 and f"переведи ({unsigned[1]}) - " in low_mes:
|
|
||||||
if len(unsigned[1]) == 1:
|
|
||||||
await message.answer(md(lang_form([words[_] for _ in range(len(words)) if _ > 2], unsigned[1])))
|
|
||||||
return
|
|
||||||
|
|
||||||
if unsigned[0] == 'переверни':
|
|
||||||
if "переверни - " in low_mes:
|
|
||||||
await message.answer(md(revers(message.text[12:], True)))
|
|
||||||
elif "переверни полностью - " in low_mes:
|
|
||||||
await message.answer(md(revers(message.text[22:], False)))
|
|
||||||
return
|
|
||||||
|
|
||||||
if unsigned[0] == 'озвучь' and "озвучь - " in low_mes:
|
|
||||||
text_to_voice = message.text[9:]
|
|
||||||
# синтез — в отдельном потоке и во временный файл (раньше общий voice.ogg:
|
|
||||||
# одновременные запросы перезаписывали друг друга)
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = os.path.join(tmp, "voice.mp3")
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(gTTS(text_to_voice, lang='ru').save, path)
|
|
||||||
except Exception:
|
|
||||||
logging.exception("gTTS")
|
|
||||||
await message.reply("Озвучить не получилось, попробуйте позже")
|
|
||||||
return
|
|
||||||
await bot.send_voice(chat_id=group_id, voice=FSInputFile(path), caption=f"*{plain(text_to_voice)}*")
|
|
||||||
return
|
|
||||||
|
|
||||||
# РАНДОМ ИВЕНТЫ
|
|
||||||
if "подраться" == words[0].lower() and "с" == words[1].lower():
|
|
||||||
text = " ".join([words[i] for i in range(len(words)) if i > 1])
|
|
||||||
|
|
||||||
if random.randint(0, 1) == 0:
|
|
||||||
await message.answer_photo(
|
|
||||||
photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"),
|
|
||||||
caption=f"{md(message.from_user.first_name)}, ты был унижен {md(text.title())}"
|
|
||||||
f", с помощью {base.VAR_LOSE[random.randint(0, 3)]}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await message.answer_photo(
|
|
||||||
photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"),
|
|
||||||
caption=f"{md(message.from_user.first_name)}, ты победил в драке "
|
|
||||||
f"с {md(text.title())}, {base.VAR_WIN[random.randint(0, 1)]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ИВЕНТ ВЗАИМОДЕЙСТВИЯ
|
|
||||||
for word in base.TMOK_LIST:
|
|
||||||
if word in low_mes:
|
|
||||||
lst = [words[i] for i in range(len(words)) if i != 0]
|
|
||||||
text = " ".join(lst)
|
|
||||||
|
|
||||||
await message.answer_photo(
|
|
||||||
photo=FSInputFile(f"../data/tmok/({random.randint(1, 4)}).jpg"),
|
|
||||||
caption=f"{md(message.from_user.first_name)} "
|
|
||||||
f"{inflect_past(words[0])} {md(text)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for word in base.KILL_LIST:
|
|
||||||
if word in low_mes:
|
|
||||||
lst = [words[i] for i in range(len(words)) if i != 0]
|
|
||||||
text = " ".join(lst)
|
|
||||||
|
|
||||||
await message.answer_photo(
|
|
||||||
photo=FSInputFile(f"../data/kill/({random.randint(1, 6)}).jpg"),
|
|
||||||
caption=f"{md(message.from_user.first_name)} "
|
|
||||||
f"{inflect_past(words[0])} {md(text)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (len(unsigned) >= 3 and
|
|
||||||
unsigned[0] in base.QUAT_LIST[0] and
|
|
||||||
unsigned[1] in base.QUAT_LIST[1] and
|
|
||||||
unsigned[2] in base.QUAT_LIST[2]):
|
|
||||||
try:
|
|
||||||
# без таймаута и в обработчике запрос мог надолго заморозить бота
|
|
||||||
resp = await asyncio.to_thread(requests.get, 'http://fucking-great-advice.ru/api/random', timeout=5)
|
|
||||||
await message.reply(md(resp.json()["text"]))
|
|
||||||
except Exception:
|
|
||||||
logging.warning("Сервис советов недоступен")
|
|
||||||
return
|
|
||||||
|
|
||||||
if message.chat.id < 0:
|
|
||||||
# УПОМИНАНИЯ ПО ИМЕНАМ
|
|
||||||
if names:
|
|
||||||
text = notice(names, False, du.get_group_id(group_id), user_id)
|
|
||||||
if text:
|
|
||||||
await message.reply(text)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ПЕРЕВОДЧИК СЛОВ
|
|
||||||
if (checker([i for i in low_mes], words, group_id, user_id, name.lower()) == len(low_mes) and
|
|
||||||
not any([engl_dict.check(i) for i in unsigned if len(i) > 1])):
|
|
||||||
await message.reply(
|
|
||||||
# раньше tg://user_id?id= — такой ссылки нет, упоминание не работало
|
|
||||||
f"[{plain(message.from_user.first_name)}](tg://user?id={user_id}) *>* {md(translator(words))}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
4
code/handlers/__init__.py
Normal file
4
code/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import chat, events, help, mentions, settings, stats, voice
|
||||||
|
|
||||||
|
# порядок важен: команды раньше общего обработчика текста
|
||||||
|
routers = [chat.router, help.router, settings.router, stats.router, mentions.router, voice.router, events.router]
|
||||||
126
code/handlers/chat.py
Normal file
126
code/handlers/chat.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Жизнь бота в чатах: добавили/исключили/поменяли права, служебные сообщения, вход и выход участников"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Bot, F, Router
|
||||||
|
from aiogram.enums import ContentType
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.types import ChatMemberUpdated, Message
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
WELCOME = ('<b>Привет группа {title}!</b>\n\nВсе функции вы можете узнать по команде /help! Для того, чтобы '
|
||||||
|
'уведомления по имени и команда /all нормально функционировали, необходимо чтобы каждый участник '
|
||||||
|
'группы написал хотя бы одно сообщение в чат! Также для того, чтобы системные сообщения удалялись '
|
||||||
|
'автоматически, необходимо боту выдать права администратора!')
|
||||||
|
|
||||||
|
|
||||||
|
def rights_of(member):
|
||||||
|
"""Права бота из ChatMember: все поля can_* (у админа — что он может, у ограниченного — что ему можно)"""
|
||||||
|
data = member.model_dump()
|
||||||
|
return {k: v for k, v in data.items() if k.startswith("can_") and isinstance(v, bool)} or None
|
||||||
|
|
||||||
|
|
||||||
|
# СТАТУС БОТА: событие Telegram при любом изменении (добавили, повысили, ограничили, исключили)
|
||||||
|
@router.my_chat_member()
|
||||||
|
async def bot_status_changed(update: ChatMemberUpdated, bot: Bot):
|
||||||
|
if update.chat.type == "private":
|
||||||
|
return
|
||||||
|
member = update.new_chat_member
|
||||||
|
count = None
|
||||||
|
if member.status in db.IN_CHAT:
|
||||||
|
try:
|
||||||
|
count = await bot.get_chat_member_count(update.chat.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
changed = await db.set_bot_status(update.chat.id, member.status, rights_of(member), "update",
|
||||||
|
member_count=count, chat_type=update.chat.type, title=update.chat.title)
|
||||||
|
if changed:
|
||||||
|
logging.info("Бот в чате %s (%s): %s", update.chat.id, update.chat.title, member.status)
|
||||||
|
|
||||||
|
# бота только что добавили — приветствие
|
||||||
|
old = update.old_chat_member.status
|
||||||
|
if member.status in ("member", "administrator") and old in ("left", "kicked"):
|
||||||
|
try:
|
||||||
|
await bot.send_message(update.chat.id, WELCOME.format(title=update.chat.title or ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def check_chat(bot: Bot, chat_id):
|
||||||
|
"""Сверка статуса бота с Telegram — ловит изменения, пока бот был выключен"""
|
||||||
|
try:
|
||||||
|
chat = await bot.get_chat(chat_id)
|
||||||
|
member = await bot.get_chat_member(chat_id, bot.id)
|
||||||
|
count = await bot.get_chat_member_count(chat_id) if member.status in db.IN_CHAT else None
|
||||||
|
await db.set_bot_status(chat_id, member.status, rights_of(member), "check",
|
||||||
|
member_count=count, chat_type=chat.type, title=chat.title)
|
||||||
|
except TelegramForbiddenError:
|
||||||
|
# исключён или чат удалён: данные не трогаем, фиксируем, что бота там нет
|
||||||
|
await db.set_bot_status(chat_id, "kicked", None, "check")
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "chat not found" in str(e).lower() or "upgraded to a supergroup" in str(e).lower():
|
||||||
|
await db.set_bot_status(chat_id, "left", None, "check")
|
||||||
|
else:
|
||||||
|
logging.warning("Сверка чата %s: %s", chat_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_all(bot: Bot):
|
||||||
|
for chat_id in await db.chats_to_check():
|
||||||
|
await check_chat(bot, chat_id)
|
||||||
|
await asyncio.sleep(0.5) # без спешки — лимиты Telegram API
|
||||||
|
|
||||||
|
|
||||||
|
# НОВЫЕ УЧАСТНИКИ: служебное сообщение удаляется (если включено и есть права)
|
||||||
|
@router.message(F.content_type == ContentType.NEW_CHAT_MEMBERS)
|
||||||
|
async def new_members(message: Message):
|
||||||
|
await db.ensure_chat(message.chat.id, message.chat.title)
|
||||||
|
if any(u.id == message.bot.id for u in message.new_chat_members):
|
||||||
|
return # о самом боте — приветствие в bot_status_changed
|
||||||
|
if await db.get_setting(message.chat.id, "delete_service"):
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ГРУППА СТАЛА СУПЕРГРУППОЙ — новый id чата
|
||||||
|
@router.message(F.content_type.in_([ContentType.MIGRATE_TO_CHAT_ID, ContentType.MIGRATE_FROM_CHAT_ID]))
|
||||||
|
async def chat_migrated(message: Message):
|
||||||
|
if message.migrate_to_chat_id:
|
||||||
|
await db.migrate_chat(message.chat.id, message.migrate_to_chat_id)
|
||||||
|
elif message.migrate_from_chat_id:
|
||||||
|
await db.migrate_chat(message.migrate_from_chat_id, message.chat.id)
|
||||||
|
|
||||||
|
|
||||||
|
# УЧАСТНИК ВЫШЕЛ: статистика остаётся, участник больше не упоминается
|
||||||
|
@router.message(F.content_type == ContentType.LEFT_CHAT_MEMBER)
|
||||||
|
async def left_member(message: Message):
|
||||||
|
if message.left_chat_member.id == message.bot.id:
|
||||||
|
return
|
||||||
|
await db.member_left(message.chat.id, message.left_chat_member.id)
|
||||||
|
if await db.get_setting(message.chat.id, "delete_service"):
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ТЕХНИЧЕСКИЕ СООБЩЕНИЯ
|
||||||
|
@router.message(F.content_type.in_([
|
||||||
|
ContentType.NEW_CHAT_TITLE,
|
||||||
|
ContentType.NEW_CHAT_PHOTO,
|
||||||
|
ContentType.PINNED_MESSAGE,
|
||||||
|
ContentType.VIDEO_CHAT_ENDED,
|
||||||
|
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED
|
||||||
|
]))
|
||||||
|
async def chat_events(message: Message):
|
||||||
|
if message.new_chat_title:
|
||||||
|
await db.ensure_chat(message.chat.id, message.new_chat_title)
|
||||||
|
if await db.get_setting(message.chat.id, "delete_service"):
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
44
code/handlers/common.py
Normal file
44
code/handlers/common.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Общее для обработчиков: учёт статистики, права, размер чата"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
import config
|
||||||
|
import db
|
||||||
|
from nlp import clean_name
|
||||||
|
|
||||||
|
_size_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def in_group(message: Message):
|
||||||
|
return message.chat.id < 0
|
||||||
|
|
||||||
|
|
||||||
|
async def count(message: Message, *var_ids):
|
||||||
|
"""Учёт события в группе от автора сообщения"""
|
||||||
|
if in_group(message) and message.from_user and not message.from_user.is_bot:
|
||||||
|
await db.count(message.chat.id, message.from_user.id, var_ids, clean_name(message.from_user.first_name),
|
||||||
|
message.chat.title)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_admin(bot: Bot, chat_id, user_id):
|
||||||
|
member = await bot.get_chat_member(chat_id, user_id)
|
||||||
|
return member.status in ("creator", "administrator")
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_size(bot: Bot, chat_id):
|
||||||
|
"""Число участников чата по Telegram (кэш на 10 минут)"""
|
||||||
|
cached = _size_cache.get(chat_id)
|
||||||
|
if cached and time.monotonic() - cached[1] < 600:
|
||||||
|
return cached[0]
|
||||||
|
try:
|
||||||
|
size = await bot.get_chat_member_count(chat_id)
|
||||||
|
except Exception:
|
||||||
|
size = await db.member_count(chat_id)
|
||||||
|
_size_cache[chat_id] = (size, time.monotonic())
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
async def is_big(bot: Bot, chat_id):
|
||||||
|
return await chat_size(bot, chat_id) > config.BIG_CHAT
|
||||||
140
code/handlers/events.py
Normal file
140
code/handlers/events.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Текстовые сообщения: учёт статистики, ивенты, упоминания по имени, исправление раскладки; медиа и стикеры"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from aiogram import Bot, F, Router
|
||||||
|
from aiogram.enums import ContentType
|
||||||
|
from aiogram.types import FSInputFile, Message
|
||||||
|
|
||||||
|
import base
|
||||||
|
import db
|
||||||
|
from nlp import h, has_url, inflect_past, lang_form, mention, revers, translator, wrong_layout
|
||||||
|
|
||||||
|
from .common import count, in_group
|
||||||
|
from .mentions import mention_in_text
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.content_type.in_([
|
||||||
|
ContentType.LOCATION,
|
||||||
|
ContentType.CONTACT,
|
||||||
|
ContentType.VIDEO,
|
||||||
|
ContentType.PHOTO,
|
||||||
|
ContentType.AUDIO,
|
||||||
|
ContentType.DOCUMENT
|
||||||
|
]))
|
||||||
|
async def media(message: Message):
|
||||||
|
await count(message, 5, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.content_type == ContentType.STICKER)
|
||||||
|
async def stick(message: Message):
|
||||||
|
await count(message, 6, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_events(message: Message, bot: Bot, low_mes, words, unsigned):
|
||||||
|
"""Текстовые ивенты; True — если сообщение обработано ивентом"""
|
||||||
|
if len(words) == 5 and "число от" in low_mes:
|
||||||
|
try:
|
||||||
|
num1, num2 = int(words[2]), int(words[4])
|
||||||
|
await message.answer(f"Число <b>[ {random.randint(min(num1, num2), max(num1, num2))} ]</b>")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
if unsigned[0] == 'переведи':
|
||||||
|
if "переведи - " in low_mes:
|
||||||
|
await message.answer(h(lang_form(words[2:])))
|
||||||
|
elif len(unsigned) > 1 and f"переведи ({unsigned[1]}) - " in low_mes and len(unsigned[1]) == 1:
|
||||||
|
await message.answer(h(lang_form(words[3:], unsigned[1])))
|
||||||
|
return True
|
||||||
|
|
||||||
|
if unsigned[0] == 'переверни':
|
||||||
|
if "переверни - " in low_mes:
|
||||||
|
await message.answer(h(revers(message.text[12:], True)))
|
||||||
|
elif "переверни полностью - " in low_mes:
|
||||||
|
await message.answer(h(revers(message.text[22:], False)))
|
||||||
|
return True
|
||||||
|
|
||||||
|
if unsigned[0] == 'озвучь' and "озвучь - " in low_mes:
|
||||||
|
from gtts import gTTS
|
||||||
|
text_to_voice = message.text[9:]
|
||||||
|
# синтез — в отдельном потоке и во временный файл
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "voice.mp3")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(gTTS(text_to_voice, lang='ru').save, path)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("gTTS")
|
||||||
|
await message.reply("Озвучить не получилось, попробуйте позже")
|
||||||
|
return True
|
||||||
|
await message.answer_voice(voice=FSInputFile(path), caption=f"<b>{h(text_to_voice)}</b>")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if words[0].lower() == "подраться" and words[1].lower() == "с":
|
||||||
|
text = h(" ".join(words[2:]).title())
|
||||||
|
name = h(message.from_user.first_name)
|
||||||
|
if random.randint(0, 1) == 0:
|
||||||
|
caption = f"{name}, ты был унижен {text}, с помощью {random.choice(base.VAR_LOSE)}"
|
||||||
|
else:
|
||||||
|
caption = f"{name}, ты победил в драке с {text}, {random.choice(base.VAR_WIN)}"
|
||||||
|
await message.answer_photo(photo=FSInputFile(f"../data/fight/({random.randint(1, 8)}).jpg"), caption=caption)
|
||||||
|
|
||||||
|
for folder, verbs, pictures in (("tmok", base.TMOK_LIST, 4), ("kill", base.KILL_LIST, 6)):
|
||||||
|
if any(word in low_mes for word in verbs):
|
||||||
|
await message.answer_photo(
|
||||||
|
photo=FSInputFile(f"../data/{folder}/({random.randint(1, pictures)}).jpg"),
|
||||||
|
caption=f"{h(message.from_user.first_name)} {h(inflect_past(words[0]))} {h(' '.join(words[1:]))}")
|
||||||
|
|
||||||
|
if (len(unsigned) >= 3 and unsigned[0] in base.QUAT_LIST[0] and unsigned[1] in base.QUAT_LIST[1]
|
||||||
|
and unsigned[2] in base.QUAT_LIST[2]):
|
||||||
|
try:
|
||||||
|
resp = await asyncio.to_thread(requests.get, 'http://fucking-great-advice.ru/api/random', timeout=5)
|
||||||
|
await message.reply(h(resp.json()["text"]))
|
||||||
|
except Exception:
|
||||||
|
logging.warning("Сервис советов недоступен")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.content_type == ContentType.TEXT)
|
||||||
|
async def text_message(message: Message, bot: Bot):
|
||||||
|
text = message.text
|
||||||
|
low_mes = text.lower()
|
||||||
|
words = text.split()
|
||||||
|
unsigned = re.sub(r'[^\w\s]', '', low_mes).split()
|
||||||
|
|
||||||
|
# статистика: сообщение, команда, ответ, ссылка — одним запросом
|
||||||
|
if in_group(message):
|
||||||
|
events = [1]
|
||||||
|
if len(text) > 1 and text[0] == '/':
|
||||||
|
events.append(3)
|
||||||
|
if message.reply_to_message:
|
||||||
|
events.append(2)
|
||||||
|
if has_url(text):
|
||||||
|
events.append(4)
|
||||||
|
await count(message, *events)
|
||||||
|
|
||||||
|
# ивенты (в группах выключаются /stop_bot; раньше флаг нигде не проверялся)
|
||||||
|
if len(words) >= 2 and unsigned and (not in_group(message) or await db.get_setting(message.chat.id,
|
||||||
|
"events_enabled")):
|
||||||
|
if await run_events(message, bot, low_mes, words, unsigned):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = await mention_in_text(bot, message, text)
|
||||||
|
if reply:
|
||||||
|
await message.reply(reply)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not has_url(text) and wrong_layout(text):
|
||||||
|
await message.reply(f"{mention(message.from_user.id, message.from_user.first_name)} <b>></b> "
|
||||||
|
f"{h(translator(words))}")
|
||||||
73
code/handlers/help.py
Normal file
73
code/handlers/help.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
|
|
||||||
|
from .common import count, in_group
|
||||||
|
import db
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
ABOUT = ('<b>-</b> Этот АБОБОТ поможет вам приятно провести время в чате с различными командами, '
|
||||||
|
'ивентами и удобными функциями, которые облегчают использование чата)\n\n'
|
||||||
|
'<b>-</b> Также прошу если вам понравился бот, оставить отзыв о его использовании на '
|
||||||
|
'команду /report)')
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
@router.message(Command('help'))
|
||||||
|
async def helps(message: Message):
|
||||||
|
await db.ensure_user(message.from_user.id)
|
||||||
|
if in_group(message):
|
||||||
|
await count(message, 3, 1)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
|
||||||
|
InlineKeyboardButton(text="ИВЕНТЫ", callback_data="even")],
|
||||||
|
[InlineKeyboardButton(text="АВТОР", callback_data="auth"),
|
||||||
|
InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun")]
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
await message.answer_voice(voice=FSInputFile('../data/tts.ogg'), caption=ABOUT, reply_markup=keyboard)
|
||||||
|
except Exception:
|
||||||
|
await message.answer(ABOUT, reply_markup=keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "auth")
|
||||||
|
async def author(call: CallbackQuery):
|
||||||
|
await call.message.answer(
|
||||||
|
'<b>| АВТОР |</b>\n\n<b>>></b> Этот бот, как бы это не печально звучало, но одна из лучших'
|
||||||
|
' моих работ и если кого-нибудь у меня получится действительно достойный продукт,'
|
||||||
|
' вы сможете о нём узнать в моём телеграм канале <b>@programium</b>')
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "fun")
|
||||||
|
async def function(call: CallbackQuery):
|
||||||
|
await call.message.answer(
|
||||||
|
'<b>| ФУНКЦИИ |</b>\n\n<b>1.</b> Возможность перевода случайно написанного текста на '
|
||||||
|
'транслите\n<b>2.</b> Ведение обширной статистики сообщений\n<b>3.</b> Упоминание участника '
|
||||||
|
'при написании его имени в чате\n<b>4.</b> Автоматическое удаление системных сообщений')
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "com")
|
||||||
|
async def commands(call: CallbackQuery):
|
||||||
|
await call.message.answer(
|
||||||
|
'<b>| КОМАНДЫ |</b>\n\n<b>/all</b> - упомянуть всех в чате\n<b>/help</b> - полный список функций\n'
|
||||||
|
'<b>/recognize</b> - транскрипция отмеченного голосового сообщения в текст\n'
|
||||||
|
'<b>/stat_group</b> - полная статистика группы\n<b>/stat_user</b> - полная статистика '
|
||||||
|
'отправителя\n<b>/top</b> - самые активные участники (<b>/top all</b> - за всё время)\n'
|
||||||
|
'<b>/month</b> - итоги месяца (<b>/month 2026-09</b> - за любой месяц)\n'
|
||||||
|
'<b>/edit и /back_edit</b> - первая команда даёт возможность '
|
||||||
|
'сменить имя для упоминаний на любое слово, а вторая для возврата динамического '
|
||||||
|
'имени\n<b>/settings</b> - настройки бота в чате\n'
|
||||||
|
'<b>/start_bot и /stop_bot</b> - возможность отключения текстовых ивентов')
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "even")
|
||||||
|
async def events(call: CallbackQuery):
|
||||||
|
await call.message.answer(
|
||||||
|
'<b>| ИВЕНТЫ |</b>\n\n<b>"Чмокнуть"</b> - сделать кому-нибудь приятно\n<b>"Отмудохать"</b> - '
|
||||||
|
'выместить злость на кого-нибудь\n<b>"Число от ... до ..."</b> - случайное значение из '
|
||||||
|
'диапозона\n<b>"Подраться с ..."</b> - повод кого-нибудь побить\n<b>"Переведи (..) - ..."</b> '
|
||||||
|
'- перевод слова на керпичный язык\n<b>"Дай блять совет!"</b> - даёт рандомный охуенный '
|
||||||
|
'совет\n<b>"Переверни - ..."</b> - переворачивает слова в предложении\n<b>"Озвучь - ..."</b> '
|
||||||
|
'- озвучивает написанный текст')
|
||||||
123
code/handlers/mentions.py
Normal file
123
code/handlers/mentions.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Упоминания: /all, упоминание по имени, своё имя для упоминаний"""
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from aiogram import Bot, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
import config
|
||||||
|
import db
|
||||||
|
from nlp import clean_name, find_names, h, join_names, mention, morph
|
||||||
|
|
||||||
|
from .common import count, in_group, is_admin, is_big
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
TEXT_LIMIT = 4096
|
||||||
|
_last_mention = {} # (чат, участник) → время последнего упоминания по имени
|
||||||
|
|
||||||
|
|
||||||
|
def chunks(parts, suffix, limit=TEXT_LIMIT):
|
||||||
|
"""Список упоминаний, разбитый на сообщения не длиннее лимита Telegram"""
|
||||||
|
messages, current = [], []
|
||||||
|
for part in parts:
|
||||||
|
candidate = join_names(current + [part]) + suffix
|
||||||
|
if current and len(candidate) > limit:
|
||||||
|
messages.append(join_names(current))
|
||||||
|
current = []
|
||||||
|
current.append(part)
|
||||||
|
messages.append(join_names(current) + suffix)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def mention_names(bot: Bot, message: Message, found):
|
||||||
|
"""Текст упоминания найденных в сообщении имён; в большом чате одного человека — не чаще раза в минуту"""
|
||||||
|
chat_id, author = message.chat.id, message.from_user.id
|
||||||
|
names = await db.chat_names(chat_id)
|
||||||
|
targets = [(names[n], n) for n in found if n in names and names[n] != author]
|
||||||
|
if not targets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if await is_big(bot, chat_id):
|
||||||
|
now = time.monotonic()
|
||||||
|
targets = [(uid, n) for uid, n in targets
|
||||||
|
if now - _last_mention.get((chat_id, uid), 0) >= config.MENTION_COOLDOWN]
|
||||||
|
for uid, _ in targets:
|
||||||
|
_last_mention[(chat_id, uid)] = now
|
||||||
|
if not targets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
links = [mention(uid, n) for uid, n in targets]
|
||||||
|
return f"{links[0]}, тебя упомянули)" if len(links) == 1 else f"{join_names(links)} вас упомянули)"
|
||||||
|
|
||||||
|
|
||||||
|
async def mention_in_text(bot: Bot, message: Message, text):
|
||||||
|
if not await db.get_setting(message.chat.id, "mentions_enabled"):
|
||||||
|
return None
|
||||||
|
found = find_names(text, set((await db.chat_names(message.chat.id)).keys()))
|
||||||
|
return await mention_names(bot, message, found) if found else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('all'))
|
||||||
|
async def every(message: Message, bot: Bot):
|
||||||
|
if not in_group(message):
|
||||||
|
await message.reply('Эта команда предназначена для вызова в чате!')
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
chat_id = message.chat.id
|
||||||
|
big = await is_big(bot, chat_id)
|
||||||
|
|
||||||
|
if big:
|
||||||
|
# в большом чате — только админы, не чаще раза в 5 минут и только недавно писавшие
|
||||||
|
if not await is_admin(bot, chat_id, message.from_user.id):
|
||||||
|
await message.reply(f'В чатах больше {config.BIG_CHAT} участников /all доступна только администраторам!')
|
||||||
|
return
|
||||||
|
last = await db.last_all(chat_id)
|
||||||
|
if last and (datetime.now(timezone.utc) - last).total_seconds() < config.ALL_COOLDOWN:
|
||||||
|
await message.reply(f'В больших чатах /all можно вызывать раз в {config.ALL_COOLDOWN // 60} минут!')
|
||||||
|
return
|
||||||
|
members = await db.active_members(chat_id, config.ALL_ACTIVE_DAYS)
|
||||||
|
else:
|
||||||
|
members = await db.active_members(chat_id)
|
||||||
|
|
||||||
|
others = [(uid, name) for uid, name in members if uid != message.from_user.id]
|
||||||
|
if len(others) < 2:
|
||||||
|
await message.reply('В группе состоит менее 3х человек, из-за чего команда не работает!')
|
||||||
|
return
|
||||||
|
|
||||||
|
author = await db.member_name(chat_id, message.from_user.id) or clean_name(message.from_user.first_name)
|
||||||
|
parts = chunks([mention(uid, name) for uid, name in others], f" вас вызывает {h(author.title())}")
|
||||||
|
await message.reply(parts[0])
|
||||||
|
for part in parts[1:]:
|
||||||
|
await message.answer(part)
|
||||||
|
await db.mark_all(chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('edit'))
|
||||||
|
async def edit_name(message: Message):
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
words = clean_name(message.text).split()
|
||||||
|
if len(words) != 2:
|
||||||
|
await message.reply('Вы не правильно ввели имя! Имя должно быть из одного слова и идти сразу после команды!')
|
||||||
|
return
|
||||||
|
|
||||||
|
name = morph.parse(words[1])[0].normal_form or words[1]
|
||||||
|
if name in await db.chat_names(message.chat.id):
|
||||||
|
await message.reply(f'{h(message.from_user.first_name.title())}, такое имя уже присутствует в чате!')
|
||||||
|
return
|
||||||
|
await db.set_custom_name(message.chat.id, message.from_user.id, name)
|
||||||
|
await message.reply(f'{h(name.title())}, ваше имя было успешно изменено)')
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('back_edit'))
|
||||||
|
async def back_edit(message: Message):
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
name = clean_name(message.from_user.first_name)
|
||||||
|
if await db.reset_custom_name(message.chat.id, message.from_user.id, name):
|
||||||
|
await message.reply(f'{h(name.title())}, вы успешно вернулись к динамическому изменению имени)')
|
||||||
|
else:
|
||||||
|
await message.reply(f'{h(name.title())}, вы не устанавливали постоянное имя!')
|
||||||
70
code/handlers/settings.py
Normal file
70
code/handlers/settings.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Настройки бота в чате: кнопки-переключатели, менять могут администраторы"""
|
||||||
|
from aiogram import Bot, F, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from .common import count, in_group, is_admin
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
"mentions_enabled": "Упоминания по имени",
|
||||||
|
"voice_names": "Имена в голосовых (через Google)",
|
||||||
|
"events_enabled": "Текстовые ивенты",
|
||||||
|
"delete_service": "Удаление служебных сообщений",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def keyboard(chat_id):
|
||||||
|
current = await db.settings(chat_id)
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text=f"{'✅' if current[key] else '❌'} {label}", callback_data=f"set:{key}")]
|
||||||
|
for key, label in LABELS.items()
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('settings'))
|
||||||
|
async def show_settings(message: Message):
|
||||||
|
if not in_group(message):
|
||||||
|
await message.reply('Настройки есть только у групп — вызовите команду в чате!')
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
await message.answer('<b>| НАСТРОЙКИ ЧАТА |</b>\n\nНажмите, чтобы включить или выключить. Менять настройки '
|
||||||
|
'могут администраторы.', reply_markup=await keyboard(message.chat.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("set:"))
|
||||||
|
async def toggle(call: CallbackQuery, bot: Bot):
|
||||||
|
key = call.data[4:]
|
||||||
|
if key not in LABELS:
|
||||||
|
return
|
||||||
|
if not await is_admin(bot, call.message.chat.id, call.from_user.id):
|
||||||
|
await call.answer("Настройки могут менять только администраторы", show_alert=True)
|
||||||
|
return
|
||||||
|
value = await db.toggle_setting(call.message.chat.id, key)
|
||||||
|
await call.message.edit_reply_markup(reply_markup=await keyboard(call.message.chat.id))
|
||||||
|
await call.answer(f"{LABELS[key]}: {'включено' if value else 'выключено'}")
|
||||||
|
|
||||||
|
|
||||||
|
async def set_events(message: Message, enable):
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
if await db.get_setting(message.chat.id, "events_enabled") == enable:
|
||||||
|
await message.answer("У вас уже включены текстовые ивенты!" if enable
|
||||||
|
else "У вас уже отключены текстовые ивенты!")
|
||||||
|
return
|
||||||
|
await db.toggle_setting(message.chat.id, "events_enabled")
|
||||||
|
await message.answer("Текстовые ивенты включены!" if enable else "Текстовые ивенты отключены!")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('start_bot'))
|
||||||
|
async def opening(message: Message):
|
||||||
|
await set_events(message, True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('stop_bot'))
|
||||||
|
async def closing(message: Message):
|
||||||
|
await set_events(message, False)
|
||||||
117
code/handlers/stats.py
Normal file
117
code/handlers/stats.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Статистика чата и участников: за всё время, за месяц, топ и итоги месяца"""
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import Command, CommandObject
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
import db
|
||||||
|
from nlp import clean_name, genitive, h
|
||||||
|
|
||||||
|
from .common import count, in_group
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
MONTHS = ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь",
|
||||||
|
"ноябрь", "декабрь"]
|
||||||
|
|
||||||
|
|
||||||
|
def stat_lines(total, month):
|
||||||
|
return (f'<b>- За всё время</b> / <b>За месяц -</b>\n'
|
||||||
|
f'<b>>></b> Сообщений в базе <b>[ {total[0]} / {month[0]} ]</b>\n\n'
|
||||||
|
f'<b>></b> Ответов <b>- [ {total[1]} / {month[1]} ]</b>\n'
|
||||||
|
f'<b>></b> Команд <b>- [ {total[2]} / {month[2]} ]</b>\n'
|
||||||
|
f'<b>></b> Ссылок <b>- [ {total[3]} / {month[3]} ]</b>\n'
|
||||||
|
f'<b>></b> Стикеров <b>- [ {total[5]} / {month[5]} ]</b>\n'
|
||||||
|
f'<b>></b> Медиа файлов <b>- [ {total[4]} / {month[4]} ]</b>\n'
|
||||||
|
f'<b>></b> Голос/Кружочки <b>- [ {total[6]} / {month[6]} ]</b>')
|
||||||
|
|
||||||
|
|
||||||
|
def month_title(period):
|
||||||
|
year, month = period.split("-")
|
||||||
|
return f"{MONTHS[int(month) - 1]} {year}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_period(arg):
|
||||||
|
"""«2026-09», «прошлый» или ничего (текущий месяц)"""
|
||||||
|
today = date.today()
|
||||||
|
if not arg:
|
||||||
|
return db.month_key(today)
|
||||||
|
arg = arg.strip().lower()
|
||||||
|
if arg in ("прошлый", "prev", "last"):
|
||||||
|
first = today.replace(day=1)
|
||||||
|
return db.month_key(date(first.year - (first.month == 1), (first.month - 2) % 12 + 1, 1))
|
||||||
|
if re.fullmatch(r"\d{4}-\d{2}", arg) and 1 <= int(arg[5:]) <= 12:
|
||||||
|
return arg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('stat_group'))
|
||||||
|
async def stat_group(message: Message):
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
total = await db.chat_stats(message.chat.id, "all")
|
||||||
|
month = await db.chat_stats(message.chat.id, db.month_key())
|
||||||
|
await message.answer(f'<b>| СТАТИСТИКА ГРУППЫ |</b>\n\n'
|
||||||
|
f'<b>>></b> В целом сообщений <b>[ {message.message_id} ]</b>\n\n' + stat_lines(total, month))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('stat_user'))
|
||||||
|
async def stat_user(message: Message):
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
total = await db.member_stats(message.chat.id, message.from_user.id, "all")
|
||||||
|
month = await db.member_stats(message.chat.id, message.from_user.id, db.month_key())
|
||||||
|
name = genitive(clean_name(message.from_user.first_name).replace(" ", "")) or message.from_user.first_name
|
||||||
|
await message.answer(f'<b>| СТАТИСТИКА {h(name.upper())} |</b>\n\n' + stat_lines(total, month))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('top'))
|
||||||
|
async def top(message: Message, command: CommandObject):
|
||||||
|
"""Самые активные участники по сообщениям: за месяц или за всё время (/top all)"""
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
all_time = (command.args or "").strip().lower() in ("all", "всё", "все")
|
||||||
|
period = "all" if all_time else db.month_key()
|
||||||
|
rows = await db.top(message.chat.id, period, "mes", 10)
|
||||||
|
if not rows:
|
||||||
|
await message.answer("Статистики пока нет!")
|
||||||
|
return
|
||||||
|
title = "за всё время" if all_time else f"за {month_title(period)}"
|
||||||
|
# без ссылок-упоминаний: иначе каждый из топа получил бы уведомление
|
||||||
|
lines = [f"<b>{i}.</b> {h(name.title())} — <b>{value}</b>" for i, (_, name, value) in enumerate(rows, 1)]
|
||||||
|
await message.answer(f"<b>| ТОП УЧАСТНИКОВ {title.upper()} |</b>\n\n" + "\n".join(lines),
|
||||||
|
disable_notification=True)
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORIES = [("mes", "Больше всех писали"), ("rep", "Больше всех отвечали"), ("sti", "Короли стикеров"),
|
||||||
|
("voi", "Больше всех голосовых и кружочков"), ("med", "Больше всех медиа")]
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command('month'))
|
||||||
|
async def month_results(message: Message, command: CommandObject):
|
||||||
|
"""Итоги месяца — только по команде: топ-3 в каждой категории"""
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 3, 1)
|
||||||
|
period = parse_period(command.args)
|
||||||
|
if period is None:
|
||||||
|
await message.reply("Укажите месяц в формате <b>2026-09</b> или <b>прошлый</b>")
|
||||||
|
return
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
for field, title in CATEGORIES:
|
||||||
|
rows = await db.top(message.chat.id, period, field, 3)
|
||||||
|
if rows:
|
||||||
|
blocks.append(f"<b>{title}:</b>\n" + "\n".join(
|
||||||
|
f"{i}. {h(name.title())} — {value}" for i, (_, name, value) in enumerate(rows, 1)))
|
||||||
|
if not blocks:
|
||||||
|
await message.answer(f"За {month_title(period)} статистики нет!")
|
||||||
|
return
|
||||||
|
totals = await db.chat_stats(message.chat.id, period)
|
||||||
|
await message.answer(f"<b>| ИТОГИ: {month_title(period).upper()} |</b>\n\n"
|
||||||
|
f"Сообщений в чате: <b>{totals[0]}</b>\n\n" + "\n\n".join(blocks))
|
||||||
86
code/handlers/voice.py
Normal file
86
code/handlers/voice.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Голосовые: /recognize и поиск имён в голосовых (Google Web Speech, выключается в /settings)"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import soundfile as sf
|
||||||
|
import speech_recognition as sr
|
||||||
|
from aiogram import Bot, F, Router
|
||||||
|
from aiogram.enums import ContentType
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
import db
|
||||||
|
from nlp import find_names, h
|
||||||
|
|
||||||
|
from .common import count, in_group
|
||||||
|
from .mentions import mention_names
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
def recognize_file(oga_path):
|
||||||
|
"""Голосовое (OGG/Opus) → текст. Синхронно — вызывается в отдельном потоке"""
|
||||||
|
data, samplerate = sf.read(oga_path)
|
||||||
|
wav_path = oga_path[:-4] + ".wav"
|
||||||
|
sf.write(wav_path, data, samplerate)
|
||||||
|
|
||||||
|
r = sr.Recognizer()
|
||||||
|
with sr.AudioFile(wav_path) as source:
|
||||||
|
r.pause_threshold = 100
|
||||||
|
audio = r.listen(source)
|
||||||
|
return r.recognize_google(audio, language='ru-RU')
|
||||||
|
|
||||||
|
|
||||||
|
async def recognize_voice(bot: Bot, file_id):
|
||||||
|
"""Распознавание без блокировки бота, файлы — во временной папке на запрос"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
oga = os.path.join(tmp, "voice.oga")
|
||||||
|
file = await bot.get_file(file_id)
|
||||||
|
await bot.download_file(file.file_path, oga)
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(recognize_file, oga)
|
||||||
|
except sr.UnknownValueError: # речь не разобрана
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Ошибка распознавания голосового")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("recognize"))
|
||||||
|
async def recognise(message: Message, bot: Bot):
|
||||||
|
if in_group(message):
|
||||||
|
await count(message, 3, 1)
|
||||||
|
voice = message.reply_to_message.voice if message.reply_to_message else None
|
||||||
|
if not voice:
|
||||||
|
await message.reply("Ответьте этой командой на голосовое сообщение!")
|
||||||
|
return
|
||||||
|
|
||||||
|
mes = await message.reply_to_message.reply("Распознавание.....")
|
||||||
|
query = await recognize_voice(bot, voice.file_id)
|
||||||
|
if query:
|
||||||
|
await mes.edit_text(f'<b>{h(message.reply_to_message.from_user.first_name)} сказал(a)</b> "{h(query)}"')
|
||||||
|
else:
|
||||||
|
await mes.edit_text("Распознать сообщение не удалось!")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.content_type.in_([ContentType.VOICE, ContentType.VIDEO_NOTE]))
|
||||||
|
async def voice_message(message: Message, bot: Bot):
|
||||||
|
if not in_group(message):
|
||||||
|
return
|
||||||
|
await count(message, 7, 1)
|
||||||
|
|
||||||
|
# имена в голосовом: голос уходит в Google, поэтому в каждом чате можно выключить
|
||||||
|
if not (message.voice and message.voice.duration <= 60):
|
||||||
|
return
|
||||||
|
settings = await db.settings(message.chat.id)
|
||||||
|
if not (settings["voice_names"] and settings["mentions_enabled"]):
|
||||||
|
return
|
||||||
|
query = await recognize_voice(bot, message.voice.file_id)
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
found = find_names(query, set((await db.chat_names(message.chat.id)).keys()))
|
||||||
|
text = await mention_names(bot, message, found) if found else None
|
||||||
|
if text:
|
||||||
|
await message.reply(text)
|
||||||
26
code/init.py
26
code/init.py
|
|
@ -1,26 +0,0 @@
|
||||||
from aiogram import Bot, Dispatcher
|
|
||||||
from aiogram.enums.parse_mode import ParseMode
|
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
|
||||||
from aiogram.client.bot import DefaultBotProperties
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import base
|
|
||||||
import enchant
|
|
||||||
import pymorphy3
|
|
||||||
|
|
||||||
bot = Bot(token=base.TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN))
|
|
||||||
dp = Dispatcher(storage=MemoryStorage())
|
|
||||||
|
|
||||||
morph = pymorphy3.MorphAnalyzer()
|
|
||||||
engl_dict = enchant.Dict("en_US")
|
|
||||||
|
|
||||||
|
|
||||||
import sql
|
|
||||||
|
|
||||||
os.makedirs('../db', exist_ok=True)
|
|
||||||
db = sql.Base('../db/base.db')
|
|
||||||
du = sql.User('../db/users.db')
|
|
||||||
dg = sql.Group('../db/groups.db')
|
|
||||||
dm = sql.Month('../db/month.db')
|
|
||||||
146
code/nlp.py
Normal file
146
code/nlp.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Обработка текста: имена, неправильная раскладка, переворот, кирпичный язык"""
|
||||||
|
import re
|
||||||
|
from html import escape
|
||||||
|
from string import digits, punctuation
|
||||||
|
|
||||||
|
import enchant
|
||||||
|
import pymorphy3
|
||||||
|
|
||||||
|
from base import ALB, ERR, ERR_, TRU, TRU_
|
||||||
|
|
||||||
|
morph = pymorphy3.MorphAnalyzer()
|
||||||
|
engl_dict = enchant.Dict("en_US")
|
||||||
|
|
||||||
|
|
||||||
|
def h(text):
|
||||||
|
"""Экранирование для HTML-разметки сообщений"""
|
||||||
|
return escape(str(text), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def mention(user_id, name):
|
||||||
|
return f'<a href="tg://user?id={int(user_id)}">{h(str(name).title())}</a>'
|
||||||
|
|
||||||
|
|
||||||
|
def join_names(items):
|
||||||
|
"""«а», «а и б», «а, б и в»"""
|
||||||
|
if len(items) <= 1:
|
||||||
|
return "".join(items)
|
||||||
|
return ", ".join(items[:-1]) + " и " + items[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_name(name):
|
||||||
|
"""Имя участника для упоминаний: нижний регистр, без знаков препинания"""
|
||||||
|
return re.sub(r'[^\w\s]', '', (name or "").lower()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def words_normal(text):
|
||||||
|
"""Слова сообщения в начальной форме"""
|
||||||
|
return [morph.parse(w)[0].normal_form for w in re.sub(r'[^\w\s]', '', text.lower()).split()]
|
||||||
|
|
||||||
|
|
||||||
|
def find_names(text, names):
|
||||||
|
"""Имена участников, упомянутые в тексте (в любой форме), без повторов"""
|
||||||
|
found = []
|
||||||
|
for word in words_normal(text):
|
||||||
|
if word in names and word not in found:
|
||||||
|
found.append(word)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def inflect_past(word):
|
||||||
|
"""Глагол в прошедшем времени; если pymorphy3 не справился — как есть"""
|
||||||
|
form = morph.parse(word.lower())[0].inflect({'past', 'sing', 'indc'})
|
||||||
|
return form.word if form else word
|
||||||
|
|
||||||
|
|
||||||
|
def genitive(name):
|
||||||
|
form = morph.parse(name)[0].inflect({"gent"})
|
||||||
|
return form.word if form else name
|
||||||
|
|
||||||
|
|
||||||
|
def has_url(text):
|
||||||
|
return "http" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def wrong_layout(text):
|
||||||
|
"""Сообщение целиком набрано латиницей в русской раскладке (и это не английские слова)"""
|
||||||
|
if not text or text[0] == '/' or all(c in digits or c in punctuation or c.isspace() for c in text):
|
||||||
|
return False
|
||||||
|
if any(not c.isspace() and c not in ERR and c not in ERR_ and c not in digits for c in text):
|
||||||
|
return False
|
||||||
|
words = [w for w in re.sub(r'[^\w\s]', '', text.lower()).split() if len(w) > 1]
|
||||||
|
return not any(engl_dict.check(w) for w in words)
|
||||||
|
|
||||||
|
|
||||||
|
def lang_form(text, smbl='г'):
|
||||||
|
for i in range(len(text)):
|
||||||
|
word = [str(j) + str(smbl) + str(j).lower() if j in ALB else str(j) for j in text[i]]
|
||||||
|
text[i] = ''.join(word)
|
||||||
|
return ' '.join(text)
|
||||||
|
|
||||||
|
|
||||||
|
def translator(words):
|
||||||
|
itg = []
|
||||||
|
for word in words:
|
||||||
|
raw_word = []
|
||||||
|
for symbol in word:
|
||||||
|
if symbol in ERR:
|
||||||
|
count = 0
|
||||||
|
for _ in ERR:
|
||||||
|
if symbol == _:
|
||||||
|
raw_word.append(TRU[count])
|
||||||
|
break
|
||||||
|
count += 1
|
||||||
|
elif symbol in ERR_:
|
||||||
|
count = 0
|
||||||
|
for _ in ERR_:
|
||||||
|
if symbol == _:
|
||||||
|
raw_word.append(TRU_[count])
|
||||||
|
break
|
||||||
|
count += 1
|
||||||
|
itg.append("".join(raw_word))
|
||||||
|
return " ".join(itg)
|
||||||
|
|
||||||
|
|
||||||
|
def revers(message, var):
|
||||||
|
no_pct = re.sub(r'[^\w\s]', '', message)
|
||||||
|
|
||||||
|
if var:
|
||||||
|
sml, pnc, pct, prf = [i for i in message] + [str(0)], [], [], False
|
||||||
|
for i in sml:
|
||||||
|
if i in punctuation or i == ' ':
|
||||||
|
pnc.append(i)
|
||||||
|
prf, flag = True if sml.index(i) == 0 else False, False
|
||||||
|
else:
|
||||||
|
flag = True
|
||||||
|
|
||||||
|
if flag and len(pnc) > 0:
|
||||||
|
pct.append(''.join(pnc))
|
||||||
|
pnc = []
|
||||||
|
|
||||||
|
wrd, rev, txt = no_pct.split(), [], []
|
||||||
|
for i in wrd:
|
||||||
|
word, up, itg = [i[-1 - l].lower() for l in range(len(i))], [], []
|
||||||
|
|
||||||
|
for _ in i:
|
||||||
|
up.append(True if _ in TRU_ else False)
|
||||||
|
for j in range(len(up)):
|
||||||
|
itg.append(word[j].upper() if up[j] else word[j])
|
||||||
|
|
||||||
|
rev.append(''.join(itg))
|
||||||
|
|
||||||
|
if prf:
|
||||||
|
for i in range(len(pct)):
|
||||||
|
txt = (txt + [pct[i]]) if (i + 1) == len(pct) and len(pct) > len(rev) else (txt + [pct[i], rev[i]])
|
||||||
|
return ''.join(txt)
|
||||||
|
else:
|
||||||
|
for i in range(len(rev)):
|
||||||
|
if i > 0:
|
||||||
|
txt.append(pct[i - 1])
|
||||||
|
txt.append(rev[i])
|
||||||
|
if (i + 1) == len(rev) and len(pct) == len(rev):
|
||||||
|
txt.append(pct[i])
|
||||||
|
return ''.join(txt)
|
||||||
|
else:
|
||||||
|
sml = [i for i in message]
|
||||||
|
return ''.join([sml[-1 - i] for i in range(len(sml))])
|
||||||
73
code/schema.sql
Normal file
73
code/schema.sql
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
-- Схема AboBot в PostgreSQL. Применяется при каждом запуске (всё IF NOT EXISTS).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chats (
|
||||||
|
id BIGINT PRIMARY KEY, -- id чата в Telegram
|
||||||
|
short_id SERIAL UNIQUE, -- внутренний номер («Группа #N» в Metabase)
|
||||||
|
title TEXT,
|
||||||
|
events_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- текстовые ивенты (/start_bot, /stop_bot)
|
||||||
|
mentions_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- упоминания по имени в сообщениях
|
||||||
|
voice_names BOOLEAN NOT NULL DEFAULT TRUE, -- поиск имён в голосовых (уходят в Google)
|
||||||
|
delete_service BOOLEAN NOT NULL DEFAULT TRUE, -- удаление служебных сообщений
|
||||||
|
last_all_at TIMESTAMPTZ, -- последний /all (ограничение в больших чатах)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
-- бот в чате: статус Telegram (member, administrator, restricted, left, kicked) и права;
|
||||||
|
-- если бота исключили, данные чата остаются, меняется только статус
|
||||||
|
bot_status TEXT,
|
||||||
|
bot_rights JSONB,
|
||||||
|
bot_status_at TIMESTAMPTZ, -- когда статус изменился
|
||||||
|
checked_at TIMESTAMPTZ, -- когда статус последний раз сверялся с Telegram
|
||||||
|
member_count INTEGER, -- участников по Telegram на момент сверки
|
||||||
|
chat_type TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- история статуса бота в чатах: добавили, повысили до админа, ограничили, исключили
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_status_history (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
chat_id BIGINT NOT NULL REFERENCES chats (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
rights JSONB,
|
||||||
|
source TEXT NOT NULL, -- update (событие Telegram) или check (сверка)
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT PRIMARY KEY, -- id пользователя в Telegram
|
||||||
|
short_id SERIAL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
|
chat_id BIGINT NOT NULL REFERENCES chats (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT '', -- имя для упоминаний (в нижнем регистре)
|
||||||
|
custom_name BOOLEAN NOT NULL DEFAULT FALSE, -- задано командой /edit, не обновляется само
|
||||||
|
last_message_at TIMESTAMPTZ,
|
||||||
|
left_at TIMESTAMPTZ, -- вышел из чата (статистика сохраняется)
|
||||||
|
PRIMARY KEY (chat_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- период: 'all' — за всё время, 'YYYY-MM' — месяц; месяцы хранятся, а не обнуляются
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_stats (
|
||||||
|
chat_id BIGINT NOT NULL REFERENCES chats (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
mes INTEGER NOT NULL DEFAULT 0, rep INTEGER NOT NULL DEFAULT 0, com INTEGER NOT NULL DEFAULT 0,
|
||||||
|
url INTEGER NOT NULL DEFAULT 0, med INTEGER NOT NULL DEFAULT 0, sti INTEGER NOT NULL DEFAULT 0,
|
||||||
|
voi INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (chat_id, period)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS member_stats (
|
||||||
|
chat_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
mes INTEGER NOT NULL DEFAULT 0, rep INTEGER NOT NULL DEFAULT 0, com INTEGER NOT NULL DEFAULT 0,
|
||||||
|
url INTEGER NOT NULL DEFAULT 0, med INTEGER NOT NULL DEFAULT 0, sti INTEGER NOT NULL DEFAULT 0,
|
||||||
|
voi INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (chat_id, user_id, period),
|
||||||
|
FOREIGN KEY (chat_id, user_id) REFERENCES members (chat_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS member_stats_period ON member_stats (chat_id, period);
|
||||||
|
|
||||||
|
-- бот в чате сейчас (для статистики по действующим группам)
|
||||||
|
CREATE OR REPLACE VIEW active_chats AS
|
||||||
|
SELECT * FROM chats WHERE bot_status IN ('member', 'administrator', 'restricted');
|
||||||
189
code/script.py
189
code/script.py
|
|
@ -1,189 +0,0 @@
|
||||||
import re
|
|
||||||
from string import digits, punctuation
|
|
||||||
|
|
||||||
from base import ERR, ERR_, TRU, TRU_, ALB
|
|
||||||
# одни и те же соединения с базами, что и у обработчиков (раньше здесь открывались вторые)
|
|
||||||
from init import db, du, dg, dm
|
|
||||||
|
|
||||||
MD_SPECIAL = "_*`["
|
|
||||||
|
|
||||||
|
|
||||||
def md(text):
|
|
||||||
"""Экранирование для Markdown вне разметки: «_» или «*» в имени или тексте иначе ломают отправку"""
|
|
||||||
return "".join("\\" + ch if ch in MD_SPECIAL else ch for ch in str(text))
|
|
||||||
|
|
||||||
|
|
||||||
def plain(text):
|
|
||||||
"""Текст внутри *жирного* или [ссылки]: экранирование там не работает, спецсимволы убираются"""
|
|
||||||
return "".join(ch for ch in str(text) if ch not in MD_SPECIAL + "]")
|
|
||||||
|
|
||||||
|
|
||||||
def join_names(items):
|
|
||||||
"""«а», «а и б», «а, б и в»"""
|
|
||||||
if len(items) <= 1:
|
|
||||||
return "".join(items)
|
|
||||||
return ", ".join(items[:-1]) + " и " + items[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def upd_stat(user_id, group_id, var_id, name, mes=False):
|
|
||||||
name = re.sub(r'[^\w\s]', '', name.lower())
|
|
||||||
if not du.user_exists(user_id):
|
|
||||||
du.add_user(user_id)
|
|
||||||
user_id = du.get_user_id(user_id)
|
|
||||||
|
|
||||||
if not du.group_exists(group_id):
|
|
||||||
du.add_group(group_id)
|
|
||||||
group_id = du.get_group_id(group_id)
|
|
||||||
|
|
||||||
if not db.group_exists(group_id):
|
|
||||||
db.add_group(group_id)
|
|
||||||
dg.created_group(group_id)
|
|
||||||
|
|
||||||
if not dg.user_exists(user_id, group_id):
|
|
||||||
dg.add_user(group_id, user_id, name)
|
|
||||||
|
|
||||||
db.update_stat(group_id, var_id)
|
|
||||||
dg.update_stat(user_id, group_id, var_id)
|
|
||||||
|
|
||||||
if not db.group_exists_month(group_id):
|
|
||||||
db.add_group_month(group_id)
|
|
||||||
dm.created_group(group_id)
|
|
||||||
dm.add_user(group_id, user_id)
|
|
||||||
else:
|
|
||||||
if not dm.user_exists(user_id, group_id):
|
|
||||||
dm.add_user(group_id, user_id)
|
|
||||||
|
|
||||||
db.update_month_stat(group_id, var_id)
|
|
||||||
dm.update_stat(user_id, group_id, var_id)
|
|
||||||
|
|
||||||
if mes:
|
|
||||||
db.update_month_stat(group_id, 1)
|
|
||||||
dg.update_stat(user_id, group_id, 1)
|
|
||||||
dm.update_stat(user_id, group_id, 1)
|
|
||||||
db.update_stat(group_id, 1)
|
|
||||||
|
|
||||||
if not db.edit_user_exists(user_id):
|
|
||||||
dg.update_name(user_id, group_id, name)
|
|
||||||
|
|
||||||
|
|
||||||
def lang_form(text, smbl='г'):
|
|
||||||
for i in range(len(text)):
|
|
||||||
word = [str(j) + str(smbl) + str(j).lower() if j in ALB else str(j) for j in text[i]]
|
|
||||||
text[i] = ''.join(word)
|
|
||||||
return ' '.join(text)
|
|
||||||
|
|
||||||
|
|
||||||
def notice(name, all_users, group_id, author):
|
|
||||||
names = [i[0] for i in dg.all_names(group_id)]
|
|
||||||
ids = [du.get_first_user_id(i[0]) for i in dg.all_ids(group_id)]
|
|
||||||
|
|
||||||
if all_users:
|
|
||||||
name = dg.user_name(name, group_id)
|
|
||||||
usr = [f"[{plain(names[i].title())}](tg://user?id={str(ids[i])})" for i in range(len(names)) if names[i] != name.lower()]
|
|
||||||
if len(usr) < 2:
|
|
||||||
raise ValueError("в группе меньше трёх участников")
|
|
||||||
return f'{join_names(usr)} вас вызывает {md(name.title())}'
|
|
||||||
else:
|
|
||||||
no_copy = []
|
|
||||||
for i in name:
|
|
||||||
if i not in no_copy:
|
|
||||||
no_copy.append(i)
|
|
||||||
|
|
||||||
if len(no_copy) > 1:
|
|
||||||
# раньше при двух именах, одно из которых — автор, падало на usr[-2]
|
|
||||||
usr = [f"[{plain(_.title())}](tg://user?id={str(ids[names.index(_)])})" for _ in no_copy if int(author) != ids[names.index(_)]]
|
|
||||||
if len(usr) > 1:
|
|
||||||
return f"{join_names(usr)} вас упомянули)"
|
|
||||||
if usr:
|
|
||||||
return f"{usr[0]}, тебя упомянули)"
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
usr = f"[{plain(no_copy[0].title())}](tg://user?id={str(ids[names.index(no_copy[0])])})"
|
|
||||||
return f"{usr}, тебя упомянули)"
|
|
||||||
|
|
||||||
|
|
||||||
def checker(signs, words, group_id, user_id, name):
|
|
||||||
if len([z for z in signs if (z in digits) or (z in punctuation)]) == len(signs) or signs[0] == '/':
|
|
||||||
return int('-1')
|
|
||||||
|
|
||||||
if len(signs) > 4:
|
|
||||||
for x in range(3, len(signs)):
|
|
||||||
http = "".join([signs[x - 3], signs[x - 2], signs[x - 1], signs[x]])
|
|
||||||
if 'http' in http:
|
|
||||||
upd_stat(user_id, group_id, 4, name)
|
|
||||||
return int('-1')
|
|
||||||
|
|
||||||
count, raw = 0, 0
|
|
||||||
for word in words:
|
|
||||||
for symbol in word:
|
|
||||||
raw += 1
|
|
||||||
if (symbol in ERR) or (symbol in ERR_) or (symbol in digits):
|
|
||||||
count += 1
|
|
||||||
return len(signs) - raw + count
|
|
||||||
|
|
||||||
|
|
||||||
def translator(words):
|
|
||||||
itg = []
|
|
||||||
for word in words:
|
|
||||||
raw_word = []
|
|
||||||
for symbol in word:
|
|
||||||
if symbol in ERR:
|
|
||||||
count = 0
|
|
||||||
for _ in ERR:
|
|
||||||
if symbol == _:
|
|
||||||
raw_word.append(TRU[count])
|
|
||||||
break
|
|
||||||
count += 1
|
|
||||||
elif symbol in ERR_:
|
|
||||||
count = 0
|
|
||||||
for _ in ERR_:
|
|
||||||
if symbol == _:
|
|
||||||
raw_word.append(TRU_[count])
|
|
||||||
break
|
|
||||||
count += 1
|
|
||||||
itg.append("".join(raw_word))
|
|
||||||
return " ".join(itg)
|
|
||||||
|
|
||||||
|
|
||||||
def revers(message, var):
|
|
||||||
no_pct = re.sub(r'[^\w\s]', '', message)
|
|
||||||
|
|
||||||
if var:
|
|
||||||
sml, pnc, pct, prf = [i for i in message] + [str(0)], [], [], False
|
|
||||||
for i in sml:
|
|
||||||
if i in punctuation or i == ' ':
|
|
||||||
pnc.append(i)
|
|
||||||
prf, flag = True if sml.index(i) == 0 else False, False
|
|
||||||
else:
|
|
||||||
flag = True
|
|
||||||
|
|
||||||
if flag and len(pnc) > 0:
|
|
||||||
pct.append(''.join(pnc))
|
|
||||||
pnc = []
|
|
||||||
|
|
||||||
wrd, rev, txt = no_pct.split(), [], []
|
|
||||||
for i in wrd:
|
|
||||||
word, up, itg = [i[-1 - l].lower() for l in range(len(i))], [], []
|
|
||||||
|
|
||||||
for _ in i:
|
|
||||||
up.append(True if _ in TRU_ else False)
|
|
||||||
for j in range(len(up)):
|
|
||||||
itg.append(word[j].upper() if up[j] else word[j])
|
|
||||||
|
|
||||||
rev.append(''.join(itg))
|
|
||||||
|
|
||||||
if prf:
|
|
||||||
for i in range(len(pct)):
|
|
||||||
txt = (txt + [pct[i]]) if (i + 1) == len(pct) and len(pct) > len(rev) else (txt + [pct[i], rev[i]])
|
|
||||||
return ''.join(txt)
|
|
||||||
else:
|
|
||||||
for i in range(len(rev)):
|
|
||||||
if i > 0:
|
|
||||||
txt.append(pct[i - 1])
|
|
||||||
txt.append(rev[i])
|
|
||||||
if (i + 1) == len(rev) and len(pct) == len(rev):
|
|
||||||
txt.append(pct[i])
|
|
||||||
return ''.join(txt)
|
|
||||||
else:
|
|
||||||
sml = [i for i in message]
|
|
||||||
return ''.join([sml[-1 - i] for i in range(len(sml))])
|
|
||||||
362
code/sql.py
362
code/sql.py
|
|
@ -1,362 +0,0 @@
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# номер счётчика в обработчиках → колонка таблицы статистики
|
|
||||||
COLUMNS = {1: "mes", 2: "rep", 3: "com", 4: "url", 5: "med", 6: "sti", 7: "voi"}
|
|
||||||
|
|
||||||
# Таблица на каждый чат — так устроены рабочие базы бота; имя таблицы — внутренний числовой id чата,
|
|
||||||
# поэтому перед подстановкой в SQL он приводится к int
|
|
||||||
|
|
||||||
|
|
||||||
def table(group_id):
|
|
||||||
return f"[{int(group_id)}]"
|
|
||||||
|
|
||||||
|
|
||||||
class Base:
|
|
||||||
def __init__(self, database):
|
|
||||||
"""Подключаемся к БД и сохраняем курсор соединения"""
|
|
||||||
self.connection = sqlite3.connect(database)
|
|
||||||
self.cursor = self.connection.cursor()
|
|
||||||
|
|
||||||
# на новой установке таблиц ещё нет
|
|
||||||
stat_columns = ", ".join(f"{c} INTEGER" for c in COLUMNS.values())
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("CREATE TABLE IF NOT EXISTS work (group_id INTEGER NOT NULL, "
|
|
||||||
"state BOOLEAN NOT NULL DEFAULT (True))")
|
|
||||||
self.cursor.execute(f"CREATE TABLE IF NOT EXISTS stat (group_id INTEGER NOT NULL, {stat_columns})")
|
|
||||||
self.cursor.execute(f"CREATE TABLE IF NOT EXISTS month (group_id INTEGER NOT NULL, {stat_columns})")
|
|
||||||
self.cursor.execute("CREATE TABLE IF NOT EXISTS edit (user_id INTEGER NOT NULL)")
|
|
||||||
self.cursor.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
||||||
|
|
||||||
def get_meta(self, key):
|
|
||||||
with self.connection:
|
|
||||||
row = self.cursor.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
def set_meta(self, key, value):
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("INSERT INTO meta (key, value) VALUES (?, ?) "
|
|
||||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
|
||||||
|
|
||||||
def reset_month(self):
|
|
||||||
"""Обнуляем месячную статистику групп"""
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("UPDATE month SET " + ", ".join(f"{c} = 0" for c in COLUMNS.values()))
|
|
||||||
|
|
||||||
def increment(self, name, group_id, var_id):
|
|
||||||
"""Счётчик +1 одним запросом: раньше SELECT и UPDATE с ручным +1 теряли сообщения,
|
|
||||||
пришедшие одновременно"""
|
|
||||||
column = COLUMNS[var_id]
|
|
||||||
try:
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute(f"UPDATE {name} SET {column} = {column} + 1 WHERE group_id = ?", (group_id,))
|
|
||||||
except sqlite3.Error:
|
|
||||||
# статистика не должна мешать остальной обработке сообщения
|
|
||||||
logging.exception("Не удалось обновить статистику %s", name)
|
|
||||||
|
|
||||||
# КОМАНДЫ
|
|
||||||
def add_group(self, group_id):
|
|
||||||
"""Добавляем нового пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("INSERT INTO `work` (`group_id`) VALUES(?)", (group_id,))
|
|
||||||
self.cursor.execute("INSERT INTO `stat` (`group_id`, `mes`, `rep`, `com`, `url`, `med`, `sti`, "
|
|
||||||
"`voi`) VALUES(?,?,?,?,?,?,?,?)", (group_id, 0, 0, 0, 0, 0, 0, 0))
|
|
||||||
return
|
|
||||||
|
|
||||||
def group_exists(self, group_id):
|
|
||||||
"""Проверяем, есть ли уже пользователь в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute('SELECT * FROM `work` WHERE `group_id` = ?', (group_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
# ТАБЛИЦА STAT
|
|
||||||
def update_stat(self, group_id, var_id):
|
|
||||||
"""Обновляем статистику"""
|
|
||||||
self.increment("stat", group_id, var_id)
|
|
||||||
|
|
||||||
def stat_group(self, group_id):
|
|
||||||
"""Получение данных пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("SELECT * FROM `stat` WHERE `group_id` = ?", (group_id,))
|
|
||||||
data = self.cursor.fetchone()
|
|
||||||
return data[1:]
|
|
||||||
|
|
||||||
# ТАБЛИЦА MONTH
|
|
||||||
def group_exists_month(self, group_id):
|
|
||||||
"""Проверяем, есть ли уже пользователь в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute('SELECT * FROM `month` WHERE `group_id` = ?', (group_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
def add_group_month(self, group_id):
|
|
||||||
"""Добавляем нового пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute("INSERT INTO `month` (`group_id`, `mes`, `rep`, `com`, `url`, `med`, `sti`, "
|
|
||||||
"`voi`) VALUES(?,?,?,?,?,?,?,?)", (group_id, 0, 0, 0, 0, 0, 0, 0))
|
|
||||||
|
|
||||||
def update_month_stat(self, group_id, var_id):
|
|
||||||
"""Обновляем статистику"""
|
|
||||||
self.increment("month", group_id, var_id)
|
|
||||||
|
|
||||||
def month_stat_group(self, group_id):
|
|
||||||
"""Получение данных пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("SELECT * FROM `month` WHERE `group_id` = ?", (group_id,))
|
|
||||||
data = self.cursor.fetchone()
|
|
||||||
return data[1:]
|
|
||||||
|
|
||||||
# ТАБЛИЦА EDIT
|
|
||||||
def add_edit_user(self, user_id):
|
|
||||||
"""Добавляем нового пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute("INSERT INTO `edit` (`user_id`) VALUES(?)", (user_id,))
|
|
||||||
|
|
||||||
def edit_user_exists(self, user_id):
|
|
||||||
"""Проверяем, есть ли уже пользователь в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute('SELECT * FROM `edit` WHERE `user_id` = ?', (user_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
def del_edit_user(self, user_id):
|
|
||||||
"""Удаление пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute('DELETE FROM `edit` WHERE `user_id` = ?', (user_id,))
|
|
||||||
|
|
||||||
# ТАБЛИЦА WORK
|
|
||||||
def check_status(self, group_id):
|
|
||||||
"""Получаем статус"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute('SELECT `state` FROM `work` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
|
|
||||||
|
|
||||||
def update_status(self, group_id):
|
|
||||||
"""Обновляем статус"""
|
|
||||||
with self.connection:
|
|
||||||
state = self.cursor.execute('SELECT `state` FROM `work` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
|
|
||||||
return self.cursor.execute("UPDATE `work` SET `state` = ? WHERE `group_id` = ?", (not state, group_id))
|
|
||||||
|
|
||||||
# ЗАКРЫТИЕ ВЫЗОВА
|
|
||||||
def close(self):
|
|
||||||
"""Закрываем соединение с БД"""
|
|
||||||
self.connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
class User:
|
|
||||||
def __init__(self, database):
|
|
||||||
"""Подключаемся к БД и сохраняем курсор соединения"""
|
|
||||||
self.connection = sqlite3.connect(database)
|
|
||||||
self.cursor = self.connection.cursor()
|
|
||||||
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
||||||
"user_id INTEGER NOT NULL)")
|
|
||||||
self.cursor.execute("CREATE TABLE IF NOT EXISTS \"groups\" (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
||||||
"group_id INTEGER NOT NULL)")
|
|
||||||
|
|
||||||
# СВЯЗКА ПОЛЬЗОВАТЕЛЯ
|
|
||||||
def user_exists(self, user_id):
|
|
||||||
"""Проверяем, есть ли уже пользователь в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute('SELECT * FROM `users` WHERE `user_id` = ?', (user_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
def add_user(self, user_id):
|
|
||||||
"""Добавляем нового пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute("INSERT INTO `users` (`user_id`) VALUES(?)", (user_id,))
|
|
||||||
|
|
||||||
def get_user_id(self, user_id):
|
|
||||||
"""Получаем короткое айди юзера"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute('SELECT `id` FROM `users` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
|
||||||
|
|
||||||
def get_first_user_id(self, user_id):
|
|
||||||
"""Получаем длинное айди юзера"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute('SELECT `user_id` FROM `users` WHERE `id` = ?', (user_id,)).fetchone()[0]
|
|
||||||
|
|
||||||
# СВЯЗКА ГРУППЫ
|
|
||||||
def group_exists(self, group_id):
|
|
||||||
"""Проверяем, есть ли уже группа в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute('SELECT * FROM `groups` WHERE `group_id` = ?', (group_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
def add_group(self, group_id):
|
|
||||||
"""Добавляем новую группу в таблицу"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute("INSERT INTO `groups` (`group_id`) VALUES(?)", (group_id,))
|
|
||||||
|
|
||||||
def get_group_id(self, group_id):
|
|
||||||
"""Получаем короткое айди юзера"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute('SELECT `id` FROM `groups` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
|
|
||||||
|
|
||||||
def get_first_group_id(self, group_id):
|
|
||||||
"""Получаем длинное айди юзера"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute('SELECT `group_id` FROM `groups` WHERE `id` = ?', (group_id,)).fetchone()[0]
|
|
||||||
|
|
||||||
def update_group_id(self, from_id, to_id):
|
|
||||||
"""Заменяем на новый айди"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute("UPDATE `groups` SET `group_id` = ? WHERE `id` = ?", (to_id, from_id))
|
|
||||||
|
|
||||||
# ЗАКРЫТИЕ ВЫЗОВА
|
|
||||||
def close(self):
|
|
||||||
"""Закрываем соединение с БД"""
|
|
||||||
self.connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
class Group:
|
|
||||||
def __init__(self, database):
|
|
||||||
"""Подключаемся к БД и сохраняем курсор соединения"""
|
|
||||||
self.connection = sqlite3.connect(database)
|
|
||||||
self.cursor = self.connection.cursor()
|
|
||||||
|
|
||||||
# КОМАНДЫ
|
|
||||||
def created_group(self, group_id):
|
|
||||||
"""Создаём новую таблицу"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS {table(group_id)} (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
first_name STRING,
|
|
||||||
mes INTEGER,
|
|
||||||
rep INTEGER,
|
|
||||||
com INTEGER,
|
|
||||||
url INTEGER,
|
|
||||||
med INTEGER,
|
|
||||||
sti INTEGER,
|
|
||||||
voi INTEGER);""")
|
|
||||||
|
|
||||||
def add_user(self, group_id, user_id, name):
|
|
||||||
"""Добавляем нового пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f"INSERT INTO {table(group_id)} (`user_id`, `first_name`, `mes`, `rep`, `com`, `url`, "
|
|
||||||
f"`med`, `sti`, `voi`) VALUES(?,?,?,?,?,?,?,?,?)", (user_id, name, 0, 0, 0, 0, 0,
|
|
||||||
0, 0))
|
|
||||||
|
|
||||||
def all_names(self, group_id):
|
|
||||||
"""Список имён"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f'SELECT `first_name` FROM {table(group_id)}').fetchall()
|
|
||||||
|
|
||||||
def all_ids(self, group_id):
|
|
||||||
"""Список айди"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f'SELECT `user_id` FROM {table(group_id)}').fetchall()
|
|
||||||
|
|
||||||
def update_stat(self, user_id, group_id, var_id):
|
|
||||||
"""Обновляем статистику"""
|
|
||||||
column = COLUMNS[var_id]
|
|
||||||
try:
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute(f"UPDATE {table(group_id)} SET {column} = {column} + 1 WHERE user_id = ?",
|
|
||||||
(user_id,))
|
|
||||||
except sqlite3.Error:
|
|
||||||
logging.exception("Не удалось обновить статистику участника")
|
|
||||||
|
|
||||||
def user_exists(self, user_id, group_id):
|
|
||||||
"""Проверяем, есть ли уже пользователь в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute(f'SELECT * FROM {table(group_id)} WHERE `user_id` = ?', (user_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
def update_name(self, user_id, group_id, name):
|
|
||||||
"""Обновляем имя пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f"UPDATE {table(group_id)} SET `first_name` = ? WHERE `user_id` = ?", (name, user_id))
|
|
||||||
|
|
||||||
def user_name(self, user_id, group_id):
|
|
||||||
"""Получаем имя пользователя по айди"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f"SELECT `first_name` FROM {table(group_id)} WHERE `user_id` = ?", (user_id,)).fetchone()[0]
|
|
||||||
|
|
||||||
def stat_user(self, user_id, group_id):
|
|
||||||
"""Получение данных пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute(f"SELECT * FROM {table(group_id)} WHERE `user_id` = ?", (user_id,))
|
|
||||||
data = self.cursor.fetchone()
|
|
||||||
return data[2:]
|
|
||||||
|
|
||||||
def del_user(self, group_id, user_id):
|
|
||||||
"""Удаление пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f'DELETE FROM {table(group_id)} WHERE `user_id` = ?', (user_id,))
|
|
||||||
|
|
||||||
# ЗАКРЫТИЕ ВЫЗОВА
|
|
||||||
def close(self):
|
|
||||||
"""Закрываем соединение с БД"""
|
|
||||||
self.connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
class Month:
|
|
||||||
def __init__(self, database):
|
|
||||||
"""Подключаемся к БД и сохраняем курсор соединения"""
|
|
||||||
self.connection = sqlite3.connect(database)
|
|
||||||
self.cursor = self.connection.cursor()
|
|
||||||
|
|
||||||
def reset_all(self):
|
|
||||||
"""Обнуляем месячную статистику участников во всех чатах"""
|
|
||||||
with self.connection:
|
|
||||||
tables = [r[0] for r in self.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
|
|
||||||
for name in tables:
|
|
||||||
if name.lstrip("-").isdigit():
|
|
||||||
self.cursor.execute(f"UPDATE {table(name)} SET " + ", ".join(f"{c} = 0" for c in COLUMNS.values()))
|
|
||||||
|
|
||||||
# КОМАНДЫ
|
|
||||||
def update_stat(self, user_id, group_id, var_id):
|
|
||||||
"""Обновляем статистику"""
|
|
||||||
column = COLUMNS[var_id]
|
|
||||||
try:
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute(f"UPDATE {table(group_id)} SET {column} = {column} + 1 WHERE user_id = ?",
|
|
||||||
(user_id,))
|
|
||||||
except sqlite3.Error:
|
|
||||||
logging.exception("Не удалось обновить статистику участника")
|
|
||||||
|
|
||||||
def created_group(self, group_id):
|
|
||||||
"""Создаём новую таблицу"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f"""CREATE TABLE IF NOT EXISTS {table(group_id)} (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
mes INTEGER,
|
|
||||||
rep INTEGER,
|
|
||||||
com INTEGER,
|
|
||||||
url INTEGER,
|
|
||||||
med INTEGER,
|
|
||||||
sti INTEGER,
|
|
||||||
voi INTEGER);""")
|
|
||||||
|
|
||||||
def add_user(self, group_id, user_id):
|
|
||||||
"""Добавляем нового пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f"INSERT INTO {table(group_id)} (`user_id`, `mes`, `rep`, `com`, `url`, `med`, "
|
|
||||||
f"`sti`, `voi`) VALUES(?,?,?,?,?,?,?,?)", (user_id, 0, 0, 0, 0, 0, 0, 0))
|
|
||||||
|
|
||||||
def all_ids(self, group_id):
|
|
||||||
"""Список айди"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f'SELECT `user_id` FROM {table(group_id)}').fetchall()
|
|
||||||
|
|
||||||
def user_exists(self, user_id, group_id):
|
|
||||||
"""Проверяем, есть ли уже пользователь в базе"""
|
|
||||||
with self.connection:
|
|
||||||
result = self.cursor.execute(f'SELECT * FROM {table(group_id)} WHERE `user_id` = ?', (user_id,)).fetchall()
|
|
||||||
return bool(len(result))
|
|
||||||
|
|
||||||
def stat_user(self, user_id, group_id):
|
|
||||||
"""Получение данных пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
self.cursor.execute(f"SELECT * FROM {table(group_id)} WHERE `user_id` = ?", (user_id,))
|
|
||||||
data = self.cursor.fetchone()
|
|
||||||
return data[1:]
|
|
||||||
|
|
||||||
def del_user(self, group_id, user_id):
|
|
||||||
"""Удаление пользователя"""
|
|
||||||
with self.connection:
|
|
||||||
return self.cursor.execute(f'DELETE FROM {table(group_id)} WHERE `user_id` = ?', (user_id,))
|
|
||||||
|
|
||||||
# ЗАКРЫТИЕ ВЫЗОВА
|
|
||||||
def close(self):
|
|
||||||
"""Закрываем соединение с БД"""
|
|
||||||
self.connection.close()
|
|
||||||
21
compose.yaml
21
compose.yaml
|
|
@ -3,9 +3,26 @@ services:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/edeev/chatping_abobot:latest
|
image: ghcr.io/edeev/chatping_abobot:latest
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://abobot:abobot@db:5432/abobot
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: abobot
|
||||||
|
POSTGRES_PASSWORD: abobot
|
||||||
|
POSTGRES_DB: abobot
|
||||||
volumes:
|
volumes:
|
||||||
- db:/app/db
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U abobot"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db:
|
pgdata:
|
||||||
|
|
|
||||||
4
pytest.ini
Normal file
4
pytest.ini
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
|
testpaths = tests
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest==8.4.2
|
pytest==8.4.2
|
||||||
|
pytest-asyncio==1.4.0
|
||||||
ruff==0.14.0
|
ruff==0.14.0
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
aiogram==3.23.0
|
aiogram==3.23.0
|
||||||
|
asyncpg==0.31.0
|
||||||
pymorphy3==2.0.6
|
pymorphy3==2.0.6
|
||||||
pymorphy3-dicts-ru==2.4.417150.4580142
|
pymorphy3-dicts-ru==2.4.417150.4580142
|
||||||
pyenchant==3.2.2
|
pyenchant==3.2.2
|
||||||
|
|
|
||||||
119
scripts/migrate_sqlite.py
Normal file
119
scripts/migrate_sqlite.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""Перенос данных AboBot из четырёх баз SQLite в PostgreSQL.
|
||||||
|
|
||||||
|
python scripts/migrate_sqlite.py --sqlite-dir /path/to/db --dsn postgresql://…
|
||||||
|
|
||||||
|
Старые базы: users.db (users, groups — внутренние номера ↔ id Telegram), base.db (work, stat, edit),
|
||||||
|
groups.db (таблица на каждый чат со статистикой участников), month.db (месячная статистика).
|
||||||
|
Месячная статистика не переносится: в старой версии она ни разу не обнулялась и фактически
|
||||||
|
дублировала статистику за всё время; новая копится по месяцам с момента переноса.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
|
||||||
|
|
||||||
|
import db # noqa: E402
|
||||||
|
|
||||||
|
FIELDS = db.STAT_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(sqlite_dir, dsn, force=False):
|
||||||
|
users_db = sqlite3.connect(os.path.join(sqlite_dir, "users.db"))
|
||||||
|
base_db = sqlite3.connect(os.path.join(sqlite_dir, "base.db"))
|
||||||
|
groups_db = sqlite3.connect(os.path.join(sqlite_dir, "groups.db"))
|
||||||
|
|
||||||
|
pool = await db.connect(dsn)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if await conn.fetchval("SELECT count(*) FROM chats"):
|
||||||
|
if not force:
|
||||||
|
raise SystemExit("В PostgreSQL уже есть данные — перенос остановлен (--force очистит таблицы)")
|
||||||
|
await conn.execute("TRUNCATE chats, users, members, chat_stats, member_stats, bot_status_history "
|
||||||
|
"RESTART IDENTITY CASCADE")
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
# пользователи и чаты с прежними внутренними номерами
|
||||||
|
users = {}
|
||||||
|
for short, tg in users_db.execute("SELECT id, user_id FROM users ORDER BY id"):
|
||||||
|
if tg not in users.values():
|
||||||
|
users[short] = tg
|
||||||
|
await conn.executemany("INSERT INTO users (id, short_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
|
[(tg, short) for short, tg in users.items()])
|
||||||
|
|
||||||
|
states = dict(base_db.execute("SELECT group_id, state FROM work"))
|
||||||
|
chats = {}
|
||||||
|
for short, tg in users_db.execute('SELECT id, group_id FROM "groups" ORDER BY id'):
|
||||||
|
if tg not in chats.values():
|
||||||
|
chats[short] = tg
|
||||||
|
await conn.executemany(
|
||||||
|
"INSERT INTO chats (id, short_id, events_enabled) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||||
|
[(tg, short, bool(states.get(short, True))) for short, tg in chats.items()])
|
||||||
|
for table in ("users", "chats"):
|
||||||
|
await conn.execute(f"SELECT setval(pg_get_serial_sequence('{table}', 'short_id'), "
|
||||||
|
f"GREATEST((SELECT max(short_id) FROM {table}), 1))")
|
||||||
|
|
||||||
|
# статистика чатов за всё время
|
||||||
|
chat_rows = []
|
||||||
|
for row in base_db.execute(f"SELECT group_id, {', '.join(FIELDS)} FROM stat"):
|
||||||
|
if row[0] in chats:
|
||||||
|
chat_rows.append((chats[row[0]], *[v or 0 for v in row[1:]]))
|
||||||
|
await conn.executemany(
|
||||||
|
f"INSERT INTO chat_stats (chat_id, period, {', '.join(FIELDS)}) "
|
||||||
|
f"VALUES ($1, 'all', {', '.join(f'${i}' for i in range(2, 9))}) ON CONFLICT DO NOTHING", chat_rows)
|
||||||
|
|
||||||
|
# участники и их статистика: таблица groups.db на каждый чат
|
||||||
|
custom = {r[0] for r in base_db.execute("SELECT user_id FROM edit")}
|
||||||
|
tables = [r[0] for r in groups_db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
|
||||||
|
members, stats, skipped = {}, {}, 0
|
||||||
|
for table in tables:
|
||||||
|
if not table.lstrip("-").isdigit() or int(table) not in chats:
|
||||||
|
continue
|
||||||
|
chat_id = chats[int(table)]
|
||||||
|
for row in groups_db.execute(f"SELECT user_id, first_name, {', '.join(FIELDS)} FROM [{table}]"):
|
||||||
|
# SQLite хранит тип по значению: имя «69» лежит числом
|
||||||
|
user_short, name = row[0], "" if row[1] is None else str(row[1])
|
||||||
|
if user_short not in users:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
key = (chat_id, users[user_short])
|
||||||
|
members.setdefault(key, (name, user_short in custom))
|
||||||
|
old = stats.get(key, (0,) * len(FIELDS))
|
||||||
|
stats[key] = tuple(a + (b or 0) for a, b in zip(old, row[2:], strict=True))
|
||||||
|
await conn.executemany("INSERT INTO members (chat_id, user_id, name, custom_name) VALUES ($1, $2, $3, $4)",
|
||||||
|
[(c, u, n, cu) for (c, u), (n, cu) in members.items()])
|
||||||
|
await conn.executemany(
|
||||||
|
f"INSERT INTO member_stats (chat_id, user_id, period, {', '.join(FIELDS)}) "
|
||||||
|
f"VALUES ($1, $2, 'all', {', '.join(f'${i}' for i in range(3, 10))})",
|
||||||
|
[(c, u, *s) for (c, u), s in stats.items()])
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"users": (len(users), await conn.fetchval("SELECT count(*) FROM users")),
|
||||||
|
"chats": (len(chats), await conn.fetchval("SELECT count(*) FROM chats")),
|
||||||
|
"members": (len(members), await conn.fetchval("SELECT count(*) FROM members")),
|
||||||
|
"chat messages": (sum(r[1] for r in chat_rows),
|
||||||
|
await conn.fetchval("SELECT coalesce(sum(mes), 0) FROM chat_stats WHERE period = 'all'")),
|
||||||
|
"member messages": (sum(s[0] for s in stats.values()),
|
||||||
|
await conn.fetchval("SELECT coalesce(sum(mes), 0) FROM member_stats WHERE period = 'all'")),
|
||||||
|
}
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
ok = all(a == b for a, b in report.values())
|
||||||
|
for key, (src, dst) in report.items():
|
||||||
|
print(f"{key:16} SQLite {src:>9} PostgreSQL {dst:>9} {'ok' if src == dst else 'MISMATCH'}")
|
||||||
|
print(f"участников без пользователя в users.db (пропущено): {skipped}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||||
|
parser.add_argument("--sqlite-dir", required=True)
|
||||||
|
parser.add_argument("--dsn", default=os.getenv("DATABASE_URL"))
|
||||||
|
parser.add_argument("--force", action="store_true", help="очистить таблицы PostgreSQL перед переносом")
|
||||||
|
args = parser.parse_args()
|
||||||
|
sys.exit(0 if asyncio.run(migrate(args.sqlite_dir, args.dsn, args.force)) else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,11 +1,42 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# бот открывает базы по путям ../db относительно папки code — запускаем как в проде, но во временной папке
|
import asyncpg
|
||||||
import tempfile
|
import pytest
|
||||||
|
|
||||||
ROOT = tempfile.mkdtemp()
|
|
||||||
os.makedirs(os.path.join(ROOT, "code"))
|
|
||||||
os.chdir(os.path.join(ROOT, "code"))
|
|
||||||
os.environ.setdefault("BOT_TOKEN", "123456:TEST")
|
os.environ.setdefault("BOT_TOKEN", "123456:TEST")
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
|
||||||
|
|
||||||
|
import db # noqa: E402
|
||||||
|
|
||||||
|
DSN = os.environ.get("TEST_DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/abobot_test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def pool():
|
||||||
|
"""Чистая схема на каждый тест"""
|
||||||
|
conn = await asyncpg.connect(DSN)
|
||||||
|
await conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
|
||||||
|
await conn.close()
|
||||||
|
p = await db.connect(DSN)
|
||||||
|
yield p
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBot:
|
||||||
|
"""Минимум Bot API для обработчиков: размер чата и права"""
|
||||||
|
|
||||||
|
def __init__(self, size=10, admins=()):
|
||||||
|
self.size, self.admins, self.id = size, set(admins), 999
|
||||||
|
|
||||||
|
async def get_chat_member_count(self, chat_id):
|
||||||
|
return self.size
|
||||||
|
|
||||||
|
async def get_chat_member(self, chat_id, user_id):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
return SimpleNamespace(status="administrator" if user_id in self.admins else "member")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_bot():
|
||||||
|
return FakeBot
|
||||||
|
|
|
||||||
49
tests/test_db.py
Normal file
49
tests/test_db.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
async def test_counters_all_time_and_month(pool):
|
||||||
|
await db.count(-1, 10, [1, 3], "маша", "Чат", day=date(2026, 10, 6))
|
||||||
|
await db.count(-1, 10, [1], "маша", day=date(2026, 10, 7))
|
||||||
|
await db.count(-1, 10, [1], "маша", day=date(2026, 11, 1))
|
||||||
|
assert (await db.member_stats(-1, 10, "all"))[:3] == (3, 0, 1)
|
||||||
|
assert (await db.member_stats(-1, 10, "2026-10"))[0] == 2
|
||||||
|
assert (await db.member_stats(-1, 10, "2026-11"))[0] == 1 # месяцы хранятся, а не обнуляются
|
||||||
|
assert (await db.chat_stats(-1, "all"))[0] == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_custom_name_survives_and_left_member(pool):
|
||||||
|
await db.count(-1, 10, [1], "мария")
|
||||||
|
await db.set_custom_name(-1, 10, "маша")
|
||||||
|
await db.count(-1, 10, [1], "мария")
|
||||||
|
assert await db.chat_names(-1) == {"маша": 10}
|
||||||
|
await db.member_left(-1, 10)
|
||||||
|
assert await db.chat_names(-1) == {}
|
||||||
|
assert (await db.member_stats(-1, 10, "all"))[0] == 2 # статистика ушедшего остаётся
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_migration_keeps_stats(pool):
|
||||||
|
await db.count(-1, 10, [1], "маша")
|
||||||
|
await db.migrate_chat(-1, -1001)
|
||||||
|
assert (await db.member_stats(-1001, 10, "all"))[0] == 1
|
||||||
|
assert not await db.chat_exists(-1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bot_status_history(pool):
|
||||||
|
assert await db.set_bot_status(-5, "member", {"can_send_messages": True}, "update", member_count=40)
|
||||||
|
assert not await db.set_bot_status(-5, "member", {"can_send_messages": True}, "check") # без изменений
|
||||||
|
assert await db.set_bot_status(-5, "administrator", {"can_delete_messages": True}, "update")
|
||||||
|
assert await db.set_bot_status(-5, "kicked", None, "check")
|
||||||
|
history = await db.pool.fetch("SELECT status, source FROM bot_status_history WHERE chat_id = -5 ORDER BY id")
|
||||||
|
assert [(r["status"], r["source"]) for r in history] == [
|
||||||
|
("member", "update"), ("administrator", "update"), ("kicked", "check")]
|
||||||
|
assert await db.chats_to_check() == [] # из исключённого чата не проверяем
|
||||||
|
assert await db.pool.fetchval("SELECT count(*) FROM active_chats") == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_top(pool):
|
||||||
|
for uid, name, n in ((1, "а", 3), (2, "б", 5), (3, "в", 1)):
|
||||||
|
for _ in range(n):
|
||||||
|
await db.count(-1, uid, [1], name)
|
||||||
|
assert [(n, v) for _, n, v in await db.top(-1, "all", "mes", 2)] == [("б", 5), ("а", 3)]
|
||||||
69
tests/test_handlers.py
Normal file
69
tests/test_handlers.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import db
|
||||||
|
import nlp
|
||||||
|
from handlers import common, mentions, stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunks_respect_limit():
|
||||||
|
parts = [f'<a href="tg://user?id={i}">Участник{i}</a>' for i in range(500)]
|
||||||
|
messages = mentions.chunks(parts, " вас вызывает Маша", limit=4096)
|
||||||
|
assert len(messages) > 1 and all(len(m) <= 4096 for m in messages)
|
||||||
|
assert messages[-1].endswith(" вас вызывает Маша")
|
||||||
|
assert sum(m.count("tg://user") for m in messages) == 500
|
||||||
|
|
||||||
|
|
||||||
|
class Msg:
|
||||||
|
def __init__(self, chat_id, user_id):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
self.chat = SimpleNamespace(id=chat_id)
|
||||||
|
self.from_user = SimpleNamespace(id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mentions_and_big_chat_cooldown(pool, fake_bot):
|
||||||
|
await db.count(-1, 1, [1], "маша")
|
||||||
|
await db.count(-1, 2, [1], "петя")
|
||||||
|
small, big = fake_bot(size=10), fake_bot(size=500)
|
||||||
|
|
||||||
|
text = await mentions.mention_in_text(small, Msg(-1, 2), "позови Машу")
|
||||||
|
assert text == '<a href="tg://user?id=1">Маша</a>, тебя упомянули)'
|
||||||
|
assert await mentions.mention_in_text(small, Msg(-1, 1), "я Маша") is None # себя не зовём
|
||||||
|
|
||||||
|
mentions._last_mention.clear()
|
||||||
|
common._size_cache.clear() # размер чата кэшируется на 10 минут
|
||||||
|
assert await mentions.mention_names(big, Msg(-1, 2), ["маша"])
|
||||||
|
assert await mentions.mention_names(big, Msg(-1, 2), ["маша"]) is None # в большом чате — раз в минуту
|
||||||
|
common._size_cache.clear()
|
||||||
|
assert await mentions.mention_names(small, Msg(-1, 2), ["маша"]) # в маленьком — всегда
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mentions_can_be_disabled(pool, fake_bot):
|
||||||
|
await db.count(-1, 1, [1], "маша")
|
||||||
|
await db.toggle_setting(-1, "mentions_enabled")
|
||||||
|
assert await mentions.mention_in_text(fake_bot(), Msg(-1, 2), "Маша") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_period():
|
||||||
|
assert stats.parse_period("2026-09") == "2026-09"
|
||||||
|
assert stats.parse_period("2026-13") is None
|
||||||
|
assert stats.parse_period(None) == db.month_key(date.today())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text,expected", [("ghbdtn vbh", True), ("hello world", False), ("http://x.ru", True),
|
||||||
|
("/help", False), ("привет", False), ("123 !!", False)])
|
||||||
|
def test_wrong_layout(text, expected):
|
||||||
|
assert nlp.wrong_layout(text) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_nlp_helpers():
|
||||||
|
assert nlp.translator(["ghbdtn"]) == "привет"
|
||||||
|
assert nlp.revers("Привет, мир!", True) == "Тевирп, рим!"
|
||||||
|
assert nlp.lang_form(["кот"]) == "когот"
|
||||||
|
assert nlp.mention(5, "иван_<b>") == '<a href="tg://user?id=5">Иван_<B></a>'
|
||||||
|
assert nlp.find_names("позови Машу и Петю, Машу!", {"маша", "петя"}) == ["маша", "петя"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_imports():
|
||||||
|
import bot # noqa: F401
|
||||||
56
tests/test_migration.py
Normal file
56
tests/test_migration.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||||
|
|
||||||
|
import db # noqa: E402
|
||||||
|
import migrate_sqlite # noqa: E402
|
||||||
|
from conftest import DSN # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def make_old_dbs(path):
|
||||||
|
users = sqlite3.connect(path / "users.db")
|
||||||
|
users.executescript("""
|
||||||
|
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL);
|
||||||
|
CREATE TABLE "groups" (id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL);
|
||||||
|
INSERT INTO users (user_id) VALUES (111), (222);
|
||||||
|
INSERT INTO "groups" (group_id) VALUES (-100), (-200);
|
||||||
|
""")
|
||||||
|
users.commit()
|
||||||
|
base = sqlite3.connect(path / "base.db")
|
||||||
|
base.executescript("""
|
||||||
|
CREATE TABLE work (group_id INTEGER NOT NULL, state BOOLEAN NOT NULL DEFAULT (True));
|
||||||
|
CREATE TABLE stat (group_id INTEGER NOT NULL, mes INTEGER, rep INTEGER, com INTEGER, url INTEGER,
|
||||||
|
med INTEGER, sti INTEGER, voi INTEGER);
|
||||||
|
CREATE TABLE edit (user_id INTEGER NOT NULL);
|
||||||
|
INSERT INTO work VALUES (1, 1), (2, 0);
|
||||||
|
INSERT INTO stat VALUES (1, 50, 5, 1, 0, 2, 3, 4), (2, 7, 0, 0, 0, 0, 0, 0);
|
||||||
|
INSERT INTO edit VALUES (2);
|
||||||
|
""")
|
||||||
|
base.commit()
|
||||||
|
groups = sqlite3.connect(path / "groups.db")
|
||||||
|
groups.executescript("""
|
||||||
|
CREATE TABLE [1] (user_id INTEGER, first_name STRING, mes INTEGER, rep INTEGER, com INTEGER, url INTEGER,
|
||||||
|
med INTEGER, sti INTEGER, voi INTEGER);
|
||||||
|
CREATE TABLE [2] (user_id INTEGER, first_name STRING, mes INTEGER, rep INTEGER, com INTEGER, url INTEGER,
|
||||||
|
med INTEGER, sti INTEGER, voi INTEGER);
|
||||||
|
INSERT INTO [1] VALUES (1, 'маша', 30, 5, 1, 0, 2, 3, 4), (2, 'котик', 20, 0, 0, 0, 0, 0, 0);
|
||||||
|
INSERT INTO [2] VALUES (2, 'котик', 7, 0, 0, 0, 0, 0, 0), (9, 'призрак', 1, 0, 0, 0, 0, 0, 0);
|
||||||
|
""")
|
||||||
|
groups.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migration(pool, tmp_path):
|
||||||
|
make_old_dbs(tmp_path)
|
||||||
|
await db.close()
|
||||||
|
assert await migrate_sqlite.migrate(str(tmp_path), DSN, force=True)
|
||||||
|
await db.connect(DSN)
|
||||||
|
assert await db.pool.fetchval("SELECT short_id FROM chats WHERE id = -200") == 2
|
||||||
|
assert await db.get_setting(-200, "events_enabled") is False
|
||||||
|
assert (await db.member_stats(-100, 111, "all"))[0] == 30
|
||||||
|
assert await db.chat_names(-100) == {"маша": 111, "котик": 222}
|
||||||
|
assert await db.pool.fetchval("SELECT custom_name FROM members WHERE chat_id = -100 AND user_id = 222")
|
||||||
|
# новый пользователь получает следующий внутренний номер
|
||||||
|
await db.ensure_user(333)
|
||||||
|
assert await db.pool.fetchval("SELECT short_id FROM users WHERE id = 333") == 3
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import script
|
|
||||||
from init import du, dg
|
|
||||||
|
|
||||||
|
|
||||||
def test_md_and_plain():
|
|
||||||
assert script.md("ivan_petrov *x*") == "ivan\\_petrov \\*x\\*"
|
|
||||||
assert script.plain("[a]_b*") == "ab"
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_names():
|
|
||||||
assert script.join_names(["а"]) == "а"
|
|
||||||
assert script.join_names(["а", "б"]) == "а и б"
|
|
||||||
assert script.join_names(["а", "б", "в"]) == "а, б и в"
|
|
||||||
|
|
||||||
|
|
||||||
def test_translator_fixes_wrong_layout():
|
|
||||||
assert script.translator(["ghbdtn", "vbh"]) == "привет мир"
|
|
||||||
|
|
||||||
|
|
||||||
def test_revers_and_lang_form():
|
|
||||||
assert script.revers("Привет, мир!", True) == "Тевирп, рим!"
|
|
||||||
assert script.lang_form(["кот"]) == "когот"
|
|
||||||
|
|
||||||
|
|
||||||
def test_stats_counters_and_mentions():
|
|
||||||
chat, author, other, third = -100500, 1, 2, 3
|
|
||||||
script.upd_stat(author, chat, 1, "Автор")
|
|
||||||
script.upd_stat(other, chat, 1, "Иван_")
|
|
||||||
script.upd_stat(other, chat, 1, "Иван_")
|
|
||||||
script.upd_stat(third, chat, 2, "Пётр")
|
|
||||||
|
|
||||||
group_id = du.get_group_id(chat)
|
|
||||||
ivan = du.get_user_id(other)
|
|
||||||
assert dg.stat_user(ivan, group_id)[0] == 2 # два сообщения — счётчик атомарный
|
|
||||||
|
|
||||||
# упоминание: спецсимволы Markdown из имени внутри ссылки убираются, автор себя не упоминает
|
|
||||||
text = script.notice(["иван_", "автор"], False, group_id, author)
|
|
||||||
assert text == "[Иван](tg://user?id=2), тебя упомянули)"
|
|
||||||
assert script.notice(["автор"], False, group_id, author).startswith("[Автор]")
|
|
||||||
|
|
||||||
every = script.notice(du.get_user_id(author), True, group_id, author)
|
|
||||||
assert "[Иван](tg://user?id=2) и [Пётр](tg://user?id=3) вас вызывает Автор" == every
|
|
||||||
|
|
||||||
|
|
||||||
def test_bot_imports():
|
|
||||||
import bot # noqa: F401 — обработчики и роутер собираются без ошибок
|
|
||||||
|
|
||||||
|
|
||||||
def test_month_reset():
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import bot
|
|
||||||
from init import db, dm
|
|
||||||
|
|
||||||
chat, user = -200300, 10
|
|
||||||
script.upd_stat(user, chat, 1, "Маша")
|
|
||||||
group_id = du.get_group_id(chat)
|
|
||||||
member = du.get_user_id(user)
|
|
||||||
|
|
||||||
assert bot.reset_month_if_needed(date(2026, 10, 6)) is False # первый запуск — только запомнить
|
|
||||||
assert dm.stat_user(member, group_id)[0] == 1
|
|
||||||
assert bot.reset_month_if_needed(date(2026, 10, 20)) is False # тот же месяц
|
|
||||||
assert bot.reset_month_if_needed(date(2026, 11, 1)) is True
|
|
||||||
assert dm.stat_user(member, group_id)[0] == 0 and db.month_stat_group(group_id)[0] == 0
|
|
||||||
assert dg.stat_user(member, group_id)[0] == 1 # статистика за всё время не трогается
|
|
||||||
Loading…
Add table
Reference in a new issue