commit 7b5c71d83287cf545aa90ead979c51e2d36ee426 Author: Kaki Filem Team Date: Sat Jan 31 20:35:11 2026 +0800 Initial commit 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