From 32db859d7e67be460862037ddd0ff5d91514f002 Mon Sep 17 00:00:00 2001 From: thedragonsinn <98635854+thedragonsinn@users.noreply.github.com> Date: Mon, 25 Sep 2023 18:15:38 +0530 Subject: [PATCH] Initial Commit. --- .gitignore | 5 ++ Dockerfile | 22 +++++ app/__init__.py | 15 ++++ app/__main__.py | 8 ++ app/config.py | 23 +++++ app/core/__init__.py | 3 + app/core/client/client.py | 125 +++++++++++++++++++++++++++ app/core/client/filters.py | 22 +++++ app/core/client/handler.py | 50 +++++++++++ app/core/db.py | 25 ++++++ app/core/types/callback_query.py | 26 ++++++ app/core/types/message.py | 128 ++++++++++++++++++++++++++++ app/plugins/admin_tools.py | 112 ++++++++++++++++++++++++ app/plugins/dev_tools.py | 142 +++++++++++++++++++++++++++++++ app/plugins/tg_utils.py | 88 +++++++++++++++++++ app/plugins/utils.py | 64 ++++++++++++++ app/utils/aiohttp_tools.py | 77 +++++++++++++++++ app/utils/db_utils.py | 21 +++++ app/utils/helpers.py | 7 ++ app/utils/shell.py | 47 ++++++++++ docker_start_cmd | 9 ++ req.txt | 10 +++ run | 18 ++++ sample-config.env | 19 +++++ 24 files changed, 1066 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/client/client.py create mode 100644 app/core/client/filters.py create mode 100644 app/core/client/handler.py create mode 100644 app/core/db.py create mode 100644 app/core/types/callback_query.py create mode 100644 app/core/types/message.py create mode 100644 app/plugins/admin_tools.py create mode 100644 app/plugins/dev_tools.py create mode 100644 app/plugins/tg_utils.py create mode 100644 app/plugins/utils.py create mode 100644 app/utils/aiohttp_tools.py create mode 100644 app/utils/db_utils.py create mode 100644 app/utils/helpers.py create mode 100644 app/utils/shell.py create mode 100644 docker_start_cmd create mode 100644 req.txt create mode 100755 run create mode 100644 sample-config.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dff707c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +config*.env +*session* +venv/ +__pycache__ +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb21955 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11.2 + +WORKDIR /app/ + +COPY req.txt . + +RUN sed -i.bak 's/us-west-2\.ec2\.//' /etc/apt/sources.list && \ + apt -qq update && apt -qq upgrade -y && \ + apt -qq install -y --no-install-recommends \ + apt-utils \ + curl \ + git \ + wget && \ + pip install -U pip setuptools wheel && \ + pip install -r req.txt && \ + rm req.txt && \ + git config --global user.email "98635854+thedragonsinn@users.noreply.github.com" && \ + git config --global user.name "thedragonsinn" + +EXPOSE 8080 + +CMD bash -c "$(curl -fsSL https://raw.githubusercontent.com/thedragonsinn/plain-ub/main/docker_start_cmd)" \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..99dcd42 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,15 @@ +import os + +from dotenv import load_dotenv +load_dotenv("config.env") + +from app.config import Config +from app.core.db import DB +from app.core.client.client import BOT + +if "com.termux" not in os.environ.get("PATH", ""): + import uvloop + + uvloop.install() + +bot = BOT() diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..97df1fd --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,8 @@ +if __name__ == "__main__": + import tracemalloc + + tracemalloc.start() + + from app import bot + + bot.run(bot.boot()) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..feba87e --- /dev/null +++ b/app/config.py @@ -0,0 +1,23 @@ +import json +import os +from typing import Coroutine + + +class Config: + CMD_DICT: dict["str", Coroutine] = {} + + CALLBACK_DICT: dict["str", Coroutine] = {} + + DEV_MODE: int = int(os.environ.get("DEV_MODE", 0)) + + DB_URL: str = os.environ.get("DB_URL") + + LOG_CHAT: int = int(os.environ.get("LOG_CHAT")) + + TRIGGER: str = os.environ.get("TRIGGER", ".") + + USERS: list[int] = json.loads(os.environ.get("USERS", "[]")) + + UPSTREAM_REPO: str = os.environ.get( + "UPSTREAM_REPO", "https://github.com/thedragonsinn/plain-ub" + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..6ef3717 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,3 @@ +from app.core.client import filters +from app.core.types.callback_query import CallbackQuery +from app.core.types.message import Message diff --git a/app/core/client/client.py b/app/core/client/client.py new file mode 100644 index 0000000..e0e0d49 --- /dev/null +++ b/app/core/client/client.py @@ -0,0 +1,125 @@ +import glob +import importlib +import os +import sys +from functools import wraps +from io import BytesIO + +from pyrogram import Client, idle +from pyrogram.enums import ParseMode +from pyrogram.types import Message as Msg + +from app import DB, Config +from app.core import Message +from app.utils import aiohttp_tools + + +async def import_modules(): + for py_module in glob.glob(pathname="app/**/*.py", recursive=True): + name = os.path.splitext(py_module)[0] + py_name = name.replace("/", ".") + importlib.import_module(py_name) + + +class BOT(Client): + def __init__(self): + super().__init__( + name="bot", + api_id=int(os.environ.get("API_ID")), + api_hash=os.environ.get("API_HASH"), + session_string=os.environ.get("SESSION_STRING"), + in_memory=True, + parse_mode=ParseMode.DEFAULT, + sleep_threshold=30, + max_concurrent_transmissions=2, + ) + + @staticmethod + def add_cmd(cmd: str, cb: bool = False): + def the_decorator(func): + @wraps(func) + def wrapper(): + config_dict = Config.CMD_DICT + if cb: + config_dict = Config.CALLBACK_DICT + if isinstance(cmd, list): + for _cmd in cmd: + config_dict[_cmd] = func + else: + config_dict[cmd] = func + + wrapper() + return func + + return the_decorator + + async def boot(self) -> None: + await super().start() + await import_modules() + await aiohttp_tools.session_switch() + await self.edit_restart_msg() + print("started") + await self.log(text="Started") + await idle() + await aiohttp_tools.session_switch() + DB._client.close() + + async def edit_restart_msg(self) -> None: + restart_msg = int(os.environ.get("RESTART_MSG", 0)) + restart_chat = int(os.environ.get("RESTART_CHAT", 0)) + if restart_msg and restart_chat: + await super().get_chat(restart_chat) + await super().edit_message_text( + chat_id=restart_chat, message_id=restart_msg, text="__Started__" + ) + os.environ.pop("RESTART_MSG", "") + os.environ.pop("RESTART_CHAT", "") + + async def log( + self, + text="", + traceback="", + chat=None, + func=None, + message: Message | Msg | None = None, + name="log.txt", + disable_web_page_preview=True, + parse_mode=ParseMode.HTML, + ) -> Message | Msg: + if message: + return await message.copy(chat_id=Config.LOG_CHAT) + if traceback: + text = f""" +#Traceback +Function: {func} +Chat: {chat} +Traceback: +{traceback}""" + return await self.send_message( + chat_id=Config.LOG_CHAT, + text=text, + name=name, + disable_web_page_preview=disable_web_page_preview, + parse_mode=parse_mode, + ) + + async def restart(self, hard=False) -> None: + await aiohttp_tools.session_switch() + await super().stop(block=False) + DB._client.close() + if hard: + os.execl("/bin/bash", "/bin/bash", "run") + os.execl(sys.executable, sys.executable, "-m", "app") + + async def send_message( + self, chat_id: int | str, text, name: str = "output.txt", **kwargs + ) -> Message | Msg: + text = str(text) + if len(text) < 4096: + return Message.parse_message( + (await super().send_message(chat_id=chat_id, text=text, **kwargs)) + ) + doc = BytesIO(bytes(text, encoding="utf-8")) + doc.name = name + kwargs.pop("disable_web_page_preview", "") + return await super().send_document(chat_id=chat_id, document=doc, **kwargs) diff --git a/app/core/client/filters.py b/app/core/client/filters.py new file mode 100644 index 0000000..e05e1e5 --- /dev/null +++ b/app/core/client/filters.py @@ -0,0 +1,22 @@ +from pyrogram import filters as _filters + +from app import Config + + +def dynamic_cmd_filter(_, __, message) -> bool: + if ( + not message.text + or not message.text.startswith(Config.TRIGGER) + or not message.from_user + or message.from_user.id not in Config.USERS + ): + return False + + start_str = message.text.split(maxsplit=1)[0] + cmd = start_str.replace(Config.TRIGGER, "", 1) + cmd_check = cmd in Config.CMD_DICT + reaction_check = not message.reactions + return bool(cmd_check and reaction_check) + + +cmd_filter = _filters.create(dynamic_cmd_filter) diff --git a/app/core/client/handler.py b/app/core/client/handler.py new file mode 100644 index 0000000..6eeab4e --- /dev/null +++ b/app/core/client/handler.py @@ -0,0 +1,50 @@ +import asyncio +import traceback +from datetime import datetime + +from pyrogram.enums import ChatType + +from app import DB, Config, bot +from app.core import CallbackQuery, Message, filters + + +@bot.on_message(filters.cmd_filter) +@bot.on_edited_message(filters.cmd_filter) +async def cmd_dispatcher(bot, message) -> None: + message = Message.parse_message(message) + func = Config.CMD_DICT[message.cmd] + coro = func(bot, message) + await run_coro(coro, message) + + +@bot.on_callback_query() +async def callback_handler(bot: bot, cb): + if ( + cb.message.chat.type == ChatType.PRIVATE + and (datetime.now() - cb.message.date).total_seconds() > 30 + ): + return await cb.edit_message_text(f"Query Expired. Try again.") + banned = await DB.BANNED.find_one({"_id": cb.from_user.id}) + if banned: + return + cb = CallbackQuery.parse_cb(cb) + func = Config.CALLBACK_DICT.get(cb.cmd) + if not func: + return + coro = func(bot, cb) + await run_coro(coro, Message.parse_message(cb.message)) + + +async def run_coro(coro, message) -> None: + try: + task = asyncio.Task(coro, name=message.task_id) + await task + except asyncio.exceptions.CancelledError: + await bot.log(text=f"#Cancelled:\n{message.text}") + except BaseException: + await bot.log( + traceback=str(traceback.format_exc()), + chat=message.chat.title or message.chat.first_name, + func=coro.__name__, + name="traceback.txt", + ) diff --git a/app/core/db.py b/app/core/db.py new file mode 100644 index 0000000..5b1d89a --- /dev/null +++ b/app/core/db.py @@ -0,0 +1,25 @@ +import dns.resolver +from motor.core import AgnosticClient, AgnosticCollection, AgnosticDatabase +from motor.motor_asyncio import AsyncIOMotorClient + +from app import Config + +dns.resolver.default_resolver = dns.resolver.Resolver(configure=False) +dns.resolver.default_resolver.nameservers = ["8.8.8.8"] + + +class DataBase: + def __init__(self): + self._client: AgnosticClient = AsyncIOMotorClient(Config.DB_URL) + self.db: AgnosticDatabase = self._client["plain_ub"] + self.SUDO_USERS: AgnosticCollection = self.db.USERS + + def __getattr__(self, attr) -> AgnosticCollection: + try: + return self.__dict__[attr] + except KeyError: + self.__dict__[attr] = self.db[attr] + return self.__dict__[attr] + + +DB = DataBase() diff --git a/app/core/types/callback_query.py b/app/core/types/callback_query.py new file mode 100644 index 0000000..ddbebff --- /dev/null +++ b/app/core/types/callback_query.py @@ -0,0 +1,26 @@ +import json +from functools import cached_property + +from pyrogram.types import CallbackQuery as Callback_Query + + +class CallbackQuery(Callback_Query): + def __init__(self, query: Callback_Query): + super().__dict__.update(query.__dict__) + + @cached_property + def cmd(self) -> str | None: + return self.cb_data.get("cmd") + + @cached_property + def cb_data(self) -> dict: + if not self.data: + return {} + try: + return json.loads(self.data.replace("'", '"')) + except BaseException: + return {} + + @classmethod + def parse_cb(cls, cb) -> "CallbackQuery": + return cls(cb) diff --git a/app/core/types/message.py b/app/core/types/message.py new file mode 100644 index 0000000..1956118 --- /dev/null +++ b/app/core/types/message.py @@ -0,0 +1,128 @@ +import asyncio +from functools import cached_property + +from pyrogram.errors import MessageDeleteForbidden +from pyrogram.types import Message as Msg +from pyrogram.types import User + +from app import Config + + +class Message(Msg): + def __init__(self, message: Msg) -> None: + super().__dict__.update(message.__dict__) + + @cached_property + def cmd(self) -> str | None: + raw_cmd = self.text_list[0] + cmd = raw_cmd.lstrip(Config.TRIGGER) + return cmd if cmd in Config.CMD_DICT else None + + @cached_property + def flags(self) -> list: + return [i for i in self.text_list if i.startswith("-")] + + @cached_property + def flt_input(self) -> str: + split_lines = self.input.splitlines() + split_n_joined = [ + " ".join([word for word in line.split(" ") if word not in self.flags]) + for line in split_lines + ] + return "\n".join(split_n_joined) + + @cached_property + def input(self) -> str: + if len(self.text_list) > 1: + return self.text.split(maxsplit=1)[-1] + return "" + + @cached_property + def replied(self) -> "Message": + if self.reply_to_message: + return Message.parse_message(self.reply_to_message) + + @cached_property + def reply_id(self) -> int | None: + return self.replied.id if self.replied else None + + @cached_property + def replied_task_id(self) -> str | None: + return self.replied.task_id if self.replied else None + + @cached_property + def task_id(self) -> str: + return f"{self.chat.id}-{self.id}" + + @cached_property + def text_list(self) -> list: + return self.text.split() + + async def async_deleter(self, del_in, task, block) -> None: + if block: + x = await task + await asyncio.sleep(del_in) + await x.delete() + else: + asyncio.create_task( + self.async_deleter(del_in=del_in, task=task, block=True) + ) + + async def delete(self, reply: bool = False) -> None: + try: + await super().delete() + if reply and self.replied: + await self.replied.delete() + except MessageDeleteForbidden: + pass + + async def edit(self, text, del_in: int = 0, block=True, **kwargs) -> "Message": + if len(str(text)) < 4096: + kwargs.pop("name", "") + task = self.edit_text(text, **kwargs) + if del_in: + reply = await self.async_deleter(task=task, del_in=del_in, block=block) + else: + reply = await task + else: + _, reply = await asyncio.gather( + super().delete(), self.reply(text, **kwargs) + ) + return reply + + async def extract_user_n_reason(self) -> list[User | str | Exception, str | None]: + if self.replied: + return [self.replied.from_user, self.flt_input] + inp_list = self.flt_input.split(maxsplit=1) + if not inp_list: + return [ + "Unable to Extract User info.\nReply to a user or input @ | id.", + "", + ] + user = inp_list[0] + reason = None + if len(inp_list) >= 2: + reason = inp_list[1] + if user.isdigit(): + user = int(user) + elif user.startswith("@"): + user = user.strip("@") + try: + return [await self._client.get_users(user_ids=user), reason] + except Exception as e: + return [e, reason] + + async def reply( + self, text, del_in: int = 0, block: bool = True, **kwargs + ) -> "Message": + task = self._client.send_message( + chat_id=self.chat.id, text=text, reply_to_message_id=self.id, **kwargs + ) + if del_in: + await self.async_deleter(task=task, del_in=del_in, block=block) + else: + return await task + + @classmethod + def parse_message(cls, message: Msg) -> "Message": + return cls(message) diff --git a/app/plugins/admin_tools.py b/app/plugins/admin_tools.py new file mode 100644 index 0000000..0232039 --- /dev/null +++ b/app/plugins/admin_tools.py @@ -0,0 +1,112 @@ +from typing import Awaitable + +from pyrogram.types import ChatPermissions, ChatPrivileges, User + +from app import bot +from app.core import Message + + +def get_privileges( + anon: bool = False, full: bool = False, demote: bool = False +) -> ChatPrivileges: + if demote: + return ChatPrivileges( + can_manage_chat=False, + can_manage_video_chats=False, + can_pin_messages=False, + can_delete_messages=False, + can_change_info=False, + can_restrict_members=False, + can_invite_users=False, + can_promote_members=False, + is_anonymous=False, + ) + return ChatPrivileges( + can_manage_chat=True, + can_manage_video_chats=True, + can_pin_messages=True, + can_delete_messages=True, + can_change_info=True, + can_restrict_members=True, + can_invite_users=True, + can_promote_members=full, + is_anonymous=anon, + ) + + +@bot.add_cmd(cmd=["promote", "demote"]) +async def promote_or_demote(bot: bot, message: Message) -> None: + user, title = await message.extract_user_n_reason() + if not isinstance(user, User): + await message.reply(user, del_in=10) + return + full: bool = "-f" in message.flags + anon: bool = "-anon" in message.flags + demote = message.cmd == "demote" + privileges: ChatPrivileges = get_privileges(full=full, anon=anon, demote=demote) + response = f"{message.cmd.capitalize()}d: {user.mention}" + try: + await bot.promote_chat_member( + chat_id=message.chat.id, user_id=user.id, privileges=privileges + ) + if not demote: + await bot.set_administrator_title( + chat_id=message.chat.id, user_id=user.id, title=title or "Admin" + ) + if title: + response += f"\nTitle: {title}." + await message.reply(text=response) + except Exception as e: + await message.reply(text=e, del_in=10, block=True) + + +@bot.add_cmd(cmd=["ban", "unban"]) +async def ban_or_unban(bot: bot, message: Message) -> None: + user, reason = await message.extract_user_n_reason() + if not isinstance(user, User): + await message.reply(user, del_in=10) + return + if message.cmd == "ban": + action: Awaitable = bot.ban_chat_member( + chat_id=message.chat.id, user_id=user.id + ) + else: + action: Awaitable = bot.unban_chat_member( + chat_id=message.chat.id, user_id=user.id + ) + try: + await action + await message.reply( + text=f"{message.cmd.capitalize()}ed: {user.mention}\nReason: {reason}." + ) + except Exception as e: + await message.reply(text=e, del_in=10) + + +@bot.add_cmd(cmd=["mute", "unmute"]) +async def mute_or_unmute(bot: bot, message: Message): + user, reason = await message.extract_user_n_reason() + if not isinstance(user, User): + await message.reply(user, del_in=10) + return + perms = message.chat.permissions + word = "Unmuted" + if message.cmd == "mute": + perms = ChatPermissions( + can_send_messages=False, + can_pin_messages=False, + can_invite_users=False, + can_change_info=False, + can_send_media_messages=False, + can_send_polls=False, + can_send_other_messages=False, + can_add_web_page_previews=False, + ) + word = "Muted" + try: + await bot.restrict_chat_member( + chat_id=message.chat.id, user_id=user.id, permissions=perms + ) + await message.reply(text=f"{word}: {user.mention}\nReason: {reason}.") + except Exception as e: + await message.reply(text=e, del_in=10) diff --git a/app/plugins/dev_tools.py b/app/plugins/dev_tools.py new file mode 100644 index 0000000..08c41bd --- /dev/null +++ b/app/plugins/dev_tools.py @@ -0,0 +1,142 @@ +import asyncio +import importlib +import sys +import traceback +from io import StringIO + +from pyrogram.enums import ParseMode + +from app.core import Message + +from app import Config, bot, DB # isort:skip +from app.utils import shell, aiohttp_tools as aio # isort:skip + + +# Run shell commands +async def run_cmd(bot: bot, message: Message) -> Message | None: + cmd: str = message.input.strip() + reply: Message = await message.reply("executing...") + try: + proc_stdout: str = await asyncio.Task( + shell.run_shell_cmd(cmd), name=reply.task_id + ) + except asyncio.exceptions.CancelledError: + return await reply.edit("`Cancelled...`") + output: str = f"~$`{cmd}`\n\n`{proc_stdout}`" + return await reply.edit(output, name="sh.txt", disable_web_page_preview=True) + + +# Shell with Live Output +async def live_shell(bot: bot, message: Message) -> Message | None: + cmd: str = message.input.strip() + reply: Message = await message.reply("`getting live output....`") + sub_process: shell.AsyncShell = await shell.AsyncShell.run_cmd(cmd) + sleep_for: int = 1 + output: str = "" + try: + async for stdout in sub_process.get_output(): + if output != stdout: + if len(stdout) <= 4096: + await reply.edit( + f"`{stdout}`", + disable_web_page_preview=True, + parse_mode=ParseMode.MARKDOWN, + ) + output = stdout + if sleep_for >= 6: + sleep_for = 1 + await asyncio.Task(asyncio.sleep(sleep_for), name=reply.task_id) + sleep_for += 1 + return await reply.edit( + f"~$`{cmd}\n\n``{sub_process.full_std}`", + name="shell.txt", + disable_web_page_preview=True, + ) + except asyncio.exceptions.CancelledError: + sub_process.cancel() + return await reply.edit(f"`Cancelled....`") + + +# Run Python code + + +async def executor(bot: bot, message: Message) -> Message | None: + code: str = message.flt_input.strip() + if not code: + return await message.reply("exec Jo mama?") + reply: Message = await message.reply("executing") + sys.stdout = codeOut = StringIO() + sys.stderr = codeErr = StringIO() + # Indent code as per proper python syntax + formatted_code = "\n ".join(code.splitlines()) + try: + # Create and initialise the function + exec(f"async def _exec(bot, message):\n {formatted_code}") + func_out = await asyncio.Task( + locals()["_exec"](bot, message), name=reply.task_id + ) + except asyncio.exceptions.CancelledError: + return await reply.edit("`Cancelled....`") + except BaseException: + func_out = str(traceback.format_exc()) + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + output = codeErr.getvalue().strip() or codeOut.getvalue().strip() + if func_out is not None: + output = "\n\n".join([output, str(func_out)]).strip() + if "-s" not in message.flags: + output = f"> `{code}`\n\n>> `{output}`" + else: + return await reply.delete() + await reply.edit( + output, + name="exec.txt", + disable_web_page_preview=True, + parse_mode=ParseMode.MARKDOWN, + ) + + +async def loader(bot: bot, message: Message) -> Message | None: + if ( + not message.replied + or not message.replied.document + or not message.replied.document.file_name.endswith(".py") + ): + return await message.reply("reply to a plugin.") + reply: Message = await message.reply("Loading....") + file_name: str = message.replied.document.file_name.rstrip(".py") + reload = sys.modules.pop(f"app.temp.{file_name}", None) + status: str = "Reloaded" if reload else "Loaded" + await message.replied.download("app/temp/") + try: + importlib.import_module(f"app.temp.{file_name}") + except BaseException: + return await reply.edit(str(traceback.format_exc())) + await reply.edit(f"{status} {file_name}.py.") + + +@bot.add_cmd(cmd="c") +async def cancel_task(bot: bot, message: Message) -> Message | None: + task_id: str | None = message.replied_task_id + if not task_id: + return await message.reply( + text="Reply To a Command or Bot's Response Message.", del_in=8 + ) + all_tasks: set[asyncio.all_tasks] = asyncio.all_tasks() + tasks: list[asyncio.Task] | None = [x for x in all_tasks if x.get_name() == task_id] + if not tasks: + return await message.reply( + text="Task not in Currently Running Tasks.", del_in=8 + ) + response: str = "" + for task in tasks: + status: bool = task.cancel() + response += f"Task: __{task.get_name()}__\nCancelled: __{status}__\n" + await message.reply(response, del_in=5) + + +if Config.DEV_MODE: + Config.CMD_DICT["sh"] = run_cmd + Config.CMD_DICT["shell"] = live_shell + Config.CMD_DICT["exec"] = executor + Config.CMD_DICT["load"] = loader diff --git a/app/plugins/tg_utils.py b/app/plugins/tg_utils.py new file mode 100644 index 0000000..68d44b0 --- /dev/null +++ b/app/plugins/tg_utils.py @@ -0,0 +1,88 @@ +import os + +from pyrogram.enums import ChatType +from pyrogram.errors import BadRequest + +from app import Config, bot +from app.core import Message + +# Delete replied and command message + + +@bot.add_cmd(cmd="del") +async def delete_message(bot: bot, message: Message) -> None: + await message.delete(reply=True) + + +# Delete Multiple messages from replied to command. +@bot.add_cmd(cmd="purge") +async def purge_(bot: bot, message: Message) -> None | Message: + start_message: int = message.reply_id + if not start_message: + return await message.reply("reply to a message") + end_message: int = message.id + messages: list[int] = [ + end_message, + *[i for i in range(int(start_message), int(end_message))], + ] + await bot.delete_messages( + chat_id=message.chat.id, message_ids=messages, revoke=True + ) + + +@bot.add_cmd(cmd="ids") +async def get_ids(bot: bot, message: Message) -> None: + reply: Message = message.replied + if reply: + ids: str = "" + reply_forward = reply.forward_from_chat + reply_user = reply.from_user + ids += f"Chat : `{reply.chat.id}`\n" + if reply_forward: + ids += f"Replied {'Channel' if reply_forward.type == ChatType.CHANNEL else 'Chat'} : `{reply_forward.id}`\n" + if reply_user: + ids += f"User : {reply.from_user.id}" + else: + ids: str = f"Chat :`{message.chat.id}`" + await message.reply(ids) + + +@bot.add_cmd(cmd="join") +async def join_chat(bot: bot, message: Message) -> None: + chat: str = message.input + try: + await bot.join_chat(chat) + except (KeyError, BadRequest): + try: + await bot.join_chat(os.path.basename(chat).strip()) + await message.reply("Joined") + except Exception as e: + await message.reply(str(e)) + + +@bot.add_cmd(cmd="leave") +async def leave_chat(bot: bot, message: Message) -> None: + if message.input: + chat = message.input + else: + chat = message.chat.id + await message.reply( + f"Leaving current chat in 5\nReply with `{Config.TRIGGER}c` to cancel", + del_in=5, + block=True, + ) + try: + await bot.leave_chat(chat) + except Exception as e: + await message.reply(str(e)) + + +@bot.add_cmd(cmd="reply") +async def reply(bot: bot, message: Message) -> None: + text: str = message.input + await bot.send_message( + chat_id=message.chat.id, + text=text, + reply_to_message_id=message.reply_id, + disable_web_page_preview=True, + ) diff --git a/app/plugins/utils.py b/app/plugins/utils.py new file mode 100644 index 0000000..4cf6e89 --- /dev/null +++ b/app/plugins/utils.py @@ -0,0 +1,64 @@ +import asyncio +import os + +from git import Repo +from pyrogram.enums import ChatType + +from app import Config, bot +from app.core import Message + + +@bot.add_cmd(cmd="help") +async def cmd_list(bot: bot, message: Message) -> None: + commands: str = "\n".join( + [f"{Config.TRIGGER}{i}" for i in Config.CMD_DICT.keys()] + ) + await message.reply(f"Available Commands:\n\n{commands}", del_in=30) + + +@bot.add_cmd(cmd="restart") +async def restart(bot: bot, message: Message, u_resp: Message | None = None) -> None: + reply: Message = u_resp or await message.reply("restarting....") + if reply.chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + os.environ["RESTART_MSG"] = str(reply.id) + os.environ["RESTART_CHAT"] = str(reply.chat.id) + await bot.restart(hard="-h" in message.flags) + + +@bot.add_cmd(cmd="repo") +async def sauce(bot: bot, message: Message) -> None: + await bot.send_message( + chat_id=message.chat.id, + text=f"Plain-UB.", + reply_to_message_id=message.reply_id or message.id, + disable_web_page_preview=True, + ) + + +@bot.add_cmd(cmd="update") +async def updater(bot: bot, message: Message) -> None | Message: + reply: Message = await message.reply("Checking for Updates....") + repo: Repo = Repo() + repo.git.fetch() + commits: str = "" + limit: int = 0 + for commit in repo.iter_commits("HEAD..origin/main"): + commits += f""" +#{commit.count()} {commit.summary} By {commit.author} +""" + limit += 1 + if limit > 50: + break + if not commits: + return await reply.edit("Already Up To Date.", del_in=5) + if "-pull" not in message.flags: + return await reply.edit( + f"Update Available:\n\n{commits}", disable_web_page_preview=True + ) + repo.git.reset("--hard") + repo.git.pull(Config.UPSTREAM_REPO, "--rebase=true") + await asyncio.gather( + bot.log(text=f"#Updater\nPulled:\n\n{commits}", disable_web_page_preview=True), + reply.edit("Update Found\nPulling...."), + ) + await restart(bot, message, reply) diff --git a/app/utils/aiohttp_tools.py b/app/utils/aiohttp_tools.py new file mode 100644 index 0000000..d33d90a --- /dev/null +++ b/app/utils/aiohttp_tools.py @@ -0,0 +1,77 @@ +import json +from enum import Enum, auto +from io import BytesIO +from os.path import basename, splitext +from urllib.parse import urlparse + +import aiohttp + +SESSION: aiohttp.ClientSession | None = None + + +class MediaType(Enum): + PHOTO = auto() + VIDEO = auto() + GROUP = auto() + GIF = auto() + MESSAGE = auto() + + +async def session_switch() -> None: + if not SESSION: + globals().update({"SESSION": aiohttp.ClientSession()}) + else: + await SESSION.close() + + +async def get_json( + url: str, + headers: dict = None, + params: dict = None, + json_: bool = False, + timeout: int = 10, +) -> dict | None: + try: + async with SESSION.get( + url=url, headers=headers, params=params, timeout=timeout + ) as ses: + if json_: + ret_json = await ses.json() + else: + ret_json = json.loads(await ses.text()) + return ret_json + except BaseException: + return + + +async def in_memory_dl(url: str) -> BytesIO: + async with SESSION.get(url) as remote_file: + bytes_data = await remote_file.read() + file = BytesIO(bytes_data) + file.name = get_filename(url) + return file + + +def get_filename(url: str) -> str: + name = basename(urlparse(url).path.rstrip("/")).lower() + if name.endswith((".webp", ".heic")): + name = name + ".jpg" + if name.endswith(".webm"): + name = name + ".mp4" + return name + + +def get_type(url: str) -> MediaType | None: + name, ext = splitext(get_filename(url)) + if ext in {".png", ".jpg", ".jpeg"}: + return MediaType.PHOTO + if ext in {".mp4", ".mkv", ".webm"}: + return MediaType.VIDEO + if ext in {".gif"}: + return MediaType.GIF + + +async def thumb_dl(thumb) -> BytesIO | str | None: + if not thumb or not thumb.startswith("http"): + return thumb + return await in_memory_dl(thumb) diff --git a/app/utils/db_utils.py b/app/utils/db_utils.py new file mode 100644 index 0000000..58e6e18 --- /dev/null +++ b/app/utils/db_utils.py @@ -0,0 +1,21 @@ +def extract_user_data(user) -> dict: + return dict( + name=f"""{user.first_name or ""} {user.last_name or ""}""", + username=user.username, + mention=user.mention, + ) + + +async def add_data(collection, id: int | str, data: dict) -> None: + found = await collection.find_one({"_id": id}) + if not found: + await collection.insert_one({"_id": id, **data}) + else: + await collection.update_one({"_id": id}, {"$set": data}) + + +async def delete_data(collection, id: int | str) -> bool | None: + found = await collection.find_one({"_id": id}) + if found: + await collection.delete_one({"_id": id}) + return True diff --git a/app/utils/helpers.py b/app/utils/helpers.py new file mode 100644 index 0000000..66735b6 --- /dev/null +++ b/app/utils/helpers.py @@ -0,0 +1,7 @@ +from pyrogram.types import User + + +def get_name(user: User) -> str: + first = user.first_name or "" + last = user.last_name or "" + return f"{first} {last}".strip() diff --git a/app/utils/shell.py b/app/utils/shell.py new file mode 100644 index 0000000..231b360 --- /dev/null +++ b/app/utils/shell.py @@ -0,0 +1,47 @@ +import asyncio +from typing import AsyncIterable + + +async def run_shell_cmd(cmd: str) -> str: + proc: asyncio.create_subprocess_shell = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + stdout, _ = await proc.communicate() + return stdout.decode("utf-8") + + +class AsyncShell: + def __init__(self, process: asyncio.create_subprocess_shell): + self.process: asyncio.create_subprocess_shell = process + self.full_std: str = "" + self.is_done: bool = False + self._task: asyncio.Task | None = None + + async def read_output(self) -> None: + while True: + line: str = (await self.process.stdout.readline()).decode("utf-8") + if not line: + break + self.full_std += line + self.is_done = True + await self.process.wait() + + async def get_output(self) -> AsyncIterable: + while not self.is_done: + yield self.full_std + + def cancel(self) -> None: + if not self.is_done: + self.process.kill() + self._task.cancel() + + @classmethod + async def run_cmd(cls, cmd: str, name: str = "AsyncShell") -> "AsyncShell": + sub_process: AsyncShell = cls( + process=await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + ) + sub_process._task = asyncio.create_task(sub_process.read_output(), name=name) + await asyncio.sleep(0.5) + return sub_process diff --git a/docker_start_cmd b/docker_start_cmd new file mode 100644 index 0000000..ad85bfd --- /dev/null +++ b/docker_start_cmd @@ -0,0 +1,9 @@ +#!bin/sh + +echo "${GH_TOKEN}" > ~/.git-credentials +git config --global credential.helper store + +git clone -q --depth=1 "${UPSTREAM_REPO:-"https://github.com/thedragonsinn/plain-ub"}" ubot +cd ubot +#pip -q install --no-cache-dir -r req*.txt +bash run diff --git a/req.txt b/req.txt new file mode 100644 index 0000000..e3cd783 --- /dev/null +++ b/req.txt @@ -0,0 +1,10 @@ +aiohttp==3.8.5 +async-lru==2.0.4 +motor==3.3.0 +pymongo==4.5.0 +dnspython==2.4.2 +gitpython>=3.1.32 +pyrogram==2.0.106 +python-dotenv==0.21.0 +tgCrypto==1.2.3 +uvloop==0.17.0 diff --git a/run b/run new file mode 100755 index 0000000..f2e9391 --- /dev/null +++ b/run @@ -0,0 +1,18 @@ +#!/bin/sh + +if [ "$API_PORT" ] ; then +py_code=" +from aiohttp import web +app = web.Application() +app.router.add_get('/', lambda _: web.Response(text='Web Server Running...')) +web.run_app(app, host='0.0.0.0', port=$API_PORT, reuse_port=True, print=None) +" + python3 -q -c "$py_code" & echo "Dummy Web Server Started..." + +fi + +if ! [ -d ".git" ] ; then + git init +fi + +python3 -m app \ No newline at end of file diff --git a/sample-config.env b/sample-config.env new file mode 100644 index 0000000..a346021 --- /dev/null +++ b/sample-config.env @@ -0,0 +1,19 @@ +API_ID= + +API_HASH= + +DEV_MODE=0 +# exec, sh, shell commands + +DB_URL= +# Mongo DB cluster URL + +LOG_CHAT= +# Bot logs chat + +SESSION_STRING="" + +USERS = [1223478] +# Separate multiple values with , + +UPSTREAM_REPO = "https://github.com/thedragonsinn/plain-ub" \ No newline at end of file