From 9c9cd96a9432c079d214d07905bcd6679a58c34d Mon Sep 17 00:00:00 2001 From: profitroll Date: Thu, 10 Aug 2023 13:05:40 +0200 Subject: [PATCH] Initial commit --- .gitignore | 164 ++++++++++++++++++++++++++++++ .renovaterc | 6 ++ LICENSE | 21 ++++ README.md | 1 + classes/captcha.py | 11 ++ classes/pyroclient.py | 30 ++++++ classes/pyrouser.py | 104 +++++++++++++++++++ config_example.json | 109 ++++++++++++++++++++ locale/en.json | 19 ++++ locale/uk.json | 19 ++++ main.py | 36 +++++++ modules/database.py | 33 ++++++ modules/kicker.py | 31 ++++++ modules/scheduler.py | 3 + modules/utils.py | 51 ++++++++++ plugins/callbacks/ban.py | 43 ++++++++ plugins/callbacks/emoji_button.py | 115 +++++++++++++++++++++ plugins/callbacks/nothing.py | 9 ++ plugins/callbacks/verify.py | 71 +++++++++++++ plugins/handlers/user_join.py | 108 ++++++++++++++++++++ requirements.txt | 13 +++ 21 files changed, 997 insertions(+) create mode 100644 .gitignore create mode 100644 .renovaterc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 classes/captcha.py create mode 100644 classes/pyroclient.py create mode 100644 classes/pyrouser.py create mode 100644 config_example.json create mode 100644 locale/en.json create mode 100644 locale/uk.json create mode 100644 main.py create mode 100644 modules/database.py create mode 100644 modules/kicker.py create mode 100644 modules/scheduler.py create mode 100644 modules/utils.py create mode 100644 plugins/callbacks/ban.py create mode 100644 plugins/callbacks/emoji_button.py create mode 100644 plugins/callbacks/nothing.py create mode 100644 plugins/callbacks/verify.py create mode 100644 plugins/handlers/user_join.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4a0707 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Custom +config.json +*.session +*.session-journal + +venv +venv_linux +venv_windows + +.vscode \ No newline at end of file diff --git a/.renovaterc b/.renovaterc new file mode 100644 index 0000000..abb2f10 --- /dev/null +++ b/.renovaterc @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a59982 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Profitroll + +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..95bf839 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# EmojiCaptchaBot diff --git a/classes/captcha.py b/classes/captcha.py new file mode 100644 index 0000000..d943517 --- /dev/null +++ b/classes/captcha.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from io import BytesIO +from typing import List + + +@dataclass +class Captcha: + __slots__ = ("image", "emojis_all", "emojis_correct") + image: BytesIO + emojis_all: List[str] + emojis_correct: List[str] diff --git a/classes/pyroclient.py b/classes/pyroclient.py new file mode 100644 index 0000000..25cddd3 --- /dev/null +++ b/classes/pyroclient.py @@ -0,0 +1,30 @@ +from typing import Union + +from libbot.pyrogram.classes import PyroClient +from pyrogram.types import User + +from classes.pyrouser import PyroUser +from modules.database import col_users + + +class PyroClient(PyroClient): + async def find_user(self, user: Union[int, User], group: int) -> PyroUser: + """Find User by it's ID or User object + + ### Args: + * user (`Union[int, User]`): ID or User object to extract ID from + * group (`int`): ID of the group + + ### Returns: + * `PyroUser`: PyroUser object + """ + db_record = col_users.find_one( + {"id": user.id if isinstance(user, User) else user, "group": group} + ) + + if db_record is None: + raise KeyError( + f"User with ID {user.id if isinstance(user, User) else user} was not found in the database" + ) + + return PyroUser(**db_record) diff --git a/classes/pyrouser.py b/classes/pyrouser.py new file mode 100644 index 0000000..4eb1cd4 --- /dev/null +++ b/classes/pyrouser.py @@ -0,0 +1,104 @@ +import logging +from dataclasses import dataclass +from typing import List + +from bson import ObjectId + +from modules.database import col_users + +logger = logging.getLogger(__name__) + + +@dataclass +class PyroUser: + """Dataclass of DB entry of a user""" + + __slots__ = ( + "_id", + "id", + "group", + "failed", + "emojis", + "selected", + "score", + "mistakes", + ) + + _id: ObjectId + id: int + group: int + failed: int + emojis: List[str] + selected: List[str] + score: int + mistakes: int + + @classmethod + def create_if_not_exists( + cls, + id: int, + group: int, + failed: bool = False, + emojis: List[str] = [], + selected: List[str] = [], + score: int = 0, + mistakes: int = 0, + ): + db_entry = col_users.find_one({"id": id, "group": group}) + if db_entry is None: + inserted = col_users.insert_one( + { + "id": id, + "group": group, + "failed": failed, + "emojis": emojis, + "selected": selected, + "score": score, + "mistakes": mistakes, + } + ) + db_entry = { + "_id": inserted.inserted_id, + "id": id, + "group": group, + "failed": failed, + "emojis": emojis, + "selected": selected, + "score": 0, + "mistakes": mistakes, + } + return cls(**db_entry) + + def set_failed(self, failed: bool = True) -> None: + logger.debug("%s's failure state has been set to %s", self.id, failed) + col_users.update_one({"_id": self._id}, {"$set": {"failed": failed}}) + + def set_emojis(self, emojis: List[str]) -> None: + logger.debug("%s's emojis have been set to %s", self.id, emojis) + col_users.update_one({"_id": self._id}, {"$set": {"emojis": emojis}}) + + def set_score(self, score: int = 0) -> None: + logger.debug("%s's score has been set to %s", self.id, score) + col_users.update_one({"_id": self._id}, {"$set": {"score": score}}) + + def set_mistakes(self, mistakes: int = 0) -> None: + logger.debug("%s's mistakes count has been set to %s", self.id, mistakes) + col_users.update_one({"_id": self._id}, {"$set": {"mistakes": mistakes}}) + + def update_score(self, points: int = 1) -> None: + logger.debug("%s point(s) have been added to %s score", points, self.id) + col_users.update_one( + {"_id": self._id}, {"$set": {"score": self.score + points}} + ) + + def update_mistakes(self, points: int = 1) -> None: + logger.debug("%s point(s) have been added to %s mistakes", points, self.id) + col_users.update_one( + {"_id": self._id}, {"$set": {"mistakes": self.mistakes + points}} + ) + + def update_selected(self, entry: str) -> None: + logger.debug("Emoji %s has been added to %s's selection list", entry, self.id) + col_users.update_one( + {"_id": self._id}, {"$set": {"selected": self.selected + [entry]}} + ) diff --git a/config_example.json b/config_example.json new file mode 100644 index 0000000..9ec8686 --- /dev/null +++ b/config_example.json @@ -0,0 +1,109 @@ +{ + "locale": "en", + "bot": { + "owner": 0, + "api_id": 0, + "api_hash": "", + "bot_token": "", + "workers": 1, + "max_concurrent_transmissions": 1, + "scoped_commands": true + }, + "reports": { + "chat_id": "owner" + }, + "database": { + "user": null, + "password": null, + "host": "127.0.0.1", + "port": 27017, + "name": "captchabot" + }, + "timeouts": { + "join": 2, + "verify": 3 + }, + "whitelist": { + "enabled": false, + "groups": [] + }, + "disabled_plugins": [], + "emojis": [ + "🐻", + "🐔", + "☁️", + "🔮", + "🌀", + "🌚", + "💎", + "🐶", + "🍩", + "🌏", + "🐸", + "🌕", + "🐵", + "🌙", + "🐧", + "🍎", + "😀", + "🐍", + "❄️", + "🐚", + "🐢", + "🌝", + "🍺", + "🍔", + "🍒", + "🍫", + "💣", + "🍟", + "🍑", + "🍷", + "🍧", + "🍕", + "🍵", + "🐋", + "🐱", + "💄", + "👠", + "💰", + "💸", + "🎹", + "📦", + "📍", + "🐊", + "🦕", + "💋", + "🦎", + "🦈", + "🦷", + "🦖", + "🐠", + "🐟", + "💀", + "🎃", + "👮", + "⛑️", + "🪢", + "🧶", + "🧵", + "🪡", + "🥼", + "🥻", + "🎩", + "👑", + "🎒", + "🙊", + "🐗", + "🦋", + "🦐", + "🐀", + "🎻", + "🦔", + "🦦", + "🦫", + "🦡", + "🐇" + ], + "commands": {} +} \ No newline at end of file diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..a634cb4 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,19 @@ +{ + "messages": { + "verify": "Please, use the buttons below to complete the captcha. Tap on the buttons with emojis you see on this image.", + "welcome_verify": "Welcome, {mention}! In order to chat here, we need to verify you're a human. Please, press the button below to start the verification.", + "welcome": "Welcome to the chat, {mention}!" + }, + "callbacks": { + "captcha_failed": "You have failed the captcha.", + "captcha_failed_force": "You have forced {user_id} to fail the captcha.", + "captcha_mistake": "Invalid answer. Remaining attempts: {remaining}.", + "captcha_passed": "You have passed the captcha. Welcome!", + "nothing": "This actions has already been finished.", + "wrong_user": "This message is not for you." + }, + "buttons": { + "ban": "Ban (for admins)", + "verify": "Verify" + } +} \ No newline at end of file diff --git a/locale/uk.json b/locale/uk.json new file mode 100644 index 0000000..44e035b --- /dev/null +++ b/locale/uk.json @@ -0,0 +1,19 @@ +{ + "messages": { + "verify": "Будь ласка, використовуйте кнопки нижче, щоб заповнити капчу. Натисніть на кнопки зі смайликами, які ви бачите на цьому зображенні.", + "welcome_verify": "Ласкаво просимо, {mention}! Для того, щоб спілкуватися в чаті, ми повинні переконатися, що ви людина. Будь ласка, натисніть кнопку нижче, щоб почати перевірку.", + "welcome": "Ласкаво просимо до чату, {mention}!" + }, + "callbacks": { + "captcha_failed": "Ви провалили капчу.", + "captcha_failed_force": "Ви змусили {user_id} не пройти капчу.", + "captcha_mistake": "Неправильна відповідь. Спроби, що залишилися: {remaining}.", + "captcha_passed": "Ви пройшли капчу. Ласкаво просимо!", + "nothing": "Цю дію вже завершено.", + "wrong_user": "Це повідомлення не для вас." + }, + "buttons": { + "ban": "Заблокувати (для адмінів)", + "verify": "Верифікуватись" + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e98b71a --- /dev/null +++ b/main.py @@ -0,0 +1,36 @@ +import contextlib +import logging +from os import getpid + +from classes.pyroclient import PyroClient +from modules.scheduler import scheduler + +logging.basicConfig( + level=logging.INFO, + format="%(name)s.%(funcName)s | %(levelname)s | %(message)s", + datefmt="[%X]", +) + +logger = logging.getLogger(__name__) + +with contextlib.suppress(ImportError): + import uvloop + + uvloop.install() + + +def main(): + client = PyroClient(scheduler=scheduler) + + try: + client.run() + except KeyboardInterrupt: + logger.warning("Forcefully shutting down with PID %s...", getpid()) + finally: + if client.scheduler is not None: + client.scheduler.shutdown() + exit() + + +if __name__ == "__main__": + main() diff --git a/modules/database.py b/modules/database.py new file mode 100644 index 0000000..95992f9 --- /dev/null +++ b/modules/database.py @@ -0,0 +1,33 @@ +"""Module that provides all database columns""" + +from pymongo import MongoClient +from ujson import loads + +with open("config.json", "r", encoding="utf-8") as f: + db_config = loads(f.read())["database"] + f.close() + +if db_config["user"] is not None and db_config["password"] is not None: + con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format( + db_config["user"], + db_config["password"], + db_config["host"], + db_config["port"], + db_config["name"], + ) +else: + con_string = "mongodb://{0}:{1}/{2}".format( + db_config["host"], db_config["port"], db_config["name"] + ) + +db_client = MongoClient(con_string) +db = db_client.get_database(name=db_config["name"]) + +collections = db.list_collection_names() + +for collection in ["users", "schedule"]: + if collection not in collections: + db.create_collection(collection) + +col_users = db.get_collection("users") +col_schedule = db.get_collection("schedule") diff --git a/modules/kicker.py b/modules/kicker.py new file mode 100644 index 0000000..354d701 --- /dev/null +++ b/modules/kicker.py @@ -0,0 +1,31 @@ +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +async def kick_unstarted( + app: PyroClient, user_id: int, group_id: int, message_id: int +) -> None: + user = await app.find_user(user_id, group_id) + + if user.score == 0 and user.failed == 0: + banned = await app.ban_chat_member(group_id, user_id) + + if isinstance(banned, Message): + await banned.delete() + + await app.delete_messages(group_id, message_id) + + +async def kick_unverified( + app: PyroClient, user_id: int, group_id: int, message_id: int +) -> None: + user = await app.find_user(user_id, group_id) + + if user.score < 6 or user.failed: + banned = await app.ban_chat_member(group_id, user_id) + + if isinstance(banned, Message): + await banned.delete() + + await app.delete_messages(group_id, message_id) diff --git a/modules/scheduler.py b/modules/scheduler.py new file mode 100644 index 0000000..a5eb79d --- /dev/null +++ b/modules/scheduler.py @@ -0,0 +1,3 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +scheduler = AsyncIOScheduler() diff --git a/modules/utils.py b/modules/utils.py new file mode 100644 index 0000000..645bd52 --- /dev/null +++ b/modules/utils.py @@ -0,0 +1,51 @@ +from io import BytesIO +from pathlib import Path +from random import randint, sample +from typing import List + +from huepaper import generate +from PIL import Image + +from classes.captcha import Captcha + + +def get_captcha_image(emojis: List[str]) -> Captcha: + emojis_all = sample(emojis, 12) + emojis_correct = sample(emojis_all, 6) + + output = BytesIO() + + image_options = [ + (randint(400, 490), randint(90, 280), randint(105, 125)), + (randint(60, 180), randint(180, 240), randint(75, 95)), + (randint(320, 440), randint(170, 210), randint(35, 45)), + (randint(150, 240), randint(240, 320), randint(80, 105)), + (randint(350, 450), randint(280, 380), randint(40, 60)), + (randint(180, 350), randint(100, 300), randint(45, 65)), + ] + + base_img = generate( + width=500, + height=500, + hue_max=1.0, + lum_min=0.3, + lum_max=0.6, + sat_min=0.8, + sat_max=1.0, + ) + + for options, emoji in zip(image_options, emojis_correct): + width, height, angle = options + base_img.paste( + Image.open(Path(f"assets/emojis/{emoji}.png")) + .resize((120, 120)) + .rotate(angle), + ((base_img.width - width), (base_img.height - height)), + Image.open(Path(f"assets/emojis/{emoji}.png")) + .resize((120, 120)) + .rotate(angle), + ) + + base_img.save(output, format="jpeg") + + return Captcha(output, emojis_all, emojis_correct) diff --git a/plugins/callbacks/ban.py b/plugins/callbacks/ban.py new file mode 100644 index 0000000..c730ad4 --- /dev/null +++ b/plugins/callbacks/ban.py @@ -0,0 +1,43 @@ +import logging + +from pyrogram import filters +from pyrogram.enums.chat_member_status import ChatMemberStatus +from pyrogram.types import CallbackQuery, Message + +from classes.pyroclient import PyroClient + +logger = logging.getLogger(__name__) + + +@PyroClient.on_callback_query(filters.regex(r"ban;[\s\S]*")) +async def callback_ban(app: PyroClient, callback: CallbackQuery): + if ( + await app.get_chat_member(callback.message.chat.id, callback.from_user.id) + ).status not in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]: + await callback.answer(app._("wrong_user", "callbacks"), show_alert=True) + return + + user = await app.find_user( + int(str(callback.data).split(";")[1]), callback.message.chat.id + ) + + logger.info( + "User %s has been marked as failed the captcha by %s", + user.id, + callback.from_user.id, + ) + + user.set_mistakes(3) + user.set_failed(True) + await callback.answer( + app._("captcha_failed_force", "callbacks").format(user_id=user.id), + show_alert=True, + ) + + banned = await app.ban_chat_member(callback.message.chat.id, user.id) + + if isinstance(banned, Message): + await banned.delete() + + await callback.message.delete() + return diff --git a/plugins/callbacks/emoji_button.py b/plugins/callbacks/emoji_button.py new file mode 100644 index 0000000..1db2e0c --- /dev/null +++ b/plugins/callbacks/emoji_button.py @@ -0,0 +1,115 @@ +import logging + +from pyrogram import filters +from pyrogram.types import ( + CallbackQuery, + ChatPermissions, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, +) + +from classes.pyroclient import PyroClient + +logger = logging.getLogger(__name__) + + +@PyroClient.on_callback_query(filters.regex(r"emoji;[\s\S]*")) +async def callback_emoji_button(app: PyroClient, callback: CallbackQuery): + user_id = int(str(callback.data).split(";")[1]) + emoji = str(callback.data).split(";")[2] + + if callback.from_user.id != user_id: + await callback.answer(app._("wrong_user", "callbacks"), show_alert=True) + return + + user = await app.find_user(callback.from_user, callback.message.chat.id) + + logger.debug( + "User %s has pressed the %s emoji '%s'", + user.id, + "correct" if emoji in user.emojis else "wrong", + emoji, + ) + + if emoji in user.selected: + await callback.answer() + return + + user.update_selected(emoji) + + if emoji in user.emojis: + user.update_score(1) + + if user.score >= 5: + logger.info("User %s has passed the captcha", user.id) + + await callback.message.delete() + await callback.answer(app._("captcha_passed", "callbacks"), show_alert=True) + await app.send_message( + callback.message.chat.id, + app._( + "welcome", + "messages", + ).format(mention=callback.from_user.mention), + ) + await app.restrict_chat_member( + chat_id=callback.message.chat.id, + user_id=callback.from_user.id, + permissions=ChatPermissions(can_send_messages=True), + ) + return + + logger.info( + "User %s has chosen correctly and needs to select %s more emoji(s)", + user.id, + 5 - user.score, + ) + + emoji_placeholder = "✅" + else: + if user.mistakes >= 2: + logger.info("User %s has failed the captcha", user.id) + + user.set_failed(True) + await callback.answer(app._("captcha_failed", "callbacks"), show_alert=True) + + banned = await app.ban_chat_member( + callback.message.chat.id, callback.from_user.id + ) + + if isinstance(banned, Message): + await banned.delete() + + await callback.message.delete() + return + + user.update_mistakes(1) + logger.info( + "User %s has made a mistake and has %s attempt(s) left", + user.id, + 2 - user.mistakes, + ) + + await callback.answer( + app._("captcha_mistake", "callbacks").format(remaining=2 - user.mistakes), + show_alert=True, + ) + + emoji_placeholder = "🛑" + + button_replace = (0, 0) + + for row_index, row in enumerate(callback.message.reply_markup.inline_keyboard): + for button_index, button in enumerate(row): + if button.text == emoji: + button_replace = (row_index, button_index) + + new_keyboard = callback.message.reply_markup.inline_keyboard + new_keyboard[button_replace[0]][button_replace[1]] = InlineKeyboardButton( + emoji_placeholder, "nothing" + ) + + await callback.edit_message_reply_markup( + reply_markup=InlineKeyboardMarkup(new_keyboard) + ) diff --git a/plugins/callbacks/nothing.py b/plugins/callbacks/nothing.py new file mode 100644 index 0000000..4a6e2a2 --- /dev/null +++ b/plugins/callbacks/nothing.py @@ -0,0 +1,9 @@ +from pyrogram import filters +from pyrogram.types import CallbackQuery + +from classes.pyroclient import PyroClient + + +@PyroClient.on_callback_query(filters.regex(r"nothing")) +async def callback_nothing(app: PyroClient, callback: CallbackQuery): + await callback.answer(app._("nothing", "callbacks")) diff --git a/plugins/callbacks/verify.py b/plugins/callbacks/verify.py new file mode 100644 index 0000000..386c0af --- /dev/null +++ b/plugins/callbacks/verify.py @@ -0,0 +1,71 @@ +import contextlib +import logging +from datetime import datetime, timedelta + +from apscheduler.jobstores.base import JobLookupError +from pykeyboard import InlineButton, InlineKeyboard +from pyrogram import filters +from pyrogram.types import CallbackQuery + +from classes.pyroclient import PyroClient +from modules.database import col_schedule +from modules.kicker import kick_unverified +from modules.utils import get_captcha_image + +logger = logging.getLogger(__name__) + + +@PyroClient.on_callback_query(filters.regex(r"verify;[\s\S]*")) +async def callback_verify(app: PyroClient, callback: CallbackQuery): + user_id = int(str(callback.data).split(";")[1]) + + if callback.from_user.id != user_id: + await callback.answer(app._("wrong_user", "callbacks"), show_alert=True) + return + + user = await app.find_user(callback.from_user, callback.message.chat.id) + captcha = get_captcha_image(app.config["emojis"]) + + logger.info( + "Captcha for %s has been generated. All: %s, Correct: %s", + user.id, + captcha.emojis_all, + captcha.emojis_correct, + ) + + scheduled_job = col_schedule.find_one_and_delete( + {"user": user_id, "group": callback.message.chat.id} + ) + + if scheduled_job is not None and app.scheduler is not None: + with contextlib.suppress(JobLookupError): + app.scheduler.remove_job(scheduled_job["job_id"]) + + user.set_emojis(captcha.emojis_correct) + + buttons = [ + InlineButton(emoji, f"emoji;{user.id};{emoji}") for emoji in captcha.emojis_all + ] + + keyboard = InlineKeyboard(3) + keyboard.add(*buttons) + + await callback.message.delete() + + captcha_message = await app.send_photo( + callback.message.chat.id, + captcha.image, + caption=app._("verify", "messages"), + reply_markup=keyboard, + ) + + del captcha + + if app.scheduler is not None: + app.scheduler.add_job( + kick_unverified, + "date", + [app, user.id, callback.message.chat.id, captcha_message.id], + run_date=datetime.now() + + timedelta(seconds=app.config["timeouts"]["verify"]), + ) diff --git a/plugins/handlers/user_join.py b/plugins/handlers/user_join.py new file mode 100644 index 0000000..da66d0e --- /dev/null +++ b/plugins/handlers/user_join.py @@ -0,0 +1,108 @@ +import asyncio +import contextlib +import logging +from datetime import datetime, timedelta + +from pyrogram import filters +from pyrogram.types import ( + ChatPermissions, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, +) + +from classes.pyroclient import PyroClient +from classes.pyrouser import PyroUser +from modules.database import col_schedule +from modules.kicker import kick_unstarted + +logger = logging.getLogger(__name__) + + +@PyroClient.on_message(filters.new_chat_members & ~filters.me) +async def handler_user_join(app: PyroClient, message: Message): + if ( + app.config["whitelist"]["enabled"] + and message.chat.id not in app.config["whitelist"]["groups"] + ): + logger.info( + "User %s has joined the group %s, but it's not whitelisted, ignoring.", + message.from_user.id, + message.chat.id, + ) + return + + logger.info( + "User %s has joined the group %s", message.from_user.id, message.chat.id + ) + + await message.delete() + + # If user has already failed the test and joined once more + with contextlib.suppress(KeyError): + user = await app.find_user(message.from_user, message.chat.id) + if user.failed is True: + logger.info( + "User %s has previously failed the captcha, kicking and banning him", + user.id, + ) + banned = await app.ban_chat_member(message.chat.id, user.id) + + if isinstance(banned, Message): + await banned.delete() + + return + + await app.restrict_chat_member( + chat_id=message.chat.id, + user_id=message.from_user.id, + permissions=ChatPermissions(can_send_messages=False), + ) + + user = PyroUser.create_if_not_exists(message.from_user.id, message.chat.id) + + if user.mistakes > 0 or user.score > 0: + user.set_score(0) + user.set_mistakes(0) + + await asyncio.sleep(2) + verification_request = await app.send_message( + chat_id=message.chat.id, + text=app._( + "welcome", + "messages", + ).format(mention=message.from_user.mention), + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + app._( + "verify", + "buttons", + ), + callback_data=f"verify;{message.from_user.id}", + ) + ], + [ + InlineKeyboardButton( + app._( + "ban", + "buttons", + ), + callback_data=f"ban;{message.from_user.id}", + ) + ], + ], + ), + ) + + if app.scheduler is not None: + job = app.scheduler.add_job( + kick_unstarted, + "date", + [app, user.id, verification_request.chat.id, verification_request.id], + run_date=datetime.now() + timedelta(seconds=app.config["timeouts"]["join"]), + ) + col_schedule.insert_one( + {"user": message.from_user.id, "group": message.chat.id, "job_id": job.id} + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..447d91b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +aiofiles~=23.1.0 +apscheduler~=3.10.1 +black~=23.7.0 +Pillow~=10.0.0 +pykeyboard==0.1.5 +pymongo==4.4.1 +pyrogram==2.0.106 +tgcrypto==1.2.5 +ujson==5.8.0 +uvloop==0.17.0 +--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple +huepaper==0.0.3 +libbot[speed,pyrogram]==2.0.0 \ No newline at end of file