Initial release: Telegram anti-spam bot
Includes join verification button, discuss-comment cleanup for non-members, and SQLite persistence. Made-with: Cursor
This commit is contained in:
commit
89c472708b
9 changed files with 642 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
BOT_TOKEN=PUT_YOUR_TOKEN_HERE
|
||||||
|
|
||||||
|
# Optional: restrict bot to a single chat (recommended for first run)
|
||||||
|
# Example: -1001234567890
|
||||||
|
CHAT_ID=
|
||||||
|
|
||||||
|
DB_PATH=./bot.sqlite3
|
||||||
|
VERIFY_WINDOW_SECONDS=60
|
||||||
|
THANKS_TTL_SECONDS=60
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
/.pycache/
|
||||||
|
|
||||||
|
# Local config/secrets
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-*
|
||||||
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Telegram антиспам-бот (кнопка-проверка)
|
||||||
|
|
||||||
|
## Быстрый старт (локально)
|
||||||
|
|
||||||
|
1) Создайте `.env` рядом с `.env.example` и заполните `BOT_TOKEN` (новый токен из @BotFather).
|
||||||
|
|
||||||
|
2) Установите зависимости (если ещё не установлены):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
./.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python -m bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важные нюансы
|
||||||
|
|
||||||
|
- Бот проверяет **только тех пользователей, которых он успел поймать на событии входа в чат** (`new_chat_members`) пока был онлайн. Это сделано, чтобы при добавлении бота в большой чат он не трогал уже существующих участников.
|
||||||
|
|
||||||
|
## Перенос на VPS/Render
|
||||||
|
|
||||||
|
- Весь конфиг — через переменные окружения (см. `.env.example`).
|
||||||
|
- База — SQLite (`DB_PATH`), один файл.
|
||||||
1
bot/__init__.py
Normal file
1
bot/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
__all__ = []
|
||||||
41
bot/__main__.py
Normal file
41
bot/__main__.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
|
||||||
|
from .config import load_config
|
||||||
|
from .db import Database
|
||||||
|
from .handlers import register_handlers, sweeper_loop
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
logging.getLogger("bot").info(
|
||||||
|
"Starting with chat_id=%s db_path=%s verify_window=%ss thanks_ttl=%ss",
|
||||||
|
cfg.chat_id,
|
||||||
|
cfg.db_path,
|
||||||
|
cfg.verify_window_seconds,
|
||||||
|
cfg.thanks_ttl_seconds,
|
||||||
|
)
|
||||||
|
bot = Bot(token=cfg.bot_token)
|
||||||
|
dp = Dispatcher()
|
||||||
|
|
||||||
|
db = Database(cfg.db_path)
|
||||||
|
await db.connect()
|
||||||
|
|
||||||
|
await register_handlers(dp, cfg=cfg, db=db)
|
||||||
|
|
||||||
|
asyncio.create_task(sweeper_loop(bot=bot, cfg=cfg, db=db))
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
45
bot/config.py
Normal file
45
bot/config.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
bot_token: str
|
||||||
|
chat_id: int | None
|
||||||
|
db_path: str
|
||||||
|
verify_window_seconds: int
|
||||||
|
thanks_ttl_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(name: str, default: int) -> int:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or raw.strip() == "":
|
||||||
|
return default
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
bot_token = (os.getenv("BOT_TOKEN") or "").strip()
|
||||||
|
if not bot_token:
|
||||||
|
raise RuntimeError("BOT_TOKEN is required (set it in .env)")
|
||||||
|
|
||||||
|
chat_id_raw = (os.getenv("CHAT_ID") or "").strip()
|
||||||
|
chat_id = int(chat_id_raw) if chat_id_raw else None
|
||||||
|
|
||||||
|
db_path = (os.getenv("DB_PATH") or "./bot.sqlite3").strip()
|
||||||
|
verify_window_seconds = _get_int("VERIFY_WINDOW_SECONDS", 60)
|
||||||
|
thanks_ttl_seconds = _get_int("THANKS_TTL_SECONDS", 60)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
bot_token=bot_token,
|
||||||
|
chat_id=chat_id,
|
||||||
|
db_path=db_path,
|
||||||
|
verify_window_seconds=verify_window_seconds,
|
||||||
|
thanks_ttl_seconds=thanks_ttl_seconds,
|
||||||
|
)
|
||||||
164
bot/db.py
Normal file
164
bot/db.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import time
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PendingVerification:
|
||||||
|
chat_id: int
|
||||||
|
user_id: int
|
||||||
|
challenge_message_id: int
|
||||||
|
expires_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self._path = path
|
||||||
|
self._conn: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._conn = await aiosqlite.connect(self._path)
|
||||||
|
await self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
await self._conn.execute("PRAGMA foreign_keys=ON;")
|
||||||
|
await self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS verified_members (
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
verified_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (chat_id, user_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_verifications (
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
challenge_message_id INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (chat_id, user_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conn(self) -> aiosqlite.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
async def mark_pending(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
challenge_message_id: int,
|
||||||
|
expires_at: int,
|
||||||
|
) -> None:
|
||||||
|
await self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pending_verifications (chat_id, user_id, challenge_message_id, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chat_id, user_id) DO UPDATE SET
|
||||||
|
challenge_message_id=excluded.challenge_message_id,
|
||||||
|
expires_at=excluded.expires_at
|
||||||
|
""",
|
||||||
|
(chat_id, user_id, challenge_message_id, expires_at),
|
||||||
|
)
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
|
async def get_pending(self, *, chat_id: int, user_id: int) -> PendingVerification | None:
|
||||||
|
cur = await self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT chat_id, user_id, challenge_message_id, expires_at
|
||||||
|
FROM pending_verifications
|
||||||
|
WHERE chat_id=? AND user_id=?
|
||||||
|
""",
|
||||||
|
(chat_id, user_id),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
await cur.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return PendingVerification(
|
||||||
|
chat_id=int(row[0]),
|
||||||
|
user_id=int(row[1]),
|
||||||
|
challenge_message_id=int(row[2]),
|
||||||
|
expires_at=int(row[3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_pending(self, *, chat_id: int, user_id: int) -> None:
|
||||||
|
await self.conn.execute(
|
||||||
|
"DELETE FROM pending_verifications WHERE chat_id=? AND user_id=?",
|
||||||
|
(chat_id, user_id),
|
||||||
|
)
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
|
async def mark_verified(self, *, chat_id: int, user_id: int) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
await self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO verified_members (chat_id, user_id, verified_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(chat_id, user_id) DO UPDATE SET verified_at=excluded.verified_at
|
||||||
|
""",
|
||||||
|
(chat_id, user_id, now),
|
||||||
|
)
|
||||||
|
await self.conn.execute(
|
||||||
|
"DELETE FROM pending_verifications WHERE chat_id=? AND user_id=?",
|
||||||
|
(chat_id, user_id),
|
||||||
|
)
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
|
async def delete_verified(self, *, chat_id: int, user_id: int) -> None:
|
||||||
|
await self.conn.execute(
|
||||||
|
"DELETE FROM verified_members WHERE chat_id=? AND user_id=?",
|
||||||
|
(chat_id, user_id),
|
||||||
|
)
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
|
async def is_verified(self, *, chat_id: int, user_id: int) -> bool:
|
||||||
|
cur = await self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM verified_members
|
||||||
|
WHERE chat_id=? AND user_id=?
|
||||||
|
""",
|
||||||
|
(chat_id, user_id),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
await cur.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
async def get_expired_pending(self, *, now: int) -> list[PendingVerification]:
|
||||||
|
cur = await self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT chat_id, user_id, challenge_message_id, expires_at
|
||||||
|
FROM pending_verifications
|
||||||
|
WHERE expires_at <= ?
|
||||||
|
ORDER BY expires_at ASC
|
||||||
|
LIMIT 100
|
||||||
|
""",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
await cur.close()
|
||||||
|
return [
|
||||||
|
PendingVerification(
|
||||||
|
chat_id=int(r[0]),
|
||||||
|
user_id=int(r[1]),
|
||||||
|
challenge_message_id=int(r[2]),
|
||||||
|
expires_at=int(r[3]),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
315
bot/handlers.py
Normal file
315
bot/handlers.py
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher, F, Router
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
ChatMemberAdministrator,
|
||||||
|
ChatMemberOwner,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .db import Database
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="antispam")
|
||||||
|
|
||||||
|
_member_status_cache: dict[tuple[int, int], tuple[str, float]] = {}
|
||||||
|
_MEMBER_STATUS_CACHE_TTL_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_chat(cfg: Config, chat_id: int) -> bool:
|
||||||
|
return cfg.chat_id is None or cfg.chat_id == chat_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_admin(bot: Bot, chat_id: int, user_id: int) -> bool:
|
||||||
|
member = await bot.get_chat_member(chat_id, user_id)
|
||||||
|
return isinstance(member, (ChatMemberAdministrator, ChatMemberOwner))
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_member_status_cached(bot: Bot, chat_id: int, user_id: int) -> str | None:
|
||||||
|
"""
|
||||||
|
Returns member.status string, or None if lookup failed.
|
||||||
|
Cached briefly to avoid per-message API calls during bursts.
|
||||||
|
"""
|
||||||
|
now_mono = time.monotonic()
|
||||||
|
key = (chat_id, user_id)
|
||||||
|
cached = _member_status_cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
status, valid_until = cached
|
||||||
|
if now_mono <= valid_until:
|
||||||
|
return status
|
||||||
|
|
||||||
|
try:
|
||||||
|
member = await bot.get_chat_member(chat_id, user_id)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = getattr(member, "status", None)
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_member_status_cache[key] = (status, now_mono + _MEMBER_STATUS_CACHE_TTL_SECONDS)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
async def _kick_user(bot: Bot, chat_id: int, user_id: int) -> None:
|
||||||
|
# "Kick" in modern Bot API is typically ban + unban.
|
||||||
|
try:
|
||||||
|
await bot.ban_chat_member(chat_id, user_id)
|
||||||
|
except TelegramForbiddenError:
|
||||||
|
return
|
||||||
|
except TelegramBadRequest:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await bot.unban_chat_member(chat_id, user_id, only_if_banned=True)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _challenge_kb(chat_id: int, user_id: int) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Я человек ✅",
|
||||||
|
callback_data=f"verify:{chat_id}:{user_id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_challenge(
|
||||||
|
*,
|
||||||
|
bot: Bot,
|
||||||
|
cfg: Config,
|
||||||
|
db: Database,
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
user_full_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Ensures there's an active pending verification for the user in this chat.
|
||||||
|
Used on join and also on first message (in case bot missed join event).
|
||||||
|
"""
|
||||||
|
now = int(time.time())
|
||||||
|
pending = await db.get_pending(chat_id=chat_id, user_id=user_id)
|
||||||
|
if pending is not None and pending.expires_at >= now:
|
||||||
|
return
|
||||||
|
|
||||||
|
expires_at = now + cfg.verify_window_seconds
|
||||||
|
text = (
|
||||||
|
f"Привет, {user_full_name}!\n\n"
|
||||||
|
"Чтобы писать в чате, нажми кнопку ниже в течение 1 минуты."
|
||||||
|
)
|
||||||
|
sent = await bot.send_message(chat_id, text, reply_markup=_challenge_kb(chat_id, user_id))
|
||||||
|
await db.mark_pending(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
challenge_message_id=sent.message_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def register_handlers(dp: Dispatcher, *, cfg: Config, db: Database) -> None:
|
||||||
|
dp.include_router(router)
|
||||||
|
dp["cfg"] = cfg
|
||||||
|
dp["db"] = db
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("ping"))
|
||||||
|
async def ping(message: Message, cfg: Config) -> None:
|
||||||
|
if not _is_allowed_chat(cfg, message.chat.id):
|
||||||
|
return
|
||||||
|
await message.answer("pong")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def start(message: Message) -> None:
|
||||||
|
if message.chat.type != "private":
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"Привет! Это личная переписка с ботом — здесь нет никаких настроек.\n\n"
|
||||||
|
"Чтобы я работал как антиспам, добавьте меня администратором в группу и выдайте права:\n\n"
|
||||||
|
"- удалять сообщения\n"
|
||||||
|
"- банить (кикать) пользователей\n\n"
|
||||||
|
"Спасибо!"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("chatid"))
|
||||||
|
async def chatid(message: Message, cfg: Config) -> None:
|
||||||
|
if not _is_allowed_chat(cfg, message.chat.id):
|
||||||
|
return
|
||||||
|
await message.answer(f"chat_id: {message.chat.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.new_chat_members)
|
||||||
|
async def on_new_members(message: Message, bot: Bot, cfg: Config, db: Database) -> None:
|
||||||
|
chat_id = message.chat.id
|
||||||
|
if not _is_allowed_chat(cfg, chat_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
for user in message.new_chat_members:
|
||||||
|
if user.is_bot:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if await _is_admin(bot, chat_id, user.id):
|
||||||
|
continue
|
||||||
|
except TelegramBadRequest:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Force verification on every (re)join: prevents bypass if user was verified earlier.
|
||||||
|
await db.delete_verified(chat_id=chat_id, user_id=user.id)
|
||||||
|
await _ensure_challenge(
|
||||||
|
bot=bot,
|
||||||
|
cfg=cfg,
|
||||||
|
db=db,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user.id,
|
||||||
|
user_full_name=user.full_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("verify:"))
|
||||||
|
async def on_verify(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
bot: Bot,
|
||||||
|
cfg: Config,
|
||||||
|
db: Database,
|
||||||
|
) -> None:
|
||||||
|
parts = (callback.data or "").split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
await callback.answer("Ошибка кнопки.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = int(parts[1])
|
||||||
|
target_user_id = int(parts[2])
|
||||||
|
|
||||||
|
if not _is_allowed_chat(cfg, chat_id):
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
if callback.message is None or callback.message.chat.id != chat_id:
|
||||||
|
await callback.answer("Кнопка не из этого чата.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if callback.from_user.id != target_user_id:
|
||||||
|
await callback.answer("Кнопка только для нового участника.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await _is_admin(bot, chat_id, target_user_id):
|
||||||
|
await callback.answer("Админам проверка не нужна.")
|
||||||
|
return
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending = await db.get_pending(chat_id=chat_id, user_id=target_user_id)
|
||||||
|
now = int(time.time())
|
||||||
|
if pending is None:
|
||||||
|
await callback.answer("Проверка уже неактуальна.")
|
||||||
|
return
|
||||||
|
if pending.expires_at < now:
|
||||||
|
await callback.answer("Время истекло. Зайдите в чат заново.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.mark_verified(chat_id=chat_id, user_id=target_user_id)
|
||||||
|
|
||||||
|
# Delete challenge message
|
||||||
|
try:
|
||||||
|
await bot.delete_message(chat_id, pending.challenge_message_id)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
thanks = await bot.send_message(chat_id, "Спасибо! Проверка пройдена.")
|
||||||
|
await callback.answer("Готово.")
|
||||||
|
|
||||||
|
async def _delete_thanks() -> None:
|
||||||
|
await asyncio.sleep(cfg.thanks_ttl_seconds)
|
||||||
|
try:
|
||||||
|
await bot.delete_message(chat_id, thanks.message_id)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.create_task(_delete_thanks())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message()
|
||||||
|
async def anti_spam_all_messages(message: Message, bot: Bot, cfg: Config, db: Database) -> None:
|
||||||
|
if message.chat is None:
|
||||||
|
return
|
||||||
|
chat_id = message.chat.id
|
||||||
|
if not _is_allowed_chat(cfg, chat_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
user = message.from_user
|
||||||
|
if user is None or user.is_bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Don't touch admins/owner.
|
||||||
|
try:
|
||||||
|
if await _is_admin(bot, chat_id, user.id):
|
||||||
|
return
|
||||||
|
except TelegramBadRequest:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Key rule for discuss comments:
|
||||||
|
# If user is not a member of the group -> delete their message.
|
||||||
|
status = await _get_member_status_cached(bot, chat_id, user.id)
|
||||||
|
if status in ("left", "kicked"):
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if status is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# For new joins: delete messages until verified.
|
||||||
|
if await db.is_verified(chat_id=chat_id, user_id=user.id):
|
||||||
|
return
|
||||||
|
|
||||||
|
pending = await db.get_pending(chat_id=chat_id, user_id=user.id)
|
||||||
|
if pending is None:
|
||||||
|
# Not in pending list => do not moderate.
|
||||||
|
# This prevents touching existing members in large chats,
|
||||||
|
# and only targets users caught at join time.
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def sweeper_loop(*, bot: Bot, cfg: Config, db: Database) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
now = int(time.time())
|
||||||
|
expired = await db.get_expired_pending(now=now)
|
||||||
|
for p in expired:
|
||||||
|
if not _is_allowed_chat(cfg, p.chat_id):
|
||||||
|
await db.delete_pending(chat_id=p.chat_id, user_id=p.user_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Delete the challenge message (if still exists)
|
||||||
|
try:
|
||||||
|
await bot.delete_message(p.chat_id, p.challenge_message_id)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Kick user for a new attempt.
|
||||||
|
await _kick_user(bot, p.chat_id, p.user_id)
|
||||||
|
|
||||||
|
await db.delete_pending(chat_id=p.chat_id, user_id=p.user_id)
|
||||||
|
|
||||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
aiofiles==24.1.0
|
||||||
|
aiogram==3.22.0
|
||||||
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.12.15
|
||||||
|
aiosignal==1.4.0
|
||||||
|
aiosqlite==0.22.1
|
||||||
|
annotated-types==0.7.0
|
||||||
|
async-timeout==5.0.1
|
||||||
|
attrs==26.1.0
|
||||||
|
certifi==2026.2.25
|
||||||
|
frozenlist==1.8.0
|
||||||
|
idna==3.11
|
||||||
|
magic-filter==1.0.12
|
||||||
|
multidict==6.7.1
|
||||||
|
propcache==0.4.1
|
||||||
|
pydantic==2.11.10
|
||||||
|
pydantic_core==2.33.2
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
yarl==1.22.0
|
||||||
Loading…
Add table
Reference in a new issue