From 7b5c71d83287cf545aa90ead979c51e2d36ee426 Mon Sep 17 00:00:00 2001 From: Kaki Filem Team Date: Sat, 31 Jan 2026 20:35:11 +0800 Subject: [PATCH] Initial commit --- .gitignore | 4 ++ Dockerfile | 10 ++++ LICENSE | 21 ++++++++ README.md | 85 +++++++++++++++++++++++++++++++ config.py | 7 +++ main.py | 33 ++++++++++++ requirements.txt | 2 + routers/__init__.py | 0 routers/join.py | 100 +++++++++++++++++++++++++++++++++++++ routers/service_cleanup.py | 14 ++++++ routers/verify.py | 46 +++++++++++++++++ services/__init__.py | 0 services/restrict.py | 28 +++++++++++ 13 files changed, 350 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 routers/__init__.py create mode 100644 routers/join.py create mode 100644 routers/service_cleanup.py create mode 100644 routers/verify.py create mode 100644 services/__init__.py create mode 100644 services/restrict.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..073eee8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +venv/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca24e68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c322d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Aman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cd981f --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Telegram Gatekeeper Bot + +A Telegram bot that verifies new users when they join a group and +automatically removes unverified users after a timeout. + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/telegram-gatekeeper-bot?referralCode=nIQTyp&utm_medium=integration&utm_source=template&utm_campaign=generic) + +--- + +## โœจ Features + +- ๐Ÿ›ก๏ธ Restricts new users on join (supergroups) +- ๐Ÿ˜€ Emoji-based human verification +- โฑ๏ธ Auto-kicks users who fail to verify in time +- ๐Ÿ‘ฎ Admins are automatically bypassed +- ๐Ÿงน Cleans up join / leave / kick service messages +- ๐Ÿ”„ Automatically detects group type (supergroup vs normal group) + +--- + +## โ„น๏ธ Group Type Behavior + +Telegram has different capabilities for normal groups and supergroups. +This bot automatically detects the group type and adjusts its behavior. + +- โœ… **Supergroup** โ†’ Full gatekeeper protection (verification, timeout, cleanup) +- โš ๏ธ **Normal group** โ†’ Limited mode (no user restrictions) + +To enable full protection, convert your group into a supergroup: +- Make the group public (set a username), or +- Let Telegram auto-upgrade it automatically + +## ๐Ÿš€ Deploy on Railway + +Click the button below to deploy instantly: + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/telegram-gatekeeper-bot?referralCode=nIQTyp&utm_medium=integration&utm_source=template&utm_campaign=generic) + +### Required Environment Variables + +| Variable | Description | +|--------|------------| +| BOT_TOKEN | Telegram bot token from @BotFather | +| VERIFY_TIMEOUT | Seconds before kicking unverified users (default: 60) | + +--- + +## ๐Ÿค– Bot Setup + +1. Create a bot using **@BotFather** +2. Copy the bot token +3. Deploy this project on Railway +4. Set the BOT_TOKEN environment variable +5. Add the bot to your Telegram group as **admin** +6. Grant permissions: + - Restrict users + - Ban users + - Delete messages + +--- + +## ๐Ÿงช How It Works + +1. User joins the group +2. Bot restricts the user +3. Emoji verification message is sent +4. User clicks the correct emoji +5. Restrictions are removed +6. If the user does nothing โ†’ bot kicks them after the timeout + +--- + +## ๐Ÿ› ๏ธ Local Development (Optional) + +```bash +pip install -r requirements.txt +export BOT_TOKEN=your_token_here +python main.py +``` + +--- + +## ๐Ÿ“œ License + +MIT License diff --git a/config.py b/config.py new file mode 100644 index 0000000..b0092e9 --- /dev/null +++ b/config.py @@ -0,0 +1,7 @@ +import os + +BOT_TOKEN = os.getenv("BOT_TOKEN") +VERIFY_TIMEOUT = int(os.getenv("VERIFY_TIMEOUT", "60")) + +if not BOT_TOKEN: + raise RuntimeError("BOT_TOKEN is required") diff --git a/main.py b/main.py new file mode 100644 index 0000000..9b81149 --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +import asyncio +import logging +from aiogram import Bot, Dispatcher +from routers import join, verify, service_cleanup + +from config import BOT_TOKEN +from routers import join, verify + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", +) + +logging.getLogger("aiogram").setLevel(logging.WARNING) +logging.getLogger("aiogram.event").setLevel(logging.WARNING) +logging.getLogger("aiogram.dispatcher").setLevel(logging.WARNING) +logging.getLogger("aiogram.client").setLevel(logging.WARNING) + +async def main(): + logging.info("Starting Gatekeeper Bot...") + + bot = Bot(BOT_TOKEN) + dp = Dispatcher() + + dp.include_router(join.router) + dp.include_router(verify.router) + dp.include_router(service_cleanup.router) + + logging.info("Bot started. Waiting for updates...") + await dp.start_polling(bot) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3bf9529 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiogram==3.23.0 +python-dotenv==1.2.1 \ No newline at end of file diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/join.py b/routers/join.py new file mode 100644 index 0000000..d776b8d --- /dev/null +++ b/routers/join.py @@ -0,0 +1,100 @@ +import asyncio +import random +import logging +from aiogram import Router +from config import VERIFY_TIMEOUT +from aiogram.types import ChatMemberUpdated, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.enums import ChatMemberStatus + +from services.restrict import restrict + +router = Router() + +pending = {} + +EMOJIS = ["๐Ÿ”ฅ", "๐Ÿ•", "๐Ÿถ", "๐Ÿš€", "๐ŸŽฏ", "๐Ÿ€", "โญ"] + +@router.chat_member() +async def on_chat_member_update(event: ChatMemberUpdated): + old = event.old_chat_member.status + new = event.new_chat_member.status + chat_type = event.chat.type + + logging.info(f"Bot mode: {'FULL' if event.chat.type=='supergroup' else 'LIMITED'}") + + if new in (ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR): + logging.info("Admin joined โ†’ bypass") + return + + if chat_type != "supergroup": + logging.info("Normal group detected โ†’ limited mode") + return + + if old in (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED) and new == ChatMemberStatus.MEMBER: + user = event.new_chat_member.user + chat_id = event.chat.id + + logging.info(f"New user joined (gatekeeper): {user.id}") + + await restrict(event.bot, chat_id, user.id) + + correct = random.choice(EMOJIS) + wrong = random.sample([e for e in EMOJIS if e != correct], 2) + options = wrong + [correct] + random.shuffle(options) + + kb = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=e, + callback_data=f"verify:{user.id}:{e}" + ) for e in options + ] + ] + ) + + msg = await event.bot.send_message( + chat_id, + ( + f"๐Ÿ‘‹ Welcome {user.mention_html()}!\n\n" + f"To continue, please tap the {correct} emoji below.\n" + f"This helps keep spam out ๐Ÿค–๐Ÿšซ" + ), + reply_markup=kb, + parse_mode="HTML", + ) + + pending[(chat_id, user.id)] = { + "message_id": msg.message_id, + "answer": correct, + } + + asyncio.create_task( + verification_timeout(event.bot, chat_id, user.id, timeout=VERIFY_TIMEOUT) + ) + + logging.info(f"Verification timeout started for {user.id}") + +async def verification_timeout(bot, chat_id: int, user_id: int, timeout: int = 60): + await asyncio.sleep(timeout) + + key = (chat_id, user_id) + data = pending.get(key) + if not data: + return + + logging.info(f"Verification timeout โ†’ kick {user_id}") + + try: + await bot.ban_chat_member(chat_id, user_id) + await bot.unban_chat_member(chat_id, user_id) + except: + pass + + try: + await bot.delete_message(chat_id, data["message_id"]) + except: + pass + + del pending[key] diff --git a/routers/service_cleanup.py b/routers/service_cleanup.py new file mode 100644 index 0000000..055651b --- /dev/null +++ b/routers/service_cleanup.py @@ -0,0 +1,14 @@ +from aiogram import Router +from aiogram.types import Message + +router = Router() + +join_messages = {} + +@router.message() +async def capture_service_messages(msg: Message): + if msg.new_chat_members: + join_messages[msg.chat.id] = msg.message_id + + elif msg.left_chat_member: + join_messages[msg.chat.id] = msg.message_id diff --git a/routers/verify.py b/routers/verify.py new file mode 100644 index 0000000..7cb8d71 --- /dev/null +++ b/routers/verify.py @@ -0,0 +1,46 @@ +from aiogram import Router +from aiogram.types import CallbackQuery + +from services.restrict import unrestrict +from routers.join import pending +from routers.service_cleanup import join_messages + +router = Router() + +@router.callback_query(lambda c: c.data.startswith("verify:")) +async def verify_user(call: CallbackQuery): + _, user_id, emoji = call.data.split(":") + user_id = int(user_id) + chat_id = call.message.chat.id + + if call.from_user.id != user_id: + await call.answer("This button is not for you.", show_alert=True) + return + + key = (chat_id, user_id) + data = pending.get(key) + + if not data: + await call.answer("Verification expired.", show_alert=True) + return + + if emoji != data["answer"]: + await call.answer("โŒ Wrong emoji. Try again.", show_alert=True) + return + + del pending[key] + + await call.answer("โœ… Verified! You can now chat.") + await unrestrict(call.bot, chat_id, user_id) + + try: + await call.message.delete() + except: + pass + + join_msg = join_messages.pop(chat_id, None) + if join_msg: + try: + await call.bot.delete_message(chat_id, join_msg) + except: + pass diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/restrict.py b/services/restrict.py new file mode 100644 index 0000000..2c7f017 --- /dev/null +++ b/services/restrict.py @@ -0,0 +1,28 @@ +from aiogram import Bot +from aiogram.types import ChatPermissions + +READ_ONLY = ChatPermissions( + can_send_messages=False, + can_send_media_messages=False, + can_send_polls=False, + can_send_other_messages=False, +) + +FULL = ChatPermissions( + can_send_messages=True, + can_send_media_messages=True, + can_send_polls=True, + can_send_other_messages=True, +) + +async def restrict(bot: Bot, chat_id: int, user_id: int): + try: + await bot.restrict_chat_member(chat_id, user_id, READ_ONLY) + except Exception: + pass + +async def unrestrict(bot: Bot, chat_id: int, user_id: int): + try: + await bot.restrict_chat_member(chat_id, user_id, FULL) + except Exception: + pass