From 89c472708b48006ddc78b997363fe71b900ece84 Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Fri, 10 Apr 2026 16:28:22 +0200 Subject: [PATCH] Initial release: Telegram anti-spam bot Includes join verification button, discuss-comment cleanup for non-members, and SQLite persistence. Made-with: Cursor --- .env.example | 9 ++ .gitignore | 19 +++ README.md | 27 ++++ bot/__init__.py | 1 + bot/__main__.py | 41 ++++++ bot/config.py | 45 +++++++ bot/db.py | 164 ++++++++++++++++++++++++ bot/handlers.py | 315 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 21 ++++ 9 files changed, 642 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot/__init__.py create mode 100644 bot/__main__.py create mode 100644 bot/config.py create mode 100644 bot/db.py create mode 100644 bot/handlers.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8bb8ed --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cd45a7 --- /dev/null +++ b/.gitignore @@ -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-* diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bcfe21 --- /dev/null +++ b/README.md @@ -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`), один файл. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/bot/__main__.py b/bot/__main__.py new file mode 100644 index 0000000..64fb811 --- /dev/null +++ b/bot/__main__.py @@ -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()) + diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..8002812 --- /dev/null +++ b/bot/config.py @@ -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, + ) diff --git a/bot/db.py b/bot/db.py new file mode 100644 index 0000000..970fc47 --- /dev/null +++ b/bot/db.py @@ -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 + ] diff --git a/bot/handlers.py b/bot/handlers.py new file mode 100644 index 0000000..de85eb6 --- /dev/null +++ b/bot/handlers.py @@ -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) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..72d76d3 --- /dev/null +++ b/requirements.txt @@ -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