commit 8fe355ed0c2154b017efe745c0d38a4e289f832a Author: overspend1 Date: Fri Jul 25 20:27:05 2025 +0200 Update README and alive command for @overspend1 fork - Updated README title to show OVERSPEND1 FORK - Changed maintainer credit to @overspend1 - Updated alive command to show @overspend1 as creator instead of Meliodas diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a08f913 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +config*.env +*session* +venv/ +down* +__pycache__ +.idea/ +conf_backup/ +logs/ +.mypy_cache +psutil +app/temp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a44b28f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12.7-slim-bookworm + +ENV PIP_NO_CACHE_DIR=1 \ + LANG=C.UTF-8 \ + DEBIAN_FRONTEND=noninteractive + +WORKDIR /app/ + +RUN apt -qq update && apt -qq upgrade -y && \ + apt -qq install -y --no-install-recommends \ + apt-utils \ + build-essential coreutils \ + curl \ + ffmpeg \ + mediainfo \ + neofetch \ + git \ + wget && \ + pip install -U pip setuptools wheel && \ + 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.sh)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..27d8b6c --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +## PLAIN UB - OVERSPEND1 FORK + +![Header Image](assets/dark.png#gh-dark-mode-only) +![Header Image](assets/light.png#gh-light-mode-only) + +A simple Telegram User-Bot. + +> Forked and maintained by @overspend1 + +## Example Plugins: + +
+ + + +* Basic Plugin: +```python +from app import BOT, bot, Message + +@bot.add_cmd(cmd="test") +async def test_function(bot: BOT, message: Message): + await message.reply("Testing....") + """Your rest of the code.""" + +``` + +* Plugin with Multiple Commands: +Instead of stacking @add_cmd you can pass in a list of command triggers. +```python +from app import BOT, bot, Message + +@bot.add_cmd(cmd=["cmd1", "cmd2"]) +async def test_function(bot: BOT, message: Message): + if message.cmd=="cmd1": + await message.reply("cmd1 triggered function") + """Your rest of the code.""" + +``` + +* Plugin with DB access: + +```python +from app import BOT, bot, Message, CustomDB + +TEST_COLLECTION = CustomDB["TEST_COLLECTION"] + +@bot.add_cmd(cmd="add_data") +async def test_function(bot: BOT, message: Message): + async for data in TEST_COLLECTION.find(): + """Your rest of the code.""" + # OR + await TEST_COLLECTION.add_data(data={"_id":"test", "data":"some_data"}) + await TEST_COLLECTION.delete_data(id="test") +``` + +* Conversational Plugin: + * Bound Method + ```python + from pyrogram import filters + from app import BOT, bot, Message + @bot.add_cmd(cmd="test") + async def test_function(bot: BOT, message: Message): + response = await message.get_response( + filters=filters.text&filters.user([1234]), + timeout=10, + ) + # Will return First text it receives in chat where cmd was ran + """ rest of the code """ + + ``` + * Conversational + + ```python + from app import BOT, bot, Message, Convo + from pyrogram import filters + + @bot.add_cmd(cmd="test") + async def test_function(bot: BOT, message: Message): + async with Convo( + client=bot, + chat_id=1234, + filters=filters.text, + timeout=10 + ) as convo: + await convo.get_response(timeout=10) + await convo.send_message(text="abc", get_response=True, timeout=8) + # and so on + + ``` +
\ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f408149 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +from ub_core import BOT, LOGGER, Config, Convo, CustomDB, Message, bot diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..6f60e9d --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,13 @@ +import sys + +from app import LOGGER, Config, bot + +if Config.CMD_TRIGGER == Config.SUDO_TRIGGER: + LOGGER.error("CMD_TRIGGER and SUDO_TRIGGER can't be the same") + sys.exit(1) + + +if __name__ == "__main__": + bot.run(bot.boot()) +else: + LOGGER.error("Wrong Start Command.\nUse 'python -m app'") diff --git a/app/extra_config.py b/app/extra_config.py new file mode 100644 index 0000000..15f83eb --- /dev/null +++ b/app/extra_config.py @@ -0,0 +1,39 @@ +from os import getenv + +from pyrogram.enums import ChatMemberStatus + +ALIVE_MEDIA: str = getenv("ALIVE_MEDIA", "https://telegra.ph/file/a1d35a86c7f54a96188a9.png") + +ADMIN_STATUS = {ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER} + +BOT_NAME = getenv("BOT_NAME", "PLAIN-UB") + +CUSTOM_PACK_NAME = getenv("CUSTOM_PACK_NAME") + +DISABLED_SUPERUSERS: list[int] = [] + +FBAN_LOG_CHANNEL: int = int(getenv("FBAN_LOG_CHANNEL") or getenv("LOG_CHAT")) + +FBAN_SUDO_ID: int = int(getenv("FBAN_SUDO_ID", 0)) + +FBAN_SUDO_TRIGGER: str = getenv("FBAN_SUDO_TRIGGER") + +GEMINI_API_KEY: str = getenv("GEMINI_API_KEY") + +LOAD_HANDLERS: bool = True + +MESSAGE_LOGGER_CHAT: int = int(getenv("MESSAGE_LOGGER_CHAT") or getenv("LOG_CHAT")) + +PM_GUARD: bool = False + +PM_LOGGER: bool = False + +PM_LOGGER_THREAD_ID: int = int(getenv("PM_LOGGER_THREAD_ID", 0)) or None + +TAG_LOGGER: bool = False + +TAG_LOGGER_THREAD_ID: int = int(getenv("TAG_LOGGER_THREAD_ID", 0)) or None + +UPSTREAM_REPO: str = getenv("UPSTREAM_REPO", "https://github.com/thedragonsinn/plain-ub") + +USE_LEGACY_KANG: int = int(getenv("USE_LEGACY_KANG", 0)) diff --git a/app/plugins/admin/ban.py b/app/plugins/admin/ban.py new file mode 100644 index 0000000..c645d99 --- /dev/null +++ b/app/plugins/admin/ban.py @@ -0,0 +1,29 @@ +from pyrogram.types import User + +from app import BOT, Message + + +@BOT.add_cmd(cmd=["ban", "unban", "unmute"]) +async def ban_or_unban(bot: BOT, message: Message) -> None: + if not message.chat._raw.admin_rights: + await message.reply("Cannot perform action without being admin.") + return + + user, reason = await message.extract_user_n_reason() + + if not isinstance(user, User): + await message.reply(user, del_in=10) + return + + action = bot.ban_chat_member if message.cmd == "ban" else bot.unban_chat_member + + if message.cmd == "unmute": + action_str = "Unmuted" + else: + action_str = f"{message.cmd.capitalize()}ned" + + try: + await action(chat_id=message.chat.id, user_id=user.id) # NOQA + await message.reply(text=f"{action_str}: {user.mention}\nReason: {reason}") + except Exception as e: + await message.reply(text=e, del_in=10) diff --git a/app/plugins/admin/fbans.py b/app/plugins/admin/fbans.py new file mode 100644 index 0000000..ba61f82 --- /dev/null +++ b/app/plugins/admin/fbans.py @@ -0,0 +1,270 @@ +import asyncio + +from pyrogram import filters +from pyrogram.enums import ChatMemberStatus, ChatType +from pyrogram.errors import UserNotParticipant +from pyrogram.types import Chat, User +from ub_core.utils.helpers import get_name + +from app import BOT, Config, CustomDB, Message, bot, extra_config + +FBAN_TASK_LOCK = asyncio.Lock() + +FED_DB = CustomDB["FED_LIST"] + +BASIC_FILTER = filters.user([609517172, 2059887769, 1376954911, 885745757]) & ~filters.service + +FBAN_REGEX = BASIC_FILTER & filters.regex( + r"(New FedBan|" + r"starting a federation ban|" + r"Starting a federation ban|" + r"start a federation ban|" + r"FedBan Reason update|" + r"FedBan reason updated|" + r"Would you like to update this reason)" +) + + +UNFBAN_REGEX = BASIC_FILTER & filters.regex(r"(New un-FedBan|I'll give|Un-FedBan)") + + +@bot.add_cmd(cmd="addf") +async def add_fed(bot: BOT, message: Message): + """ + CMD: ADDF + INFO: Add a Fed Chat to DB. + USAGE: + .addf | .addf NAME + """ + data = dict(name=message.input or message.chat.title, type=str(message.chat.type)) + await FED_DB.add_data({"_id": message.chat.id, **data}) + text = f"#FBANS\n{data['name']}: {message.chat.id} added to FED LIST." + await message.reply(text=text, del_in=5, block=True) + await bot.log_text(text=text, type="info") + + +@bot.add_cmd(cmd="delf") +async def remove_fed(bot: BOT, message: Message): + """ + CMD: DELF + INFO: Delete a Fed from DB. + FLAGS: -all to delete all feds. + USAGE: + .delf | .delf id | .delf -all + """ + if "-all" in message.flags: + await FED_DB.drop() + await message.reply("FED LIST cleared.") + return + + chat: int | str | Chat = message.input or message.chat + name = "" + + if isinstance(chat, Chat): + name = f"Chat: {chat.title}\n" + chat = chat.id + elif chat.lstrip("-").isdigit(): + chat = int(chat) + + deleted: int = await FED_DB.delete_data(id=chat) + + if deleted: + text = f"#FBANS\n{name}{chat} removed from FED LIST." + await message.reply(text=text, del_in=8) + await bot.log_text(text=text, type="info") + else: + await message.reply(text=f"{name or chat} not in FED LIST.", del_in=8) + + +@bot.add_cmd(cmd="listf") +async def fed_list(bot: BOT, message: Message): + """ + CMD: LISTF + INFO: View Connected Feds. + FLAGS: -id to list Fed Chat IDs. + USAGE: .listf | .listf -id + """ + output: str = "" + total = 0 + + async for fed in FED_DB.find(): + output += f'• {fed["name"]}\n' + + if "-id" in message.flags: + output += f' {fed["_id"]}\n' + + total += 1 + + if not total: + await message.reply("You don't have any Feds Connected.") + return + + output: str = f"List of {total} Connected Feds:\n\n{output}" + await message.reply(output, del_in=30, block=True) + + +@bot.add_cmd(cmd=["fban", "fbanp"]) +async def fed_ban(bot: BOT, message: Message): + progress: Message = await message.reply("❯") + extracted_info = await get_user_reason(message=message, progress=progress) + if not extracted_info: + await progress.edit("Unable to extract user info.") + return + + user_id, user_mention, reason = extracted_info + + if user_id in [Config.OWNER_ID, *Config.SUPERUSERS, *Config.SUDO_USERS]: + await progress.edit("Cannot Fban Owner/Sudo users.") + return + + proof_str: str = "" + if message.cmd == "fbanp": + if not message.replied: + await progress.edit("Reply to a proof") + return + proof = await message.replied.forward(extra_config.FBAN_LOG_CHANNEL) + proof_str = f"\n{ {proof.link} }" + + reason = f"{reason}{proof_str}" + + if message.replied and message.chat.type != ChatType.PRIVATE: + try: + if message.chat._raw.admin_rights: + await message.replied.reply( + text=f"!dban {reason}", disable_preview=True, del_in=3, block=False + ) + except UserNotParticipant: + pass + + fban_cmd: str = f"/fban {user_id} {reason}" + + await perform_fed_task( + user_id=user_id, + user_mention=user_mention, + command=fban_cmd, + task_filter=FBAN_REGEX, + task_type="Fban", + reason=reason, + progress=progress, + message=message, + ) + + +@bot.add_cmd(cmd="unfban") +async def un_fban(bot: BOT, message: Message): + progress: Message = await message.reply("❯") + extracted_info = await get_user_reason(message=message, progress=progress) + + if not extracted_info: + await progress.edit("Unable to extract user info.") + return + + user_id, user_mention, reason = extracted_info + unfban_cmd: str = f"/unfban {user_id} {reason}" + + await perform_fed_task( + user_id=user_id, + user_mention=user_mention, + command=unfban_cmd, + task_filter=UNFBAN_REGEX, + task_type="Un-FBan", + reason=reason, + progress=progress, + message=message, + ) + + +async def get_user_reason(message: Message, progress: Message) -> tuple[int, str, str] | None: + user, reason = await message.extract_user_n_reason() + if isinstance(user, str): + await progress.edit(user) + return + if not isinstance(user, User): + user_id = user + user_mention = f"{user_id}" + else: + user_id = user.id + user_mention = user.mention + return user_id, user_mention, reason + + +async def perform_fed_task(*args, **kwargs): + async with FBAN_TASK_LOCK: + await _perform_fed_task(*args, **kwargs) + + +async def _perform_fed_task( + user_id: int, + user_mention: str, + command: str, + task_filter: filters.Filter, + task_type: str, + reason: str, + progress: Message, + message: Message, +): + await progress.edit("❯❯") + + total: int = 0 + failed: list[str] = [] + + async for fed in FED_DB.find(): + chat_id = int(fed["_id"]) + total += 1 + + try: + cmd: Message = await bot.send_message( + chat_id=chat_id, text=command, disable_preview=True + ) + response: Message | None = await cmd.get_response(filters=task_filter, timeout=8) + if not response: + failed.append(fed["name"]) + elif "Would you like to update this reason" in response.text: + await response.click("Update reason") + + except Exception as e: + await bot.log_text( + text=f"An Error occured while banning in fed: {fed['name']} [{chat_id}]" + f"\nError: {e}", + type=task_type.upper(), + ) + failed.append(fed["name"]) + continue + + await asyncio.sleep(1) + + if not total: + await progress.edit("You Don't have any feds connected!") + return + + resp_str = ( + f"❯❯❯ {task_type}ned {user_mention}" + f"\nID: {user_id}" + f"\nReason: {reason}" + f"\nInitiated in: {message.chat.title or 'PM'}" + ) + + if failed: + resp_str += f"\nFailed in: {len(failed)}/{total}\n• " + "\n• ".join(failed) + else: + resp_str += f"\nStatus: {task_type}ned in {total} feds." + + if not message.is_from_owner: + resp_str += f"\n\nBy: {get_name(message.from_user)}" + + await bot.send_message( + chat_id=extra_config.FBAN_LOG_CHANNEL, text=resp_str, disable_preview=True + ) + + await progress.edit(text=resp_str, del_in=5, block=True, disable_preview=True) + + await handle_sudo_fban(command=command) + + +async def handle_sudo_fban(command: str): + if not (extra_config.FBAN_SUDO_ID and extra_config.FBAN_SUDO_TRIGGER): + return + + sudo_cmd = command.replace("/", extra_config.FBAN_SUDO_TRIGGER, 1) + + await bot.send_message(chat_id=extra_config.FBAN_SUDO_ID, text=sudo_cmd, disable_preview=True) diff --git a/app/plugins/admin/kicks.py b/app/plugins/admin/kicks.py new file mode 100644 index 0000000..11dba44 --- /dev/null +++ b/app/plugins/admin/kicks.py @@ -0,0 +1,77 @@ +import asyncio +from datetime import UTC, datetime, timedelta + +from pyrogram.types import User + +from app import BOT, Message +from app.extra_config import ADMIN_STATUS + + +@BOT.add_cmd(cmd="kick") +async def kick_user(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 + + try: + await bot.ban_chat_member(chat_id=message.chat.id, user_id=user.id) + await asyncio.sleep(2) + await bot.unban_chat_member(chat_id=message.chat.id, user_id=user.id) + 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="kick_im", allow_sudo=False) +async def kick_inactive_members(bot: BOT, message: Message): + """ + CMD: KICK_IM + INFO: Kick inactive members with message count less than 10 + """ + + if not message.chat._raw.admin_rights: + await message.reply("Cannot kick members without being admin.") + return + + count = 0 + chat_id = message.chat.id + + async with bot.Convo(client=bot, chat_id=chat_id, from_user=message.from_user.id) as convo: + async for member in bot.get_chat_members(chat_id): + + if member.status in ADMIN_STATUS: + continue + + user = member.user + + message_count = await bot.search_messages_count(chat_id=chat_id, from_user=user.id) + if message_count >= 10: + continue + + try: + prompt = await convo.send_message( + text=f"Kick {user.mention} with total of {message_count} messages in chat?" + f"\nreply with y to continue" + ) + + convo.reply_to_message_id = prompt.id + + confirmation = await convo.get_response() + + if confirmation.text == "y": + await bot.ban_chat_member( + chat_id=chat_id, + user_id=user.id, + until_date=datetime.now(UTC) + timedelta(seconds=60), + ) + await prompt.edit(f"Kicked {user.mention}") + count += 1 + + else: + await prompt.edit("Aborted, continuing onto next the member.") + + except TimeoutError: + pass + + await message.reply(f"Kicked {count} inactive members.") diff --git a/app/plugins/admin/mute.py b/app/plugins/admin/mute.py new file mode 100644 index 0000000..3ee03b5 --- /dev/null +++ b/app/plugins/admin/mute.py @@ -0,0 +1,35 @@ +from pyrogram.types import ChatPermissions, User + +from app import BOT, Message + + +@BOT.add_cmd(cmd="mute") +async def mute_or_unmute(bot: BOT, message: Message): + if not message.chat._raw.admin_rights: + await message.reply("Cannot mute members without being admin.") + return + + user, reason = await message.extract_user_n_reason() + + if not isinstance(user, User): + await message.reply(user, del_in=10) + return + + try: + await bot.restrict_chat_member( + chat_id=message.chat.id, + user_id=user.id, + permissions=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, + ), + ) + await message.reply(text=f"{message.cmd.capitalize()}d: {user.mention}\nReason: {reason}") + except Exception as e: + await message.reply(text=e, del_in=10) diff --git a/app/plugins/admin/promote.py b/app/plugins/admin/promote.py new file mode 100644 index 0000000..5923283 --- /dev/null +++ b/app/plugins/admin/promote.py @@ -0,0 +1,111 @@ +import asyncio + +from pyrogram.enums import ChatMembersFilter, ChatMemberStatus +from pyrogram.errors import FloodWait +from pyrogram.types import ChatPrivileges, User + +from app import BOT, Message +from app.extra_config import ADMIN_STATUS + +DEMOTE_PRIVILEGES = ChatPrivileges(can_manage_chat=False) + +NO_PRIVILEGES = ChatPrivileges( + can_manage_chat=True, + 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, +) + + +@BOT.add_cmd(cmd=["promote", "demote"]) +async def promote_or_demote(bot: BOT, message: Message) -> None: + """ + CMD: PROMOTE | DEMOTE + INFO: Add/Remove an Admin. + FLAGS: + PROMOTE: -full for full rights, -anon for anon admin + USAGE: + PROMOTE: .promote [ -anon | -full ] [ UID | REPLY | @ ] Title[Optional] + DEMOTE: .demote [ UID | REPLY | @ ] + """ + response: Message = await message.reply(f"Trying to {message.cmd.capitalize()}.....") + + my_status = await bot.get_chat_member(chat_id=message.chat.id, user_id=bot.me.id) + + my_privileges = my_status.privileges + + if not (my_status.status in ADMIN_STATUS and my_privileges.can_promote_members): + await response.edit("You don't to have enough rights to do this.") + return + + user, title = await message.extract_user_n_reason() + + if not isinstance(user, User): + await response.edit(user, del_in=10) + return + + my_privileges.can_promote_members = "-full" in message.flags + my_privileges.is_anonymous = "-anon" in message.flags + + promote = message.cmd == "promote" + + if promote: + final_privileges = NO_PRIVILEGES if "-wr" in message.flags else my_privileges + else: + final_privileges = DEMOTE_PRIVILEGES + + response_text = f"{message.cmd.capitalize()}d: {user.mention}" + + try: + await bot.promote_chat_member( + chat_id=message.chat.id, user_id=user.id, privileges=final_privileges + ) + + if promote: + await asyncio.sleep(1) + await bot.set_administrator_title( + chat_id=message.chat.id, user_id=user.id, title=title or "Admin" + ) + + if title: + response_text += f"\nTitle: {title}" + if "-wr" in message.flags: + response_text += "\nWithout Rights: True" + + await response.edit(text=response_text) + except Exception as e: + await response.edit(text=e, del_in=10, block=True) + + +@BOT.add_cmd(cmd="demote_all", allow_sudo=False) +async def demote_all(bot: BOT, message: Message): + me = await bot.get_chat_member(message.chat.id, bot.me.id) + if me.status != ChatMemberStatus.OWNER: + await message.reply("Cannot Demote all without being Chat Owner.") + return + + resp = await message.reply("Hang on demoting all Admins...") + count = 0 + + async for member in bot.get_chat_members( + chat_id=message.chat.id, filter=ChatMembersFilter.ADMINISTRATORS + ): + try: + await bot.promote_chat_member( + chat_id=message.chat.id, user_id=member.user.id, privileges=DEMOTE_PRIVILEGES + ) + except FloodWait as f: + await asyncio.sleep(f.value + 10) + await bot.promote_chat_member( + chat_id=message.chat.id, user_id=member.user.id, privileges=DEMOTE_PRIVILEGES + ) + await asyncio.sleep(0.5) + count += 1 + + await resp.edit(f"Demoted {count} admins in {message.chat.title}.") + await resp.log() diff --git a/app/plugins/admin/zombies.py b/app/plugins/admin/zombies.py new file mode 100644 index 0000000..6ce7463 --- /dev/null +++ b/app/plugins/admin/zombies.py @@ -0,0 +1,46 @@ +import asyncio +from datetime import UTC, datetime, timedelta + +from pyrogram.errors import FloodWait + +from app import BOT, Message +from app.extra_config import ADMIN_STATUS + + +@BOT.add_cmd(cmd="zombies") +async def clean_zombies(bot: BOT, message: Message): + if not message.chat._raw.admin_rights: + await message.reply("Cannot clean zombies without being admin.") + return + + zombies = 0 + admin_zombies = 0 + + response = await message.reply("Cleaning Zombies....\nthis may take a while") + + async for member in bot.get_chat_members(chat_id=message.chat.id): + try: + if member.user.is_deleted: + + if member.status in ADMIN_STATUS: + admin_zombies += 1 + continue + + zombies += 1 + + await bot.ban_chat_member( + chat_id=message.chat.id, + user_id=member.user.id, + until_date=datetime.now(UTC) + timedelta(seconds=60), + ) + await asyncio.sleep(1) + + except FloodWait as e: + await asyncio.sleep(e.value + 3) + + resp_str = f"Cleaned {zombies} zombies." + + if admin_zombies: + resp_str += f"\n{admin_zombies} Admin Zombie(s) not Removed." + + await response.edit(resp_str) diff --git a/app/plugins/ai/gemini/__init__.py b/app/plugins/ai/gemini/__init__.py new file mode 100644 index 0000000..48b3f2c --- /dev/null +++ b/app/plugins/ai/gemini/__init__.py @@ -0,0 +1,2 @@ +from .client import client, async_client, Response +from .config import AIConfig, DB_SETTINGS diff --git a/app/plugins/ai/gemini/chat.py b/app/plugins/ai/gemini/chat.py new file mode 100644 index 0000000..81c54d7 --- /dev/null +++ b/app/plugins/ai/gemini/chat.py @@ -0,0 +1,157 @@ +import pickle +from io import BytesIO + +from google.genai.chats import AsyncChat +from pyrogram.enums import ChatType, ParseMode + +from app import BOT, Convo, Message, bot +from app.plugins.ai.gemini import AIConfig, Response, async_client +from app.plugins.ai.gemini.utils import create_prompts, run_basic_check + + +@bot.add_cmd(cmd="aic") +@run_basic_check +async def ai_chat(bot: BOT, message: Message): + """ + CMD: AICHAT + INFO: Have a Conversation with Gemini AI. + FLAGS: + "-s": use search + "-i": use image gen/edit mode + -a: audio output + -sp: multi speaker output + USAGE: + .aic hello + keep replying to AI responses with text | media [no need to reply in DM] + After 5 mins of Idle bot will export history and stop chat. + use .load_history to continue + + """ + chat = async_client.chats.create(**AIConfig.get_kwargs(message.flags)) + await do_convo(chat=chat, message=message) + + +@bot.add_cmd(cmd="lh") +@run_basic_check +async def history_chat(bot: BOT, message: Message): + """ + CMD: LOAD_HISTORY + INFO: Load a Conversation with Gemini AI from previous session. + USAGE: + .lh {question} [reply to history document] + """ + reply = message.replied + + if not message.input: + await message.reply(f"Ask a question along with {message.trigger}{message.cmd}") + return + + try: + assert reply.document.file_name == "AI_Chat_History.pkl" + except (AssertionError, AttributeError): + await message.reply("Reply to a Valid History file.") + return + + resp = await message.reply("`Loading History...`") + + doc = await reply.download(in_memory=True) + doc.seek(0) + pickle.load(doc) + + await resp.edit("__History Loaded... Resuming chat__") + + chat = async_client.chats.create(**AIConfig.get_kwargs(message.flags)) + await do_convo(chat=chat, message=message) + + +CONVO_CACHE: dict[str, Convo] = {} + + +async def do_convo(chat: AsyncChat, message: Message): + chat_id = message.chat.id + + old_conversation = CONVO_CACHE.get(message.unique_chat_user_id) + + if old_conversation in Convo.CONVO_DICT[chat_id]: + Convo.CONVO_DICT[chat_id].remove(old_conversation) + + if message.chat.type in (ChatType.PRIVATE, ChatType.BOT): + reply_to_user_id = None + else: + reply_to_user_id = message._client.me.id + + conversation_object = Convo( + client=message._client, + chat_id=chat_id, + timeout=300, + check_for_duplicates=False, + from_user=message.from_user.id, + reply_to_user_id=reply_to_user_id, + ) + + CONVO_CACHE[message.unique_chat_user_id] = conversation_object + + try: + async with conversation_object: + prompt = await create_prompts(message) + reply_to_id = message.id + + while True: + ai_response = await chat.send_message(prompt) + prompt_message = await send_and_get_resp( + convo_obj=conversation_object, + response=ai_response, + reply_to_id=reply_to_id, + ) + + try: + prompt = await create_prompts(prompt_message, is_chat=True, check_size=False) + except Exception as e: + prompt_message = await send_and_get_resp( + conversation_object, str(e), reply_to_id=reply_to_id + ) + prompt = await create_prompts(prompt_message, is_chat=True, check_size=False) + + reply_to_id = prompt_message.id + + except TimeoutError: + await export_history(chat, message) + finally: + CONVO_CACHE.pop(message.unique_chat_user_id, 0) + + +async def send_and_get_resp( + convo_obj: Convo, + response, + reply_to_id: int | None = None, +) -> Message: + + response = Response(response) + + if text := response.text(): + await convo_obj.send_message( + text=f"**>\n•><**\n{text}", + reply_to_id=reply_to_id, + parse_mode=ParseMode.MARKDOWN, + disable_preview=True, + ) + + if response.image: + await convo_obj.send_photo(photo=response.image_file, reply_to_id=reply_to_id) + + if response.audio: + await convo_obj.send_voice( + voice=response.audio_file, + waveform=response.audio_file.waveform, + reply_to_id=reply_to_id, + duration=response.audio_file.duration, + ) + + return await convo_obj.get_response() + + +async def export_history(chat: AsyncChat, message: Message): + doc = BytesIO(pickle.dumps(chat._curated_history)) + doc.name = "AI_Chat_History.pkl" + caption = Response(await chat.send_message("Summarize our Conversation into one line.")).text() + await bot.send_document(chat_id=message.from_user.id, document=doc, caption=caption) diff --git a/app/plugins/ai/gemini/client.py b/app/plugins/ai/gemini/client.py new file mode 100644 index 0000000..3c8bae2 --- /dev/null +++ b/app/plugins/ai/gemini/client.py @@ -0,0 +1,147 @@ +import io +import logging +import wave + +import numpy as np +from google.genai.client import AsyncClient, Client +from google.genai.types import Blob, GenerateContentResponse +from pyrogram.enums import ParseMode +from ub_core.utils import MediaExts + +from app import CustomDB, extra_config + +logging.getLogger("google_genai.models").setLevel(logging.WARNING) + +DB_SETTINGS = CustomDB["COMMON_SETTINGS"] + +try: + client: Client | None = Client(api_key=extra_config.GEMINI_API_KEY) + async_client: AsyncClient | None = client.aio +except: + client = async_client = None + + +class Response: + def __init__(self, ai_response: GenerateContentResponse): + self._ai_response = ai_response + + self.first_candidate = None + self.first_content = None + self.first_parts = [] + + if ai_response.candidates: + self.first_candidate = ai_response.candidates[0] + if self.first_candidate.content: + self.first_content = self.first_candidate.content + if self.first_content.parts: + self.first_parts = self.first_content.parts + + for part in self.first_parts: + if part.inline_data: + self._inline_data = part.inline_data + break + else: + self._inline_data = None + + self.is_empty = not self.first_parts + self.failed_str = "`Error: Query Failed.`" + + def wrap_in_quote(self, text: str, mode: ParseMode = ParseMode.MARKDOWN): + _text = text.strip() + match mode: + case ParseMode.MARKDOWN: + return _text if "```" in _text else f"**>\n{_text}<**" + case ParseMode.HTML: + return f"
{_text}
" + case _: + return _text + + @property + def _text(self) -> str: + return "\n".join(part.text for part in self.first_parts if isinstance(part.text, str)) + + def text(self, quote_mode: ParseMode | None = ParseMode.MARKDOWN) -> str: + if self.is_empty: + return self.failed_str + return self.wrap_in_quote(text=self._text, mode=quote_mode) + + def text_with_sources(self, quote_mode: ParseMode = ParseMode.MARKDOWN) -> str: + if self.is_empty: + return self.failed_str + + try: + chunks = self.first_candidate.grounding_metadata.grounding_chunks + except (AttributeError, TypeError): + return self.text(quote_mode=quote_mode) + + hrefs = [f"[{chunk.web.title}]({chunk.web.uri})" for chunk in chunks] + sources = "\n\nSources: " + " | ".join(hrefs) + final_text = self._text.strip() + sources + return self.wrap_in_quote(text=final_text, mode=quote_mode) + + @property + def image(self) -> bool: + if self._inline_data and self._inline_data.mime_type: + return "image" in self._inline_data.mime_type + return False + + @property + def image_file(self) -> io.BytesIO | None: + inline_data = self._inline_data + + if inline_data: + file = io.BytesIO(inline_data.data) + file.name = "photo.png" + return file + + return None + + @staticmethod + def save_wave_file(pcm, channels=1, rate=24000, sample_width=2) -> io.BytesIO: + file = io.BytesIO() + + with wave.open(file, mode="wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(sample_width) + wf.setframerate(rate) + wf.writeframes(pcm) + + n_samples = len(pcm) // (sample_width * channels) + duration = n_samples / rate + + dtype = {1: np.int8, 2: np.int16, 4: np.int32}[sample_width] + samples = np.frombuffer(pcm, dtype=dtype) + + chunk_size = max(1, len(samples) // 80) + + waveform = bytes( + [ + int( + min( + 255, + np.abs(samples[i : i + chunk_size]).mean() + / (2 ** (8 * sample_width - 1)) + * 255, + ) + ) + for i in range(0, len(samples), chunk_size) + ] + ) + + waveform = waveform[:80] + file.name = "audio.ogg" + file.waveform = waveform + file.duration = round(duration) + + return file + + @property + def audio(self) -> bool: + if self._inline_data and self._inline_data.mime_type: + return "audio" in self._inline_data.mime_type + return False + + @property + def audio_file(self) -> io.BytesIO | None: + inline_data = self._inline_data + return self.save_wave_file(inline_data.data) if inline_data else None diff --git a/app/plugins/ai/gemini/config.py b/app/plugins/ai/gemini/config.py new file mode 100644 index 0000000..1f963d1 --- /dev/null +++ b/app/plugins/ai/gemini/config.py @@ -0,0 +1,134 @@ +import logging + +from google.genai import types +from ub_core import CustomDB + +logging.getLogger("google_genai.models").setLevel(logging.WARNING) + +DB_SETTINGS = CustomDB["COMMON_SETTINGS"] + + +async def init_task(): + model_info = await DB_SETTINGS.find_one({"_id": "gemini_model_info"}) or {} + if model_name := model_info.get("model_name"): + AIConfig.TEXT_MODEL = model_name + if image_model := model_info.get("image_model_name"): + AIConfig.IMAGE_MODEL = image_model + + +SAFETY_SETTINGS = [ + # SafetySetting(category="HARM_CATEGORY_UNSPECIFIED", threshold="BLOCK_NONE"), + types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), + types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), + types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), + types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), + types.SafetySetting(category="HARM_CATEGORY_CIVIC_INTEGRITY", threshold="BLOCK_NONE"), +] + +SEARCH_TOOL = types.Tool( + google_search=types.GoogleSearchRetrieval( + dynamic_retrieval_config=types.DynamicRetrievalConfig(dynamic_threshold=0.3) + ) +) + +SYSTEM_INSTRUCTION = ( + "Answer precisely and in short unless specifically instructed otherwise." + "\nIF asked related to code, do not comment the code and do not explain the code unless instructed." +) + +MALE_SPEECH_CONFIG = types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Puck") + ) +) + +FEMALE_SPEECH_CONFIG = types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Kore") + ) +) + + +MULTI_SPEECH_CONFIG = types.SpeechConfig( + multi_speaker_voice_config=types.MultiSpeakerVoiceConfig( + speaker_voice_configs=[ + types.SpeakerVoiceConfig( + speaker="John", + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name="Kore", + ) + ), + ), + types.SpeakerVoiceConfig( + speaker="Jane", + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name="Puck", + ) + ), + ), + ] + ) +) + + +class AIConfig: + TEXT_MODEL = "gemini-2.0-flash" + + TEXT_CONFIG = types.GenerateContentConfig( + candidate_count=1, + # max_output_tokens=1024, + response_modalities=["Text"], + system_instruction=SYSTEM_INSTRUCTION, + temperature=0.69, + tools=[], + ) + + IMAGE_MODEL = "gemini-2.0-flash-exp" + + IMAGE_CONFIG = types.GenerateContentConfig( + candidate_count=1, + # max_output_tokens=1024, + response_modalities=["Text", "Image"], + # system_instruction=SYSTEM_INSTRUCTION, + temperature=0.99, + ) + + AUDIO_MODEL = "gemini-2.5-flash-preview-tts" + AUDIO_CONFIG = types.GenerateContentConfig( + temperature=1, + response_modalities=["audio"], + speech_config=FEMALE_SPEECH_CONFIG, + ) + + @staticmethod + def get_kwargs(flags: list[str]) -> dict: + if "-i" in flags: + return {"model": AIConfig.IMAGE_MODEL, "config": AIConfig.IMAGE_CONFIG} + + if "-a" in flags: + audio_config = AIConfig.AUDIO_CONFIG + + if "-m" in flags: + audio_config.speech_config = MALE_SPEECH_CONFIG + else: + audio_config.speech_config = FEMALE_SPEECH_CONFIG + + return {"model": AIConfig.AUDIO_MODEL, "config": audio_config} + + if "-sp" in flags: + AIConfig.AUDIO_CONFIG.speech_config = MULTI_SPEECH_CONFIG + return {"model": AIConfig.AUDIO_MODEL, "config": AIConfig.AUDIO_CONFIG} + + tools = AIConfig.TEXT_CONFIG.tools + + use_search = "-s" in flags + + if not use_search and SEARCH_TOOL in tools: + tools.remove(SEARCH_TOOL) + + if use_search and SEARCH_TOOL not in tools: + tools.append(SEARCH_TOOL) + + return {"model": AIConfig.TEXT_MODEL, "config": AIConfig.TEXT_CONFIG} diff --git a/app/plugins/ai/gemini/query.py b/app/plugins/ai/gemini/query.py new file mode 100644 index 0000000..a9b2f51 --- /dev/null +++ b/app/plugins/ai/gemini/query.py @@ -0,0 +1,90 @@ +from pyrogram.enums import ParseMode +from pyrogram.types import InputMediaAudio, InputMediaPhoto + +from app import BOT, Message, bot +from app.plugins.ai.gemini import AIConfig, Response, async_client +from app.plugins.ai.gemini.utils import create_prompts, run_basic_check + + +@bot.add_cmd(cmd="ai") +@run_basic_check +async def question(bot: BOT, message: Message): + """ + CMD: AI + INFO: Ask a question to Gemini AI or get info about replied message / media. + FLAGS: + -s: to use Search + -i: to edit/generate images + -a: to generate audio + -m: male voice + -f: female voice + -sp: to create speech between two people + + USAGE: + .ai what is the meaning of life. + .ai [reply to a message] (sends replied text as query) + .ai [reply to message] [extra prompt relating to replied text] + + .ai [reply to image | video | gif] + .ai [reply to image | video | gif] [custom prompt] + + .ai -a [-m|-f] (defaults to female voice) + + .ai -sp TTS the following conversation between Joe and Jane: + Joe: How's it going today Jane? + Jane: Not too bad, how about you? + """ + + reply = message.replied + prompt = message.filtered_input.strip() + + if reply and reply.media: + resp_str = "Processing... this may take a while." + else: + resp_str = "Input received... generating response." + + message_response = await message.reply(resp_str) + + try: + prompts = await create_prompts(message=message) + except AssertionError as e: + await message_response.edit(e) + return + + kwargs = AIConfig.get_kwargs(flags=message.flags) + + response = await async_client.models.generate_content(contents=prompts, **kwargs) + + response = Response(response) + + text = response.text_with_sources() + + if response.image: + await message_response.edit_media( + media=InputMediaPhoto(media=response.image_file, caption=f"**>\n•> {prompt}<**") + ) + return + + if response.audio: + if isinstance(message, Message): + await message.reply_voice( + voice=response.audio_file, + waveform=response.audio_file.waveform, + duration=response.audio_file.duration, + caption=f"**>\n•> {prompt}<**", + ) + else: + await message_response.edit_media( + media=InputMediaAudio( + media=response.audio_file, + caption=f"**>\n•> {prompt}<**", + duration=response.audio_file.duration, + ) + ) + return + + await message_response.edit( + text=f"**>\n•> {prompt}<**\n{text}", + parse_mode=ParseMode.MARKDOWN, + disable_preview=True, + ) diff --git a/app/plugins/ai/gemini/utils.py b/app/plugins/ai/gemini/utils.py new file mode 100644 index 0000000..49a9828 --- /dev/null +++ b/app/plugins/ai/gemini/utils.py @@ -0,0 +1,151 @@ +import asyncio +import shutil +import time +from functools import wraps +from mimetypes import guess_type + +from google.genai.types import File, Part +from ub_core.utils import get_tg_media_details + +from app import BOT, Message, extra_config +from app.plugins.ai.gemini import DB_SETTINGS, AIConfig, async_client + + +def run_basic_check(function): + @wraps(function) + async def wrapper(bot: BOT, message: Message): + if not extra_config.GEMINI_API_KEY: + await message.reply( + "Gemini API KEY not found." + "\nGet it HERE " + "and set GEMINI_API_KEY var." + ) + return + + if not (message.input or message.replied): + await message.reply("Ask a Question | Reply to a Message") + return + await function(bot, message) + + return wrapper + + +async def save_file(message: Message, check_size: bool = True) -> File | None: + media = get_tg_media_details(message) + + if check_size: + assert getattr(media, "file_size", 0) <= 1048576 * 25, "File size exceeds 25mb." + + download_dir = f"downloads/{time.time()}/" + try: + downloaded_file: str = await message.download(download_dir) + uploaded_file = await async_client.files.upload( + file=downloaded_file, + config={ + "mime_type": getattr(media, "mime_type", None) or guess_type(downloaded_file)[0] + }, + ) + while uploaded_file.state.name == "PROCESSING": + await asyncio.sleep(5) + uploaded_file = await async_client.files.get(name=uploaded_file.name) + + return uploaded_file + + finally: + shutil.rmtree(download_dir, ignore_errors=True) + + +PROMPT_MAP = { + "video": "Summarize video and audio from the file", + "photo": "Summarize the image file", + "voice": ( + "\nDo not summarise." + "\nTranscribe the audio file to english alphabets AS IS." + "\nTranslate it only if the audio is not english." + "\nIf the audio is in hindi: Transcribe it to hinglish without translating." + ), +} +PROMPT_MAP["audio"] = PROMPT_MAP["voice"] + + +async def create_prompts( + message: Message, is_chat: bool = False, check_size: bool = True +) -> list[File, str] | list[Part]: + + default_media_prompt = "Analyse the file and explain." + input_prompt = message.filtered_input or "answer" + + # Conversational + if is_chat: + if message.media: + prompt = message.caption or PROMPT_MAP.get(message.media.value) or default_media_prompt + text_part = Part.from_text(text=prompt) + uploaded_file = await save_file(message=message, check_size=check_size) + file_part = Part.from_uri(file_uri=uploaded_file.uri, mime_type=uploaded_file.mime_type) + return [text_part, file_part] + + return [Part.from_text(text=message.text)] + + # Single Use + if reply := message.replied: + if reply.media: + prompt = ( + message.filtered_input or PROMPT_MAP.get(reply.media.value) or default_media_prompt + ) + text_part = Part.from_text(text=prompt) + uploaded_file = await save_file(message=reply, check_size=check_size) + file_part = Part.from_uri(file_uri=uploaded_file.uri, mime_type=uploaded_file.mime_type) + return [text_part, file_part] + + return [Part.from_text(text=input_prompt), Part.from_text(text=str(reply.text))] + + return [Part.from_text(text=input_prompt)] + + +@BOT.add_cmd(cmd="llms") +async def list_ai_models(bot: BOT, message: Message): + """ + CMD: LIST MODELS + INFO: List and change Gemini Models. + USAGE: .llms + """ + model_list = [ + model.name.lstrip("models/") + async for model in await async_client.models.list(config={"query_base": True}) + if "generateContent" in model.supported_actions + ] + + model_str = "\n\n".join(model_list) + + update_str = ( + f"Current Model: " + f"{AIConfig.TEXT_MODEL if "-i" not in message.flags else AIConfig.IMAGE_MODEL}" + f"\n\n
{model_str}
" + "\n\nReply to this message with the model name to change to a different model." + ) + + model_info_response = await message.reply(update_str) + + model_response = await model_info_response.get_response( + timeout=60, reply_to_message_id=model_info_response.id, from_user=message.from_user.id + ) + + if not model_response: + await model_info_response.delete() + return + + if model_response.text not in model_list: + await model_info_response.edit(f"Invalid Model... Try again") + return + + if "-i" in message.flags: + data_key = "image_model_name" + AIConfig.IMAGE_MODEL = model_response.text + else: + data_key = "model_name" + AIConfig.TEXT_MODEL = model_response.text + + await DB_SETTINGS.add_data({"_id": "gemini_model_info", data_key: model_response.text}) + resp_str = f"{model_response.text} saved as model." + await model_info_response.edit(resp_str) + await bot.log_text(text=resp_str, type=f"ai_{data_key}") diff --git a/app/plugins/ai/openai.py b/app/plugins/ai/openai.py new file mode 100644 index 0000000..c283a0b --- /dev/null +++ b/app/plugins/ai/openai.py @@ -0,0 +1,170 @@ +from base64 import b64decode +from io import BytesIO +from os import environ + +import openai +from pyrogram.enums import ParseMode +from pyrogram.types import InputMediaPhoto + +from app import BOT, Message +from app.plugins.ai.gemini.config import SYSTEM_INSTRUCTION + +OPENAI_CLIENT = environ.get("OPENAI_CLIENT", "") +OPENAI_MODEL = environ.get("OPENAI_MODEL", "gpt-4o") + +AI_CLIENT = getattr(openai, f"Async{OPENAI_CLIENT}OpenAI") + +if AI_CLIENT == openai.AsyncAzureOpenAI: + text_init_kwargs = dict( + api_key=environ.get("AZURE_OPENAI_API_KEY"), + api_version=environ.get("OPENAI_API_VERSION"), + azure_endpoint=environ.get("AZURE_OPENAI_ENDPOINT"), + azure_deployment=environ.get("AZURE_DEPLOYMENT"), + ) + image_init_kwargs = dict( + api_key=environ.get("DALL_E_API_KEY"), + api_version=environ.get("DALL_E_API_VERSION"), + azure_endpoint=environ.get("DALL_E_ENDPOINT"), + azure_deployment=environ.get("DALL_E_DEPLOYMENT"), + ) +else: + text_init_kwargs = dict( + api_key=environ.get("OPENAI_API_KEY"), base_url=environ.get("OPENAI_BASE_URL") + ) + image_init_kwargs = dict( + api_key=environ.get("DALL_E_API_KEY"), base_url=environ.get("DALL_E_ENDPOINT") + ) + +try: + TEXT_CLIENT = AI_CLIENT(**text_init_kwargs) +except: + TEXT_CLIENT = None + +try: + DALL_E_CLIENT = AI_CLIENT(**image_init_kwargs) +except: + DALL_E_CLIENT = None + + +@BOT.add_cmd(cmd="gpt") +async def chat_gpt(bot: BOT, message: Message): + """ + CMD: GPT + INFO: Ask a question to chat gpt. + + SETUP: + To use this command you need to set either of these vars. + + For Default Client: + OPENAI_API_KEY = your API key + OPENAI_MODEL = model (optional, defaults to gpt-4o) + OPENAI_BASE_URL = a custom endpoint (optional) + + For Azure Client: + OPENAI_CLIENT="Azure" + OPENAI_API_VERSION = your version + OPENAI_MODEL = your azure model + AZURE_OPENAI_API_KEY = your api key + AZURE_OPENAI_ENDPOINT = your azure endpoint + AZURE_DEPLOYMENT = your azure deployment + + USAGE: + .gpt hi + .gpt [reply to a message] + """ + if TEXT_CLIENT is None: + await message.reply(f"OpenAI Creds not set or are invalid.\nCheck Help.") + return + + reply_text = message.replied.text if message.replied else "" + + prompt = f"{reply_text}\n\n\n{message.input}".strip() + + if not prompt: + await message.reply("Ask a Question | Reply to a message.") + return + + chat_completion = await TEXT_CLIENT.chat.completions.create( + messages=[ + {"role": "system", "content": SYSTEM_INSTRUCTION}, + {"role": "user", "content": prompt}, + ], + model=OPENAI_MODEL, + ) + + response = chat_completion.choices[0].message.content + + await message.reply(text=f"**>\n••> {prompt}<**\n" + response, parse_mode=ParseMode.MARKDOWN) + + +@BOT.add_cmd(cmd="igen") +async def chat_gpt(bot: BOT, message: Message): + """ + CMD: IGEN + INFO: Generate Images using Dall-E. + + SETUP: + To use this command you need to set either of these vars. + + For Default Client: + DALL_E_API_KEY = your API key + DALL_E_ENDPOINT = a custom endpoint (optional) + + For Azure Client: + OPENAI_CLIENT="Azure" + DALL_E_API_KEY = your api key + DALL_E_API_VERSION = your version + DALL_E_ENDPOINT = your azure endpoint + DALL_E_DEPLOYMENT = your azure deployment + + FLAGS: + -v: for vivid style images (default) + -n: for less vivid and natural type of images + -s: to send with spoiler + -p: portrait output + -l: landscape output + + USAGE: + .igen cats on moon + """ + if DALL_E_CLIENT is None: + await message.reply(f"OpenAI Creds not set or are invalid.\nCheck Help.") + return + + prompt = message.filtered_input.strip() + + if not prompt: + await message.reply("Give a prompt to generate image.") + return + + response = await message.reply("Generating image...") + + if "-p" in message.flags: + output_res = "1024x1792" + elif "-l" in message.flags: + output_res = "1792x1024" + else: + output_res = "1024x1024" + + try: + generated_image = await DALL_E_CLIENT.images.generate( + model="dall-e-3", + prompt=prompt, + n=1, + size=output_res, + quality="hd", + response_format="b64_json", + style="natural" if "-n" in message.flags else "vivid", + ) + except Exception: + await response.edit("Something went wrong... Check log channel.") + raise + + image_io = BytesIO(b64decode(generated_image.data[0].b64_json)) + image_io.name = "photo.png" + + await response.edit_media( + InputMediaPhoto( + media=image_io, caption=f"**>\n{prompt}\n<**", has_spoiler="-s" in message.flags + ) + ) diff --git a/app/plugins/files/anonfiles.py b/app/plugins/files/anonfiles.py new file mode 100644 index 0000000..08092a4 --- /dev/null +++ b/app/plugins/files/anonfiles.py @@ -0,0 +1,258 @@ +import asyncio +import aiohttp +import time +import json +from pathlib import Path + +from ub_core.utils import Download, DownloadedFile, get_tg_media_details, progress + +from app import BOT, Message, bot + + +class AnonFiles: + # Multiple anonymous file hosting services + SERVICES = { + "anonfiles": { + "upload_url": "https://api.anonfiles.com/upload", + "name": "AnonFiles", + "max_size": "20GB" + }, + "bayfiles": { + "upload_url": "https://api.bayfiles.com/upload", + "name": "BayFiles", + "max_size": "20GB" + }, + "letsupload": { + "upload_url": "https://api.letsupload.cc/upload", + "name": "LetsUpload", + "max_size": "10GB" + }, + "filechan": { + "upload_url": "https://api.filechan.org/upload", + "name": "FileChan", + "max_size": "5GB" + } + } + + def __init__(self): + self._session = None + + async def get_session(self): + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close_session(self): + if self._session and not self._session.closed: + await self._session.close() + + async def upload_file(self, file_path: Path, service: str = "anonfiles"): + """Upload file to anonymous file hosting service""" + session = await self.get_session() + + if service not in self.SERVICES: + raise Exception(f"Unknown service: {service}") + + service_info = self.SERVICES[service] + + try: + with open(file_path, 'rb') as f: + data = aiohttp.FormData() + data.add_field('file', f, filename=file_path.name) + + async with session.post(service_info["upload_url"], data=data) as response: + if response.status == 200: + result = await response.json() + + if result.get('status') == True or result.get('success') == True: + file_data = result.get('data', {}) + file_info = file_data.get('file', file_data) + + return { + 'service': service_info['name'], + 'filename': file_info.get('metadata', {}).get('name') or file_path.name, + 'size': file_info.get('metadata', {}).get('size', {}).get('bytes', file_path.stat().st_size), + 'url': file_info.get('url', {}).get('full') or file_info.get('url'), + 'short_url': file_info.get('url', {}).get('short'), + 'download_url': file_info.get('download', {}).get('url'), + 'delete_url': file_info.get('remove', {}).get('url'), + 'id': file_info.get('id') + } + else: + error = result.get('error', {}) + raise Exception(f"Upload failed: {error.get('message', 'Unknown error')}") + else: + error_text = await response.text() + raise Exception(f"Upload failed: {response.status} - {error_text}") + + except Exception as e: + raise Exception(f"{service_info['name']} upload error: {str(e)}") + + +anonfiles = AnonFiles() + + +@bot.add_cmd(cmd="anonup") +async def anon_upload(bot: BOT, message: Message): + """ + CMD: ANONUP + INFO: Upload files to anonymous file hosting services + FLAGS: -s for service selection (anonfiles, bayfiles, letsupload, filechan) + USAGE: + .anonup [reply to media] + .anonup -s bayfiles [reply to media] + .anonup [url] + """ + response = await message.reply("🔄 Processing upload request...") + + if not message.replied and not message.input: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + try: + # Parse service selection + service = "anonfiles" + input_text = message.filtered_input if message.filtered_input else message.input + + if "-s" in message.flags: + parts = input_text.split() if input_text else [] + if len(parts) >= 1 and parts[0] in anonfiles.SERVICES: + service = parts[0] + input_text = " ".join(parts[1:]) if len(parts) > 1 else "" + + dl_dir = Path("downloads") / str(time.time()) + + # Handle replied media + if message.replied and message.replied.media: + await response.edit("📥 Downloading media from Telegram...") + + tg_media = get_tg_media_details(message.replied) + file_name = tg_media.file_name or f"file_{int(time.time())}" + file_path = dl_dir / file_name + + dl_dir.mkdir(parents=True, exist_ok=True) + + await message.replied.download( + file_name=file_path, + progress=progress, + progress_args=(response, "Downloading from Telegram...", file_path) + ) + + # Handle URL input + elif input_text and input_text.strip(): + url = input_text.strip() + if not url.startswith(('http://', 'https://')): + await response.edit("❌ Invalid URL!\nPlease provide a valid HTTP/HTTPS URL.") + return + + await response.edit("📥 Downloading from URL...") + + dl_obj = await Download.setup( + url=url, + dir=dl_dir, + message_to_edit=response + ) + + downloaded_file = await dl_obj.download() + file_path = downloaded_file.path + await dl_obj.close() + else: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + # Upload to selected service + service_name = anonfiles.SERVICES[service]['name'] + await response.edit(f"📤 Uploading to {service_name}...") + file_info = await anonfiles.upload_file(file_path, service) + + # Cleanup + if file_path.exists(): + file_path.unlink() + if dl_dir.exists() and not any(dl_dir.iterdir()): + dl_dir.rmdir() + + # Format response + size_mb = file_info['size'] / (1024 * 1024) + + result_text = f"✅ Successfully uploaded to {file_info['service']}!\n\n" + result_text += f"📁 File: {file_info['filename']}\n" + result_text += f"📊 Size: {size_mb:.2f} MB\n" + result_text += f"🆔 ID: {file_info.get('id', 'N/A')}\n\n" + result_text += f"🔗 Download URL:\n{file_info['url']}\n\n" + + if file_info.get('short_url'): + result_text += f"🔗 Short URL:\n{file_info['short_url']}\n\n" + + if file_info.get('delete_url'): + result_text += f"🗑 Delete URL:\n{file_info['delete_url']}" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Upload failed!\n\n{str(e)}") + finally: + await anonfiles.close_session() + + +@bot.add_cmd(cmd="anonservices") +async def anon_services(bot: BOT, message: Message): + """ + CMD: ANONSERVICES + INFO: List available anonymous file hosting services + USAGE: .anonservices + """ + services_text = f"📋 Available Anonymous File Hosting Services\n\n" + + for service_id, service_info in anonfiles.SERVICES.items(): + services_text += f"🔸 {service_info['name']}\n" + services_text += f" • ID: {service_id}\n" + services_text += f" • Max Size: {service_info['max_size']}\n\n" + + services_text += f"💡 Usage:\n" + services_text += f"• .anonup - Upload to AnonFiles (default)\n" + services_text += f"• .anonup -s bayfiles - Upload to BayFiles\n" + services_text += f"• .anonup -s letsupload - Upload to LetsUpload\n" + services_text += f"• .anonup -s filechan - Upload to FileChan\n\n" + + services_text += f"ℹ️ Features:\n" + services_text += f"• Anonymous uploads (no registration)\n" + services_text += f"• Large file support\n" + services_text += f"• Automatic cleanup\n" + services_text += f"• Delete URLs provided" + + await message.reply(services_text) + + +@bot.add_cmd(cmd="anonhelp") +async def anon_help(bot: BOT, message: Message): + """ + CMD: ANONHELP + INFO: Show anonymous file hosting help + USAGE: .anonhelp + """ + help_text = f"📋 Anonymous File Hosting Help\n\n" + + help_text += f"🚀 Quick Start:\n" + help_text += f"• Reply to any media: .anonup\n" + help_text += f"• Upload from URL: .anonup https://example.com/file.zip\n\n" + + help_text += f"⚙️ Service Selection:\n" + help_text += f"• .anonup -s bayfiles - Use BayFiles\n" + help_text += f"• .anonup -s letsupload - Use LetsUpload\n" + help_text += f"• .anonup -s filechan - Use FileChan\n\n" + + help_text += f"📊 File Size Limits:\n" + help_text += f"• AnonFiles & BayFiles: 20GB\n" + help_text += f"• LetsUpload: 10GB\n" + help_text += f"• FileChan: 5GB\n\n" + + help_text += f"🔧 Other Commands:\n" + help_text += f"• .anonservices - List all services\n" + help_text += f"• .anonhelp - Show this help\n\n" + + help_text += f"⚠️ Important:\n" + help_text += f"• Files are uploaded anonymously\n" + help_text += f"• Save delete URLs to remove files later\n" + help_text += f"• No account registration required" + + await message.reply(help_text) \ No newline at end of file diff --git a/app/plugins/files/catbox.py b/app/plugins/files/catbox.py new file mode 100644 index 0000000..9de4fa0 --- /dev/null +++ b/app/plugins/files/catbox.py @@ -0,0 +1,288 @@ +import asyncio +import aiohttp +import time +from pathlib import Path + +from ub_core.utils import Download, DownloadedFile, get_tg_media_details, progress + +from app import BOT, Message, bot + + +class CatBox: + UPLOAD_URL = "https://catbox.moe/user/api.php" + LITTERBOX_URL = "https://litterbox.catbox.moe/resources/internals/api.php" + + def __init__(self): + self._session = None + + async def get_session(self): + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close_session(self): + if self._session and not self._session.closed: + await self._session.close() + + async def upload_file(self, file_path: Path, temporary: bool = False, expiry: str = "1h"): + """ + Upload file to CatBox or LitterBox (temporary) + + Args: + file_path: Path to file to upload + temporary: If True, upload to LitterBox (temporary), else CatBox (permanent) + expiry: For LitterBox only - "1h", "12h", "24h", "72h" + """ + session = await self.get_session() + + try: + if temporary: + url = self.LITTERBOX_URL + valid_expiry = ["1h", "12h", "24h", "72h"] + if expiry not in valid_expiry: + expiry = "1h" + + data = aiohttp.FormData() + data.add_field('reqtype', 'fileupload') + data.add_field('time', expiry) + + else: + url = self.UPLOAD_URL + data = aiohttp.FormData() + data.add_field('reqtype', 'fileupload') + + with open(file_path, 'rb') as f: + data.add_field('fileToUpload', f, filename=file_path.name) + + async with session.post(url, data=data) as response: + if response.status == 200: + result_url = (await response.text()).strip() + + if result_url.startswith('http'): + return { + 'url': result_url, + 'filename': file_path.name, + 'size': file_path.stat().st_size, + 'temporary': temporary, + 'expiry': expiry if temporary else None + } + else: + raise Exception(f"Upload failed: {result_url}") + else: + error_text = await response.text() + raise Exception(f"Upload failed: {response.status} - {error_text}") + + except Exception as e: + raise Exception(f"CatBox upload error: {str(e)}") + + +catbox = CatBox() + + +@bot.add_cmd(cmd="catup") +async def catbox_upload(bot: BOT, message: Message): + """ + CMD: CATUP + INFO: Upload files to CatBox (permanent hosting) + USAGE: + .catup [reply to media] + .catup [url] + """ + response = await message.reply("🔄 Processing upload request...") + + if not message.replied and not message.input: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + try: + dl_dir = Path("downloads") / str(time.time()) + + # Handle replied media + if message.replied and message.replied.media: + await response.edit("📥 Downloading media from Telegram...") + + tg_media = get_tg_media_details(message.replied) + file_name = tg_media.file_name or f"file_{int(time.time())}" + file_path = dl_dir / file_name + + dl_dir.mkdir(parents=True, exist_ok=True) + + await message.replied.download( + file_name=file_path, + progress=progress, + progress_args=(response, "Downloading from Telegram...", file_path) + ) + + # Handle URL input + elif message.input: + url = message.input.strip() + if not url.startswith(('http://', 'https://')): + await response.edit("❌ Invalid URL!\nPlease provide a valid HTTP/HTTPS URL.") + return + + await response.edit("📥 Downloading from URL...") + + dl_obj = await Download.setup( + url=url, + dir=dl_dir, + message_to_edit=response + ) + + downloaded_file = await dl_obj.download() + file_path = downloaded_file.path + await dl_obj.close() + + # Upload to CatBox + await response.edit("📤 Uploading to CatBox...") + file_info = await catbox.upload_file(file_path, temporary=False) + + # Cleanup + if file_path.exists(): + file_path.unlink() + if dl_dir.exists() and not any(dl_dir.iterdir()): + dl_dir.rmdir() + + # Format response + size_mb = file_info['size'] / (1024 * 1024) + + result_text = f"✅ Successfully uploaded to CatBox!\n\n" + result_text += f"📁 File: {file_info['filename']}\n" + result_text += f"📊 Size: {size_mb:.2f} MB\n" + result_text += f"🏠 Hosting: Permanent\n\n" + result_text += f"🔗 URL:\n{file_info['url']}" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Upload failed!\n\n{str(e)}") + finally: + await catbox.close_session() + + +@bot.add_cmd(cmd="litterup") +async def litterbox_upload(bot: BOT, message: Message): + """ + CMD: LITTERUP + INFO: Upload files to LitterBox (temporary hosting) + FLAGS: -t for expiry time (1h, 12h, 24h, 72h) + USAGE: + .litterup [reply to media] + .litterup -t 24h [reply to media] + .litterup [url] + """ + response = await message.reply("🔄 Processing upload request...") + + if not message.replied and not message.input: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + try: + # Parse expiry time + expiry = "1h" + input_text = message.filtered_input if message.filtered_input else message.input + + if "-t" in message.flags: + parts = input_text.split() + if len(parts) >= 1 and parts[0] in ["1h", "12h", "24h", "72h"]: + expiry = parts[0] + input_text = " ".join(parts[1:]) if len(parts) > 1 else "" + + dl_dir = Path("downloads") / str(time.time()) + + # Handle replied media + if message.replied and message.replied.media: + await response.edit("📥 Downloading media from Telegram...") + + tg_media = get_tg_media_details(message.replied) + file_name = tg_media.file_name or f"file_{int(time.time())}" + file_path = dl_dir / file_name + + dl_dir.mkdir(parents=True, exist_ok=True) + + await message.replied.download( + file_name=file_path, + progress=progress, + progress_args=(response, "Downloading from Telegram...", file_path) + ) + + # Handle URL input + elif input_text and input_text.strip(): + url = input_text.strip() + if not url.startswith(('http://', 'https://')): + await response.edit("❌ Invalid URL!\nPlease provide a valid HTTP/HTTPS URL.") + return + + await response.edit("📥 Downloading from URL...") + + dl_obj = await Download.setup( + url=url, + dir=dl_dir, + message_to_edit=response + ) + + downloaded_file = await dl_obj.download() + file_path = downloaded_file.path + await dl_obj.close() + else: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + # Upload to LitterBox + await response.edit("📤 Uploading to LitterBox...") + file_info = await catbox.upload_file(file_path, temporary=True, expiry=expiry) + + # Cleanup + if file_path.exists(): + file_path.unlink() + if dl_dir.exists() and not any(dl_dir.iterdir()): + dl_dir.rmdir() + + # Format response + size_mb = file_info['size'] / (1024 * 1024) + + result_text = f"✅ Successfully uploaded to LitterBox!\n\n" + result_text += f"📁 File: {file_info['filename']}\n" + result_text += f"📊 Size: {size_mb:.2f} MB\n" + result_text += f"⏰ Expires: {file_info['expiry']}\n" + result_text += f"🗑 Hosting: Temporary\n\n" + result_text += f"🔗 URL:\n{file_info['url']}" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Upload failed!\n\n{str(e)}") + finally: + await catbox.close_session() + + +@bot.add_cmd(cmd="catboxhelp") +async def catbox_help(bot: BOT, message: Message): + """ + CMD: CATBOXHELP + INFO: Show CatBox and LitterBox help information + USAGE: .catboxhelp + """ + help_text = f"📋 CatBox & LitterBox Commands\n\n" + + help_text += f"🏠 CatBox (Permanent Hosting):\n" + help_text += f"• .catup - Upload files permanently\n" + help_text += f"• No expiration, files stay forever\n" + help_text += f"• Max file size: 200MB\n\n" + + help_text += f"🗑 LitterBox (Temporary Hosting):\n" + help_text += f"• .litterup - Upload files temporarily\n" + help_text += f"• .litterup -t 24h - Set expiry time\n" + help_text += f"• Available expiry: 1h, 12h, 24h, 72h\n" + help_text += f"• Max file size: 1GB\n\n" + + help_text += f"💡 Usage Examples:\n" + help_text += f"• Reply to media: .catup\n" + help_text += f"• Upload from URL: .catup https://example.com/file.zip\n" + help_text += f"• Temporary with custom expiry: .litterup -t 72h\n\n" + + help_text += f"⚠️ Notes:\n" + help_text += f"• CatBox for permanent storage\n" + help_text += f"• LitterBox for temporary sharing\n" + help_text += f"• Both services are anonymous" + + await message.reply(help_text) \ No newline at end of file diff --git a/app/plugins/files/gdrive.py b/app/plugins/files/gdrive.py new file mode 100644 index 0000000..c60b035 --- /dev/null +++ b/app/plugins/files/gdrive.py @@ -0,0 +1,543 @@ +import asyncio +import json +import os +from collections import defaultdict +from functools import wraps + +import aiohttp +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from pyrogram.enums import ParseMode +from ub_core import BOT, Config, CustomDB, Message, bot +from ub_core.utils import Download, get_tg_media_details, progress + +DB = CustomDB["COMMON_SETTINGS"] + +INSTRUCTIONS = """ +Gdrive Credentials and Access token not found! + +- Get credentials.json from: https://console.cloud.google.com + +
Steps: +• Enable google drive api. +• Setup consent screen. +• Select external app. +• Add yourself in audience. +• Go back. +• Add the Google drive scope in data access. +• Create a desktop app and download the json in credentials section.
+ +- Upload this file to your saved messages and reply to it with .gsetup +""" + + +class Drive: + URL_TEMPLATE = "https://drive.google.com/file/d/{media_id}/view?usp=sharing" + FOLDER_MIME = "application/vnd.google-apps.folder" + SHORTCUT_MIME = "application/vnd.google-apps.shortcut" + DRIVE_ROOT_ID = os.getenv("DRIVE_ROOT_ID", "root") + + def __init__(self): + self._aiohttp_session = None + self._progress_store: dict[str, dict[str, str | int | asyncio.Task]] = defaultdict(dict) + self._creds: Credentials | None = None + self.service = None + self.files = None + self.is_authenticated = False + + async def async_init(self): + if self._aiohttp_session is None: + self._aiohttp_session = aiohttp.ClientSession() + Config.EXIT_TASKS.append(self._aiohttp_session.close) + await self.set_creds() + + @property + def creds(self): + if ( + isinstance(self._creds, Credentials) + and self._creds.expired + and self._creds.refresh_token + ): + self._creds.refresh(Request()) + asyncio.run_coroutine_threadsafe( + coro=DB.add_data( + {"_id": "drive_creds", "creds": json.loads(self._creds.to_json())} + ), + loop=bot.loop, + ) + bot.log.info("Gdrive Creds Auto-Refreshed") + return self._creds + + @creds.setter + def creds(self, creds): + self._creds = creds + + async def set_creds(self): + cred_data = await DB.find_one({"_id": "drive_creds"}) + if not cred_data: + self.is_authenticated = False + return + + self.creds = Credentials.from_authorized_user_info( + info=cred_data["creds"], scopes=["https://www.googleapis.com/auth/drive"] + ) + self.service = build( + serviceName="drive", version="v3", credentials=self.creds, cache_discovery=False + ) + self.files = self.service.files() + self.is_authenticated = True + + def ensure_creds(self, func): + @wraps(func) + async def inner(bot: BOT, message: Message): + if not self.is_authenticated: + await message.reply(INSTRUCTIONS) + else: + await func(bot, message) + + return inner + + async def list_contents( + self, + _id: bool = False, + limit: int = 10, + file_only: bool = False, + folder_only: bool = False, + search_param: str | None = None, + ) -> list[dict[str, str | int]]: + """ + :param _id: The ID of the folder to list files from. + :param limit: Number of results to fetch. + :param file_only: If True, only list files. + :param folder_only: If True, only list folders. + :param search_param: A string to search for in file/folder names. + :return: A list of dictionaries containing file/folder id, name and mimeType. + """ + return await asyncio.to_thread(self._list, _id, limit, file_only, folder_only, search_param) + + async def upload_from_url( + self, + file_url: str, + is_encoded: bool = False, + folder_id: str = None, + message_to_edit: Message = None, + ): + try: + file_id = await self._upload_from_url(file_url, is_encoded, folder_id, message_to_edit) + if file_id is not None: + return self.URL_TEMPLATE.format(media_id=file_id) + except Exception as e: + return f"Error:\n{e}" + finally: + store = self._progress_store.pop(file_url, {}) + store["done"] = True + task = store.get("edit_task") + if isinstance(task, asyncio.Task): + task.cancel() + + async def upload_from_telegram( + self, media_message: Message, message_to_edit: Message = None, folder_id: str = None + ): + try: + file_id = await self._upload_from_telegram(media_message, message_to_edit, folder_id) + if file_id is not None: + return self.URL_TEMPLATE.format(media_id=file_id) + except Exception as e: + return f"Error:\n{e}" + finally: + store = self._progress_store.pop(message_to_edit.task_id, {}) + store["done"] = True + task = store.get("edit_task") + if isinstance(task, asyncio.Task): + task.cancel() + + def _list( + self, + _id: bool = False, + limit: int = 10, + file_only: bool = False, + folder_only: bool = False, + search_param: str | None = None, + ) -> list[dict[str, str | int]]: + + query_params = ["trashed=false"] + + if folder_only: + query_params.append(f"mimeType = '{self.FOLDER_MIME}'") + elif file_only: + query_params.append(f"mimeType != '{self.FOLDER_MIME}'") + + if search_param is not None: + if _id: + query_params.append(f"'{search_param}' in parents") + else: + query_params.append(f"name contains '{search_param}'") + else: + query_params.append(f"'{self.DRIVE_ROOT_ID}' in parents") + + query = " and ".join(query_params) + + files = [] + + fields = "nextPageToken, files(id, name, mimeType, shortcutDetails)" + result = self.files.list(q=query, pageSize=limit, fields=fields).execute() + files.extend(result.get("files", [])) + + while next_token := result.get("nextPageToken"): + if len(files) >= limit: + break + else: + file_limit = limit - len(files) + result = self.files.list( + q=query, pageSize=file_limit, fields=fields, pageToken=next_token + ).execute() + files.extend(result.get("files", [])) + + return files[0:limit] + + async def create_file(self, file_name: str, folder_id: str = None) -> str: + """ + :return: An url pointing to a location in drive. + """ + headers = { + "Authorization": f"Bearer {self.creds.token}", + "Content-Type": "application/json", + "X-Upload-Content-Type": "application/octet-stream", + } + async with self._aiohttp_session.post( + url="https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", + json={"name": file_name, "parents": [folder_id or self.DRIVE_ROOT_ID]}, + headers=headers, + ) as resp: + if resp.status != 200: + text = await resp.text() + raise Exception(f"Initiate failed: {text}") + return resp.headers["Location"] + + async def upload_chunk(self, location, headers, chunk) -> str | None: + async with self._aiohttp_session.put(location, headers=headers, data=chunk) as put: + if put.status == 308: + # Chunk accepted, not finished yet + return None + elif put.status in (200, 201): + # File finished + file = await put.json() + return file["id"] + else: + text = await put.text() + raise Exception(f"Chunk upload failed with {put.status}: {text}") + + async def _upload_from_url( + self, + file_url: str, + is_encoded: bool = False, + folder_id: str = None, + message_to_edit: Message = None, + ): + async with Download(url=file_url, dir="", is_encoded_url=is_encoded) as downloader: + store = self._progress_store[file_url] + store["size"] = downloader.size_bytes + store["done"] = False + store["uploaded_size"] = 0 + store["edit_task"] = asyncio.create_task( + self.progress_worker(store, message_to_edit), name="url_drive_up_prog" + ) + + file_session = downloader.file_response_session + file_session.raise_for_status() + drive_location = await self.create_file(downloader.file_name, folder_id) + start = 0 + buffer = b"" + chunk_size = 524288 + + async for chunk in downloader.iter_chunks(chunk_size): + buffer += chunk + if len(buffer) < chunk_size: + continue + else: + chunk = buffer[:chunk_size] + end = start + len(chunk) - 1 + put_headers = { + "Content-Range": f"bytes {start}-{end}/{downloader.size_bytes}", + "Authorization": f"Bearer {self.creds.token}", + } + file_id = await self.upload_chunk(drive_location, put_headers, chunk) + start += len(chunk) + store["uploaded_size"] += len(chunk) + buffer = buffer[chunk_size:] + + if buffer: + end = start + len(buffer) - 1 + put_headers = { + "Content-Range": f"bytes {start}-{end}/{downloader.size_bytes}", + "Authorization": f"Bearer {self.creds.token}", + } + file_id = await self.upload_chunk(drive_location, put_headers, buffer) + start = end + 1 + store["uploaded_size"] = start + buffer = b"" + + store["done"] = True + return file_id + + async def _upload_from_telegram( + self, media_message: Message, message_to_edit: Message = None, folder_id: str = None + ): + media = get_tg_media_details(media_message) + + store = self._progress_store[message_to_edit.task_id] + store["size"] = getattr(media, "file_size", 0) + store["done"] = False + store["uploaded_size"] = 0 + store["edit_task"] = asyncio.create_task( + self.progress_worker(store, message_to_edit), name="tg_drive_up_prog" + ) + + start = 0 + drive_location = await self.create_file(getattr(media, "file_name"), folder_id) + file_id = None + # noinspection PyTypeChecker + async for chunk in message_to_edit._client.stream_media(message=media_message): + end = start + len(chunk) - 1 + headers = { + "Content-Range": f"bytes {start}-{end}/{getattr(media, "file_size", 0)}", + "Authorization": f"Bearer {self.creds.token}", + } + file_id = await self.upload_chunk(drive_location, headers, chunk) + start = end + 1 + store["uploaded_size"] = end + 1 + + return file_id + + @staticmethod + async def progress_worker(store: dict, message: Message): + if not isinstance(message, Message): + return + + while not store["done"]: + await progress( + current_size=store["uploaded_size"], + total_size=store["size"] or 1, + response=message, + action_str="Uploading to Drive...", + ) + await asyncio.sleep(5) + + +drive = Drive() + + +async def init_task(): + await drive.async_init() + + +@BOT.add_cmd("gsetup") +async def gdrive_creds_setup(bot: BOT, message: Message): + """ + CMD: GSETUP + INFO: Generated and save O-Auth Creds Json to bot. + USAGE: .gsetup {reply to credentials.json file} + """ + + try: + assert message.replied.document.file_name == "credentials.json" + except (AssertionError, AttributeError): + await message.reply("credentials.json not found.") + return + + try: + cred_file = await message.replied.download(in_memory=True) + cred_file.seek(0) + flow = InstalledAppFlow.from_client_config( + json.load(cred_file), + ["https://www.googleapis.com/auth/drive"], + ) + flow.redirect_uri = "urn:ietf:wg:oauth:2.0:oob" + auth_url, state = flow.authorization_url(prompt="consent") + + auth_message = await message.reply( + f"Please go to this URL and authorize:\n{auth_url}\n\nReply to this message with the code within 30 seconds.", + ) + code_message = await auth_message.get_response( + from_user=message.from_user.id, reply_to_message_id=auth_message.id, timeout=30 + ) + + await auth_message.delete() + + if not code_message: + await message.reply("expired") + return + + await code_message.delete() + flow.fetch_token(code=code_message.text) + await DB.add_data({"_id": "drive_creds", "creds": json.loads(flow.credentials.to_json())}) + await drive.set_creds() + await message.reply("Creds Saved!") + except Exception as e: + await message.reply(e) + + +@BOT.add_cmd("agcreds") +async def set_drive_creds(bot: BOT, message: Message): + """ + CMD: AGCREDS + INFO: Add your pre generated O-Auth Creds Json to bot. + USAGE: .agcreds {data} + """ + creds = message.input.strip() + + if not creds: + await message.reply("Enter Creds!!!") + return + + try: + creds_json = json.loads(creds) + creds = Credentials.from_authorized_user_info(info=creds_json) + + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + + await DB.add_data({"_id": "drive_creds", "creds": json.loads(creds.to_json())}) + await drive.set_creds() + await message.reply("Creds added!") + except Exception as e: + await message.reply(e) + + +@BOT.add_cmd("rgcreds") +async def remove_drive_creds(bot: BOT, message: Message): + response = await message.reply( + "Are you sure you want to delete drive creds?\nreply with y to continue" + ) + + resp = await response.get_response(from_user=message.from_user.id) + if not (resp and resp.text in ("y", "Y")): + await response.edit("Aborted!!!") + return + + drive.is_authenticated = False + await DB.delete_data({"_id": "drive_creds"}) + await response.edit("Creds Deleted Successfully!") + + +@BOT.add_cmd("gls") +@drive.ensure_creds +async def list_drive(bot: BOT, message: Message): + """ + CMD: GLS + INFO: List Files/Folders from Drive + FLAGS: + -f: list files only + -d: list dirs only + -id: list via folder id + -l: limit of results (10 by default) + + USAGE: + .gls [-f|-d] + .gls [-f|-d] abc (lists files/folders matching abc in name) + .gls -id + .gls [-f|-d] -l 20 (lists 20 results) + .gls -l 20 abc (tries to list 20 results containing abc in name) + """ + response = await message.reply("Listing...") + flags = message.flags + filtered_input_chunks = message.filtered_input.split(maxsplit=1) + + kwargs = { + "_id": False, + "limit": 10, + "folder_only": False, + "file_only": False, + "search_param": None, + } + + # Search by ID + if "-id" in flags: + kwargs["_id"] = True + # list folders + if "-d" in flags: + kwargs["folder_only"] = True + # list files + if "-f" in flags: + kwargs["file_only"] = True + + # limit total number of results + if "-l" in flags: + kwargs["limit"] = int(filtered_input_chunks[0]) + # search for specific files/dirs + if len(filtered_input_chunks) == 2: + kwargs["search_param"] = filtered_input_chunks[1] + else: + # search for specific files/dirs + kwargs["search_param"] = message.filtered_input.strip() or None + + remote_files = await drive.list_contents(**kwargs) + + if not remote_files: + await response.edit("No results found.") + return + + folders = [] + files = [""] + shortcuts = [""] + + for file in remote_files: + url = drive.URL_TEMPLATE.format(media_id=file["id"]) + mime = file["mimeType"] + if mime == drive.FOLDER_MIME: + folders.append(f"📁 {file["name"]}") + elif mime == drive.SHORTCUT_MIME: + shortcut_details = file.get("shortcutDetails", {}) + target_id = shortcut_details.get("targetId") + if target_id: + url = drive.URL_TEMPLATE.format(media_id=target_id) + shortcuts.append(f"🔗 {file["name"]}") + else: + files.append(f"📄 {file["name"]}") + + list_str = "Results:\n\n" + "\n".join(folders + shortcuts + files) + + await response.edit(list_str, parse_mode=ParseMode.HTML) + + +@BOT.add_cmd(cmd="gup") +@drive.ensure_creds +async def upload_to_drive(bot: BOT, message: Message): + """ + CMD: GUP + INFO: Upload file to drive + FLAGS: + -id: folder id + -e: if the url is encoded + USAGE: + .gup [reply to a message | url] + .gup -id [reply to a message | url] + """ + reply = message.replied + response = await message.reply("Checking Input...") + + if reply and reply.media: + folder_id = message.filtered_input if "-id" in message.flags else None + upload_coro = drive.upload_from_telegram(reply, response, folder_id=folder_id) + + elif message.filtered_input.startswith("http"): + if "-id" in message.flags: + folder_id, file_url = message.filtered_input.split(maxsplit=1) + else: + folder_id = None + file_url = message.filtered_input + + upload_coro = drive.upload_from_url( + file_url=file_url, + is_encoded="-e" in message.flags, + folder_id=folder_id, + message_to_edit=response, + ) + + else: + await response.edit("Invalid Input!!!") + return + + await response.edit(await upload_coro) diff --git a/app/plugins/files/gofile.py b/app/plugins/files/gofile.py new file mode 100644 index 0000000..8f47698 --- /dev/null +++ b/app/plugins/files/gofile.py @@ -0,0 +1,300 @@ +import asyncio +import aiohttp +import time +import json +from pathlib import Path + +from ub_core.utils import Download, DownloadedFile, get_tg_media_details, progress + +from app import BOT, Message, bot + + +class GoFile: + BASE_URL = "https://api.gofile.io" + + def __init__(self): + self._session = None + + async def get_session(self): + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close_session(self): + if self._session and not self._session.closed: + await self._session.close() + + async def get_server(self): + """Get the best server for uploading""" + session = await self.get_session() + + try: + async with session.get(f"{self.BASE_URL}/getServer") as response: + if response.status == 200: + result = await response.json() + if result.get('status') == 'ok': + return result['data']['server'] + raise Exception("Failed to get upload server") + except Exception as e: + raise Exception(f"Server selection error: {str(e)}") + + async def upload_file(self, file_path: Path, folder_id: str = None): + """Upload file to GoFile""" + session = await self.get_session() + + try: + # Get upload server + server = await self.get_server() + upload_url = f"https://{server}.gofile.io/uploadFile" + + # Prepare form data + data = aiohttp.FormData() + + if folder_id: + data.add_field('folderId', folder_id) + + with open(file_path, 'rb') as f: + data.add_field('file', f, filename=file_path.name) + + async with session.post(upload_url, data=data) as response: + if response.status == 200: + result = await response.json() + + if result.get('status') == 'ok': + file_data = result['data'] + + return { + 'filename': file_path.name, + 'size': file_path.stat().st_size, + 'download_page': file_data.get('downloadPage'), + 'code': file_data.get('code'), + 'parent_folder': file_data.get('parentFolder'), + 'file_id': file_data.get('fileId'), + 'server': server, + 'direct_link': file_data.get('directLink') + } + else: + raise Exception(f"Upload failed: {result.get('errorMessage', 'Unknown error')}") + else: + error_text = await response.text() + raise Exception(f"Upload failed: {response.status} - {error_text}") + + except Exception as e: + raise Exception(f"GoFile upload error: {str(e)}") + + async def get_folder_contents(self, folder_id: str): + """Get contents of a GoFile folder""" + session = await self.get_session() + + try: + async with session.get(f"{self.BASE_URL}/getContent?contentId={folder_id}") as response: + if response.status == 200: + result = await response.json() + if result.get('status') == 'ok': + return result['data'] + else: + raise Exception(f"Failed to get folder contents: {result.get('errorMessage')}") + else: + raise Exception(f"Request failed: {response.status}") + except Exception as e: + raise Exception(f"Error getting folder contents: {str(e)}") + + +gofile = GoFile() + + +@bot.add_cmd(cmd="goup") +async def gofile_upload(bot: BOT, message: Message): + """ + CMD: GOUP + INFO: Upload files to GoFile + FLAGS: -f for folder ID + USAGE: + .goup [reply to media] + .goup -f [folder_id] [reply to media] + .goup [url] + """ + response = await message.reply("🔄 Processing upload request...") + + if not message.replied and not message.input: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + try: + # Parse folder ID + folder_id = None + input_text = message.filtered_input if message.filtered_input else message.input + + if "-f" in message.flags: + parts = input_text.split() if input_text else [] + if len(parts) >= 1: + folder_id = parts[0] + input_text = " ".join(parts[1:]) if len(parts) > 1 else "" + + dl_dir = Path("downloads") / str(time.time()) + + # Handle replied media + if message.replied and message.replied.media: + await response.edit("📥 Downloading media from Telegram...") + + tg_media = get_tg_media_details(message.replied) + file_name = tg_media.file_name or f"file_{int(time.time())}" + file_path = dl_dir / file_name + + dl_dir.mkdir(parents=True, exist_ok=True) + + await message.replied.download( + file_name=file_path, + progress=progress, + progress_args=(response, "Downloading from Telegram...", file_path) + ) + + # Handle URL input + elif input_text and input_text.strip(): + url = input_text.strip() + if not url.startswith(('http://', 'https://')): + await response.edit("❌ Invalid URL!\nPlease provide a valid HTTP/HTTPS URL.") + return + + await response.edit("📥 Downloading from URL...") + + dl_obj = await Download.setup( + url=url, + dir=dl_dir, + message_to_edit=response + ) + + downloaded_file = await dl_obj.download() + file_path = downloaded_file.path + await dl_obj.close() + else: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + # Upload to GoFile + await response.edit("📤 Uploading to GoFile...") + file_info = await gofile.upload_file(file_path, folder_id) + + # Cleanup + if file_path.exists(): + file_path.unlink() + if dl_dir.exists() and not any(dl_dir.iterdir()): + dl_dir.rmdir() + + # Format response + size_mb = file_info['size'] / (1024 * 1024) + + result_text = f"✅ Successfully uploaded to GoFile!\n\n" + result_text += f"📁 File: {file_info['filename']}\n" + result_text += f"📊 Size: {size_mb:.2f} MB\n" + result_text += f"🆔 File ID: {file_info['file_id']}\n" + result_text += f"📂 Folder ID: {file_info['parent_folder']}\n" + result_text += f"🌐 Server: {file_info['server']}\n\n" + result_text += f"🔗 Download Page:\n{file_info['download_page']}\n\n" + + if file_info.get('direct_link'): + result_text += f"📎 Direct Link:\n{file_info['direct_link']}\n\n" + + result_text += f"🔑 Access Code: {file_info['code']}" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Upload failed!\n\n{str(e)}") + finally: + await gofile.close_session() + + +@bot.add_cmd(cmd="golist") +async def gofile_list(bot: BOT, message: Message): + """ + CMD: GOLIST + INFO: List contents of a GoFile folder + USAGE: .golist [folder_id] + """ + response = await message.reply("🔄 Fetching folder contents...") + + if not message.input: + await response.edit("❌ No folder ID provided!\n\nProvide a GoFile folder ID.") + return + + try: + folder_id = message.input.strip() + + # Get folder contents + folder_data = await gofile.get_folder_contents(folder_id) + + folder_name = folder_data.get('name', 'Unknown') + folder_type = folder_data.get('type', 'Unknown') + children = folder_data.get('children', {}) + + contents_text = f"📂 GoFile Folder: {folder_name}\n\n" + contents_text += f"🆔 ID: {folder_id}\n" + contents_text += f"📋 Type: {folder_type}\n" + contents_text += f"📊 Items: {len(children)}\n\n" + + if children: + files_count = 0 + folders_count = 0 + + for item_id, item_data in children.items(): + item_type = item_data.get('type', 'unknown') + item_name = item_data.get('name', 'Unknown') + + if item_type == 'file': + size = item_data.get('size', 0) + size_mb = size / (1024 * 1024) if size > 0 else 0 + contents_text += f"📄 {item_name} ({size_mb:.2f} MB)\n" + files_count += 1 + elif item_type == 'folder': + contents_text += f"📁 {item_name}\n" + folders_count += 1 + + contents_text += f"\n📊 Summary: {files_count} files, {folders_count} folders" + else: + contents_text += "📭 Folder is empty" + + await response.edit(contents_text) + + except Exception as e: + await response.edit(f"❌ Failed to list folder contents!\n\n{str(e)}") + finally: + await gofile.close_session() + + +@bot.add_cmd(cmd="gohelp") +async def gofile_help(bot: BOT, message: Message): + """ + CMD: GOHELP + INFO: Show GoFile help information + USAGE: .gohelp + """ + help_text = f"📋 GoFile Commands Help\n\n" + + help_text += f"🚀 Upload Commands:\n" + help_text += f"• .goup - Upload files to GoFile\n" + help_text += f"• .goup -f [folder_id] - Upload to specific folder\n\n" + + help_text += f"📂 Management Commands:\n" + help_text += f"• .golist [folder_id] - List folder contents\n" + help_text += f"• .gohelp - Show this help\n\n" + + help_text += f"💡 Usage Examples:\n" + help_text += f"• Reply to media: .goup\n" + help_text += f"• Upload from URL: .goup https://example.com/file.zip\n" + help_text += f"• Upload to folder: .goup -f abc123\n" + help_text += f"• List folder: .golist abc123\n\n" + + help_text += f"📊 Features:\n" + help_text += f"• Free file hosting\n" + help_text += f"• Large file support\n" + help_text += f"• Folder organization\n" + help_text += f"• Direct download links\n" + help_text += f"• No registration required\n\n" + + help_text += f"ℹ️ File Limits:\n" + help_text += f"• Max file size varies by server\n" + help_text += f"• Generally supports files up to 5GB\n" + help_text += f"• Files stored for extended periods" + + await message.reply(help_text) \ No newline at end of file diff --git a/app/plugins/files/leech.py b/app/plugins/files/leech.py new file mode 100644 index 0000000..c60c789 --- /dev/null +++ b/app/plugins/files/leech.py @@ -0,0 +1,500 @@ +import asyncio +import os +import time +import tempfile +import hashlib +from pathlib import Path +import subprocess + +import aiohttp +from ub_core import BOT, Message +from ub_core.utils import progress + +# Import pixeldrain functionality +try: + from .pixeldrain import pixeldrain + PIXELDRAIN_AVAILABLE = True +except ImportError: + PIXELDRAIN_AVAILABLE = False + +LEECH_TYPE_MAP: dict[str, str] = { + "-p": "photo", + "-a": "audio", + "-v": "video", + "-g": "animation", + "-d": "document", +} + + +@BOT.add_cmd("l") +async def leech_urls_to_tg(bot: BOT, message: Message): + """ + CMD: L (leech) + INFO: Instantly Upload Media to TG from Links without Downloading. + FLAGS: + -p: photo + -a: audio + -v: video + -g: gif + -d: document + + -s: to leech with spoiler + + USAGE: + .l { flag } link | file_id + .l { flag } -s link | file_id + """ + + try: + method_str = LEECH_TYPE_MAP.get(message.flags[0]) + + assert method_str and message.filtered_input + + reply_method = getattr(message, f"reply_{method_str}") + + kwargs = {method_str: message.filtered_input} + + if "-s" in message.flags: + kwargs["has_spoiler"] = True + + if "-g" in message.flags and bot.is_user: + kwargs["unsave"] = True + + await reply_method(**kwargs) + + except (IndexError, AssertionError): + await message.reply("Invalid Input.\nCheck Help!") + return + + except Exception as exc: + await message.reply(exc) + return + + +class TorrentLeecher: + def __init__(self): + self.download_dir = Path("downloads/leech") + self.download_dir.mkdir(parents=True, exist_ok=True) + self.active_downloads = {} + + async def check_aria2c(self) -> bool: + """Check if aria2c is available""" + try: + result = subprocess.run(['aria2c', '--version'], + capture_output=True, text=True, timeout=5) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + async def download_torrent_with_aria2c(self, torrent_path: Path, download_dir: Path, + progress_callback=None) -> list[Path]: + """Download torrent using aria2c - improved version""" + + # Create a subdirectory for this torrent's content + content_dir = download_dir / "content" + content_dir.mkdir(exist_ok=True) + + cmd = [ + 'aria2c', + '--seed-time=0', # Don't seed after download + '--bt-max-peers=50', + '--max-connection-per-server=10', + '--split=10', + '--max-concurrent-downloads=5', + '--continue=true', # Resume downloads + '--max-tries=3', + '--retry-wait=3', + '--timeout=30', + '--dir', str(content_dir), # Download to content subdirectory + '--summary-interval=1', # Progress updates every second + str(torrent_path) + ] + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Combine stderr and stdout + text=True, + universal_newlines=True, + bufsize=1 + ) + + download_id = hashlib.md5(str(torrent_path).encode()).hexdigest()[:8] + self.active_downloads[download_id] = { + 'process': process, + 'start_time': time.time(), + 'last_output': '' + } + + # Monitor process output for progress + while process.poll() is None: + if progress_callback: + elapsed = int(time.time() - self.active_downloads[download_id]['start_time']) + + # Try to read some output for progress info + try: + # Read available output without blocking + import select + if hasattr(select, 'select'): + ready, _, _ = select.select([process.stdout], [], [], 0.1) + if ready: + line = process.stdout.readline() + if line: + self.active_downloads[download_id]['last_output'] = line.strip() + except: + pass + + await progress_callback({ + 'status': 'Downloading torrent content...', + 'elapsed': f"{elapsed//60}m {elapsed%60}s", + 'details': self.active_downloads[download_id].get('last_output', '')[:100] + }) + + await asyncio.sleep(2) + + # Get the final output + stdout, _ = process.communicate() + + if process.returncode == 0: + # Find all downloaded files in the content directory + downloaded_files = [] + for file_path in content_dir.rglob('*'): + if (file_path.is_file() and + not file_path.name.endswith(('.aria2', '.torrent')) and + file_path.stat().st_size > 0): # Only non-empty files + downloaded_files.append(file_path) + + # If no files found in content dir, check the main download dir + if not downloaded_files: + for file_path in download_dir.rglob('*'): + if (file_path.is_file() and + not file_path.name.endswith(('.aria2', '.torrent')) and + file_path.stat().st_size > 0): + downloaded_files.append(file_path) + + return downloaded_files + else: + error_msg = stdout if stdout else "Unknown aria2c error" + raise Exception(f"aria2c failed: {error_msg}") + + async def download_http(self, url: str, download_dir: Path, + progress_callback=None) -> list[Path]: + """Download file via HTTP""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status != 200: + raise Exception(f"Download failed: {response.status}") + + # Better filename extraction + filename = url.split('/')[-1].split('?')[0] # Remove query params + if not filename or '.' not in filename: + filename = f"download_{int(time.time())}" + + if 'content-disposition' in response.headers: + cd = response.headers['content-disposition'] + if 'filename=' in cd: + filename = cd.split('filename=')[1].strip('"\'') + + file_path = download_dir / filename + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + + with open(file_path, 'wb') as f: + async for chunk in response.content.iter_chunked(8192): + f.write(chunk) + downloaded += len(chunk) + + if progress_callback and total_size > 0: + progress_pct = (downloaded / total_size) * 100 + await progress_callback({ + 'status': f'Downloading... {progress_pct:.1f}%', + 'downloaded': downloaded, + 'total': total_size + }) + + return [file_path] + + +torrent_leecher = TorrentLeecher() + + +async def download_torrent_file(url: str) -> bytes: + """Download torrent file from URL""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + return await response.read() + else: + raise Exception(f"Failed to download torrent: {response.status}") + + +@BOT.add_cmd("leech") +async def enhanced_leech(bot: BOT, message: Message): + """ + CMD: LEECH + INFO: Download torrents/files and upload to Telegram or PixelDrain + FLAGS: + -pd: Upload to PixelDrain after download + -tg: Upload to Telegram chat (default behavior) + -del: Delete local files after upload + -http: Force HTTP download (for direct downloads) + USAGE: + .leech [url] (upload to Telegram) + .leech -pd [url] (upload to PixelDrain) + .leech -pd -del [url] (upload to PixelDrain and delete local) + .leech -http [direct_url] (HTTP download) + """ + response = await message.reply("🔄 Processing download request...") + + if not message.input: + await response.edit("❌ No URL provided!\n\n" + "Usage:\n" + "• .leech [url] - Upload to Telegram\n" + "• .leech -pd [url] - Upload to PixelDrain\n" + "• .leech -http [direct_url] - HTTP download") + return + + url = message.filtered_input.strip() + upload_to_pixeldrain = "-pd" in message.flags + upload_to_telegram = "-tg" in message.flags or not upload_to_pixeldrain # Default to Telegram + delete_after_upload = "-del" in message.flags + force_http = "-http" in message.flags + + if upload_to_pixeldrain and not PIXELDRAIN_AVAILABLE: + await response.edit("❌ PixelDrain module not available!") + return + + try: + # Create download directory + download_id = hashlib.md5(url.encode()).hexdigest()[:8] + download_dir = torrent_leecher.download_dir / f"leech_{download_id}_{int(time.time())}" + download_dir.mkdir(parents=True, exist_ok=True) + + # Progress callback + async def progress_callback(status): + try: + progress_text = f"📥 Downloading...\n\n" + progress_text += f"🌐 URL: {url[:50]}...\n" + progress_text += f"📊 Status: {status.get('status', 'Unknown')}\n" + progress_text += f"⏱ Time: {status.get('elapsed', 'N/A')}" + + if 'details' in status and status['details']: + progress_text += f"\n📋 Details: {status['details']}" + + if 'downloaded' in status and 'total' in status: + mb_down = status['downloaded'] / 1024 / 1024 + mb_total = status['total'] / 1024 / 1024 + progress_text += f"\n📊 Progress: {mb_down:.1f}/{mb_total:.1f} MB" + + await response.edit(progress_text) + except: + pass + + downloaded_files = [] + + # Determine download method + is_torrent = url.endswith('.torrent') and not force_http + + if is_torrent and await torrent_leecher.check_aria2c(): + # Download torrent file first + await response.edit("📥 Downloading torrent file...") + torrent_data = await download_torrent_file(url) + torrent_file = download_dir / "torrent.torrent" + + with open(torrent_file, 'wb') as f: + f.write(torrent_data) + + # Download using aria2c + await response.edit("🚀 Starting torrent download...") + downloaded_files = await torrent_leecher.download_torrent_with_aria2c( + torrent_file, download_dir, progress_callback + ) + else: + # Use HTTP download + if is_torrent: + await response.edit("⚠️ aria2c not found, using HTTP...") + else: + await response.edit("📥 Starting HTTP download...") + + downloaded_files = await torrent_leecher.download_http( + url, download_dir, progress_callback + ) + + if not downloaded_files: + await response.edit("❌ No files were downloaded!") + return + + # Handle uploads + uploaded_results = [] + + if upload_to_telegram: + await response.edit("📤 Uploading files to Telegram...") + + for i, file_path in enumerate(downloaded_files): + try: + file_size = file_path.stat().st_size + + # Skip very large files for Telegram (>2GB limit) + if file_size > 2 * 1024 * 1024 * 1024: + uploaded_results.append({ + 'name': file_path.name, + 'error': 'File too large for Telegram (>2GB)' + }) + continue + + # Update progress + await response.edit(f"📤 Uploading to Telegram...\n\n" + f"📁 File {i+1}/{len(downloaded_files)}: {file_path.name}\n" + f"📊 Size: {file_size / 1024 / 1024:.1f} MB") + + # Upload to Telegram as document + with open(file_path, 'rb') as f: + await message.reply_document( + document=f, + file_name=file_path.name, + caption=f"📥 Leeched from:\n{url}\n\n" + f"📊 Size: {file_size / 1024 / 1024:.1f} MB", + progress=progress, + progress_args=(response, f"Uploading {file_path.name}...", file_path.name) + ) + + uploaded_results.append({ + 'name': file_path.name, + 'size': file_size, + 'status': 'success' + }) + + # Delete local file if requested + if delete_after_upload and file_path.exists(): + file_path.unlink() + + except Exception as e: + uploaded_results.append({ + 'name': file_path.name, + 'error': str(e) + }) + + elif upload_to_pixeldrain: + await response.edit("📤 Uploading files to PixelDrain...") + + for file_path in downloaded_files: + try: + if file_path.stat().st_size > 1024 * 1024 * 1024: # 1GB limit + uploaded_results.append({ + 'name': file_path.name, + 'error': 'File too large (>1GB)' + }) + continue + + file_info = await pixeldrain.upload_file(file_path, response) + uploaded_results.append({ + 'name': file_path.name, + 'url': file_info['url'], + 'size': file_info['size'] + }) + + if delete_after_upload and file_path.exists(): + file_path.unlink() + + except Exception as e: + uploaded_results.append({ + 'name': file_path.name, + 'error': str(e) + }) + + # Clean up directory if empty + if delete_after_upload: + try: + if download_dir.exists() and not any(download_dir.iterdir()): + download_dir.rmdir() + except: + pass + + # Format final response + total_size = sum(f.stat().st_size for f in downloaded_files if f.exists()) + + result_text = f"✅ Leech completed!\n\n" + result_text += f"📂 Files Downloaded: {len(downloaded_files)}\n" + result_text += f"📊 Total Size: {total_size / 1024 / 1024:.1f} MB\n\n" + + if upload_to_telegram: + success_count = len([r for r in uploaded_results if 'status' in r and r['status'] == 'success']) + error_count = len([r for r in uploaded_results if 'error' in r]) + + result_text += f"📤 Telegram Upload: {success_count} success, {error_count} failed\n\n" + + if error_count > 0: + result_text += f"❌ Failed uploads:\n" + for result in uploaded_results: + if 'error' in result: + result_text += f"• {result['name']}: {result['error']}\n" + + elif upload_to_pixeldrain: + result_text += f"🔗 PixelDrain Links:\n" + for result in uploaded_results: + if 'error' in result: + result_text += f"❌ {result['name']}: {result['error']}\n" + else: + size_mb = result['size'] / 1024 / 1024 + result_text += f"✅ {result['name']} ({size_mb:.1f} MB)\n" + result_text += f" {result['url']}\n\n" + + else: + result_text += f"📍 Local Path: {download_dir}\n\n" + result_text += f"📋 Files:\n" + for file_path in downloaded_files[:5]: + if file_path.exists(): + size_mb = file_path.stat().st_size / 1024 / 1024 + result_text += f"• {file_path.name} ({size_mb:.1f} MB)\n" + + if len(downloaded_files) > 5: + result_text += f"... and {len(downloaded_files) - 5} more files" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Download failed!\n\n{str(e)}") + + +@BOT.add_cmd("leechhelp") +async def leech_help(bot: BOT, message: Message): + """ + CMD: LEECHHELP + INFO: Show leech commands help + USAGE: .leechhelp + """ + help_text = f"📋 Leech Commands Help\n\n" + + help_text += f"🔸 Media Leech (Telegram):\n" + help_text += f"• .l -p [url] - Upload as photo\n" + help_text += f"• .l -v [url] - Upload as video\n" + help_text += f"• .l -a [url] - Upload as audio\n" + help_text += f"• .l -d [url] - Upload as document\n" + help_text += f"• .l -g [url] - Upload as GIF\n\n" + + help_text += f"🔸 File Leech (Download + Upload):\n" + help_text += f"• .leech [url] - Download and upload to Telegram\n" + help_text += f"• .leech -pd [url] - Download and upload to PixelDrain\n" + help_text += f"• .leech -pd -del [url] - Upload to PixelDrain and delete local\n" + help_text += f"• .leech -http [url] - Force HTTP download\n" + help_text += f"• .leech -del [url] - Upload to Telegram and delete local\n\n" + + help_text += f"💡 Examples:\n" + help_text += f"• .l -v https://site.com/video.mp4 (Direct to Telegram)\n" + help_text += f"• .leech https://site.com/movie.torrent (Torrent to Telegram)\n" + help_text += f"• .leech -pd https://site.com/file.zip (HTTP to PixelDrain)\n\n" + + help_text += f"⚙️ Requirements:\n" + help_text += f"• aria2c (for torrent support)\n" + help_text += f"• PixelDrain module (for -pd flag)\n\n" + + help_text += f"📊 Features:\n" + help_text += f"• Torrent and HTTP downloads\n" + help_text += f"• Direct Telegram upload (default)\n" + help_text += f"• PixelDrain integration\n" + help_text += f"• Progress tracking\n" + help_text += f"• File cleanup options\n" + help_text += f"• aria2c auto-detection\n" + help_text += f"• File size limits (2GB for Telegram, 1GB for PixelDrain)" + + await message.reply(help_text) diff --git a/app/plugins/files/pixeldrain.py b/app/plugins/files/pixeldrain.py new file mode 100644 index 0000000..1524094 --- /dev/null +++ b/app/plugins/files/pixeldrain.py @@ -0,0 +1,325 @@ +import asyncio +import aiohttp +import time +from pathlib import Path + +from ub_core.utils import Download, DownloadedFile, get_tg_media_details, progress + +from app import BOT, Message, bot + + +class PixelDrain: + BASE_URL = "https://pixeldrain.com" + API_URL = f"{BASE_URL}/api" + + def __init__(self): + self._session = None + + async def get_session(self): + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close_session(self): + if self._session and not self._session.closed: + await self._session.close() + + async def upload_file(self, file_path: Path, message_to_edit: Message = None): + """Upload file to PixelDrain""" + session = await self.get_session() + + try: + file_size = file_path.stat().st_size + uploaded = 0 + + if message_to_edit: + await message_to_edit.edit("📤 Uploading to PixelDrain...") + + with open(file_path, 'rb') as f: + data = aiohttp.FormData() + data.add_field('file', f, filename=file_path.name) + + async with session.post(f"{self.API_URL}/file", data=data) as response: + if response.status == 201: + result = await response.json() + file_id = result.get('id') + + file_info = { + 'id': file_id, + 'name': result.get('name', file_path.name), + 'size': result.get('size', file_size), + 'views': result.get('views', 0), + 'url': f"{self.BASE_URL}/u/{file_id}", + 'direct_url': f"{self.BASE_URL}/api/file/{file_id}", + 'delete_url': f"{self.BASE_URL}/api/file/{file_id}" + } + + return file_info + else: + error_text = await response.text() + raise Exception(f"Upload failed: {response.status} - {error_text}") + + except Exception as e: + raise Exception(f"PixelDrain upload error: {str(e)}") + + async def get_file_info(self, file_id: str): + """Get file information from PixelDrain""" + session = await self.get_session() + + try: + async with session.get(f"{self.API_URL}/file/{file_id}/info") as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"Failed to get file info: {response.status}") + except Exception as e: + raise Exception(f"Error getting file info: {str(e)}") + + async def download_file(self, file_id: str, download_dir: Path, message_to_edit: Message = None): + """Download file from PixelDrain""" + session = await self.get_session() + + try: + # Get file info first + file_info = await self.get_file_info(file_id) + file_name = file_info.get('name', f'pixeldrain_{file_id}') + file_size = file_info.get('size', 0) + + download_path = download_dir / file_name + download_dir.mkdir(parents=True, exist_ok=True) + + if message_to_edit: + await message_to_edit.edit(f"📥 Downloading from PixelDrain...\n{file_name}") + + async with session.get(f"{self.API_URL}/file/{file_id}") as response: + if response.status == 200: + downloaded = 0 + with open(download_path, 'wb') as f: + async for chunk in response.content.iter_chunked(8192): + f.write(chunk) + downloaded += len(chunk) + + if message_to_edit and file_size > 0: + progress_percent = (downloaded / file_size) * 100 + await progress(downloaded, file_size, message_to_edit, + f"Downloading from PixelDrain...\n{file_name}") + + return DownloadedFile(file=download_path, size=file_size) + else: + raise Exception(f"Download failed: {response.status}") + + except Exception as e: + raise Exception(f"PixelDrain download error: {str(e)}") + + +pixeldrain = PixelDrain() + + +@bot.add_cmd(cmd="pdup") +async def pixeldrain_upload(bot: BOT, message: Message): + """ + CMD: PDUP + INFO: Upload files to PixelDrain + USAGE: + .pdup [reply to media] + .pdup [url] + """ + response = await message.reply("🔄 Processing upload request...") + + if not message.replied and not message.input: + await response.edit("❌ No input provided!\n\nReply to a media file or provide a URL.") + return + + try: + dl_dir = Path("downloads") / str(time.time()) + + # Handle replied media + if message.replied and message.replied.media: + await response.edit("📥 Downloading media from Telegram...") + + tg_media = get_tg_media_details(message.replied) + file_name = tg_media.file_name or f"file_{int(time.time())}" + file_path = dl_dir / file_name + + dl_dir.mkdir(parents=True, exist_ok=True) + + await message.replied.download( + file_name=file_path, + progress=progress, + progress_args=(response, "Downloading from Telegram...", file_path) + ) + + # Handle URL input + elif message.input: + url = message.input.strip() + if not url.startswith(('http://', 'https://')): + await response.edit("❌ Invalid URL!\nPlease provide a valid HTTP/HTTPS URL.") + return + + await response.edit("📥 Downloading from URL...") + + dl_obj = await Download.setup( + url=url, + dir=dl_dir, + message_to_edit=response + ) + + downloaded_file = await dl_obj.download() + file_path = downloaded_file.path + await dl_obj.close() + + # Upload to PixelDrain + await response.edit("📤 Uploading to PixelDrain...") + file_info = await pixeldrain.upload_file(file_path, response) + + # Cleanup + if file_path.exists(): + file_path.unlink() + if dl_dir.exists() and not any(dl_dir.iterdir()): + dl_dir.rmdir() + + # Format response + size_mb = file_info['size'] / (1024 * 1024) + + result_text = f"✅ Successfully uploaded to PixelDrain!\n\n" + result_text += f"📁 File: {file_info['name']}\n" + result_text += f"📊 Size: {size_mb:.2f} MB\n" + result_text += f"🆔 ID: {file_info['id']}\n" + result_text += f"👁 Views: {file_info['views']}\n\n" + result_text += f"🔗 Share URL:\n{file_info['url']}\n\n" + result_text += f"📎 Direct URL:\n{file_info['direct_url']}" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Upload failed!\n\n{str(e)}") + finally: + await pixeldrain.close_session() + + +@bot.add_cmd(cmd="pddl") +async def pixeldrain_download(bot: BOT, message: Message): + """ + CMD: PDDL + INFO: Download files from PixelDrain + USAGE: + .pddl [pixeldrain_url_or_id] + """ + response = await message.reply("🔄 Processing download request...") + + if not message.input: + await response.edit("❌ No input provided!\n\nProvide a PixelDrain URL or file ID.") + return + + try: + input_text = message.input.strip() + + # Extract file ID from URL or use direct ID + if 'pixeldrain.com' in input_text: + if '/u/' in input_text: + file_id = input_text.split('/u/')[-1].split('?')[0] + elif '/api/file/' in input_text: + file_id = input_text.split('/api/file/')[-1].split('/')[0] + else: + await response.edit("❌ Invalid PixelDrain URL!\n\nUse format: https://pixeldrain.com/u/[file_id]") + return + else: + file_id = input_text + + # Validate file ID + if not file_id or len(file_id) < 8: + await response.edit("❌ Invalid file ID!\n\nFile ID should be at least 8 characters long.") + return + + # Download file + dl_dir = Path("downloads") / str(time.time()) + downloaded_file = await pixeldrain.download_file(file_id, dl_dir, response) + + # Send file back to user + with open(downloaded_file.path, 'rb') as f: + await message.reply_document( + document=f, + file_name=downloaded_file.path.name, + caption=f"📥 Downloaded from PixelDrain\n\n" + f"🆔 File ID: {file_id}\n" + f"📊 Size: {downloaded_file.size / (1024 * 1024):.2f} MB" + ) + + await response.delete() + + # Cleanup + if downloaded_file.path.exists(): + downloaded_file.path.unlink() + if dl_dir.exists() and not any(dl_dir.iterdir()): + dl_dir.rmdir() + + except Exception as e: + await response.edit(f"❌ Download failed!\n\n{str(e)}") + finally: + await pixeldrain.close_session() + + +@bot.add_cmd(cmd="pdinfo") +async def pixeldrain_info(bot: BOT, message: Message): + """ + CMD: PDINFO + INFO: Get information about a PixelDrain file + USAGE: + .pdinfo [pixeldrain_url_or_id] + """ + response = await message.reply("🔄 Fetching file information...") + + if not message.input: + await response.edit("❌ No input provided!\n\nProvide a PixelDrain URL or file ID.") + return + + try: + input_text = message.input.strip() + + # Extract file ID from URL or use direct ID + if 'pixeldrain.com' in input_text: + if '/u/' in input_text: + file_id = input_text.split('/u/')[-1].split('?')[0] + elif '/api/file/' in input_text: + file_id = input_text.split('/api/file/')[-1].split('/')[0] + else: + await response.edit("❌ Invalid PixelDrain URL!") + return + else: + file_id = input_text + + # Get file info + file_info = await pixeldrain.get_file_info(file_id) + + # Format file size + size_bytes = file_info.get('size', 0) + if size_bytes > 1024 * 1024 * 1024: + size_str = f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + elif size_bytes > 1024 * 1024: + size_str = f"{size_bytes / (1024 * 1024):.2f} MB" + elif size_bytes > 1024: + size_str = f"{size_bytes / 1024:.2f} KB" + else: + size_str = f"{size_bytes} B" + + # Format upload date + upload_date = file_info.get('date_upload', 'Unknown') + if upload_date != 'Unknown': + upload_date = upload_date.replace('T', ' ').split('.')[0] + + info_text = f"📋 PixelDrain File Information\n\n" + info_text += f"🆔 ID: {file_id}\n" + info_text += f"📁 Name: {file_info.get('name', 'Unknown')}\n" + info_text += f"📊 Size: {size_str}\n" + info_text += f"🎭 MIME: {file_info.get('mime_type', 'Unknown')}\n" + info_text += f"👁 Views: {file_info.get('views', 0)}\n" + info_text += f"📅 Uploaded: {upload_date}\n\n" + info_text += f"🔗 Share URL:\nhttps://pixeldrain.com/u/{file_id}\n\n" + info_text += f"📎 Direct URL:\nhttps://pixeldrain.com/api/file/{file_id}" + + await response.edit(info_text) + + except Exception as e: + await response.edit(f"❌ Failed to get file info!\n\n{str(e)}") + finally: + await pixeldrain.close_session() \ No newline at end of file diff --git a/app/plugins/files/rename.py b/app/plugins/files/rename.py new file mode 100644 index 0000000..e8da1bd --- /dev/null +++ b/app/plugins/files/rename.py @@ -0,0 +1,65 @@ +import asyncio +import shutil +import time +from pathlib import Path + +from ub_core.utils.downloader import Download, DownloadedFile + +from app import BOT, Message, bot +from app.plugins.files.download import telegram_download +from app.plugins.files.upload import upload_to_tg + + +@bot.add_cmd(cmd="rename") +async def rename(bot: BOT, message: Message): + """ + CMD: RENAME + INFO: Upload Files with custom name + FLAGS: -s for spoiler + USAGE: + .rename [ url | reply to message ] file_name.ext + """ + input = message.filtered_input + + response = await message.reply("Checking input...") + + if not message.replied or not message.replied.media or not message.filtered_input: + await response.edit( + "Invalid input...\nReply to a message containing media or give a link and a filename with cmd." + ) + return + + dl_path = Path("downloads") / str(time.time()) + + await response.edit("Input verified....Starting Download...") + + if message.replied: + dl_obj: None = None + download_coro = telegram_download( + message=message.replied, dir_name=dl_path, file_name=input, response=response + ) + + else: + url, file_name = input.split(maxsplit=1) + dl_obj: Download = await Download.setup( + url=url, dir=dl_path, message_to_edit=response, custom_file_name=file_name + ) + download_coro = dl_obj.download() + + try: + downloaded_file: DownloadedFile = await download_coro + await upload_to_tg(file=downloaded_file, message=message, response=response) + shutil.rmtree(dl_path, ignore_errors=True) + + except asyncio.exceptions.CancelledError: + await response.edit("Cancelled....") + + except TimeoutError: + await response.edit("Download Timeout...") + + except Exception as e: + await response.edit(str(e)) + + finally: + if dl_obj: + await dl_obj.close() diff --git a/app/plugins/files/spoiler.py b/app/plugins/files/spoiler.py new file mode 100644 index 0000000..a9350a1 --- /dev/null +++ b/app/plugins/files/spoiler.py @@ -0,0 +1,34 @@ +from pyrogram.enums import MessageMediaType +from ub_core import BOT, Message +from ub_core.utils import get_tg_media_details + +MEDIA_TYPE_MAP: dict[MessageMediaType, str] = { + MessageMediaType.PHOTO: "photo", + MessageMediaType.VIDEO: "video", +} + + +@BOT.add_cmd("spoiler") +async def mark_spoiler(bot: BOT, message: Message): + """ + CMD: SPOILER + INFO: Convert Non-Spoiler media to Spoiler + USAGE: .spoiler [reply to a photo | video] + """ + reply_message = message.replied + + try: + reply_method_str = MEDIA_TYPE_MAP.get(reply_message.media) + assert reply_method_str and not reply_message.document + + except (AssertionError, AttributeError): + await message.reply(text="Reply to a Photo | Video") + return + + media = get_tg_media_details(message=reply_message) + + kwargs = {reply_method_str: media.file_id, "has_spoiler": True} + + reply_method = getattr(message, f"reply_{reply_method_str}") + + await reply_method(**kwargs) diff --git a/app/plugins/files/torrent_leech.py b/app/plugins/files/torrent_leech.py new file mode 100644 index 0000000..0876ada --- /dev/null +++ b/app/plugins/files/torrent_leech.py @@ -0,0 +1,406 @@ +import asyncio +import os +import time +import tempfile +import hashlib +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict, List + +import aiohttp + +from ub_core.utils import progress +from app import BOT, Message, bot + +# Try to import libtorrent, make it optional +try: + import libtorrent as lt + LIBTORRENT_AVAILABLE = True +except ImportError: + LIBTORRENT_AVAILABLE = False + lt = None + +# Import pixeldrain functionality +try: + from .pixeldrain import pixeldrain + PIXELDRAIN_AVAILABLE = True +except ImportError: + PIXELDRAIN_AVAILABLE = False + + +class TorrentLeecher: + def __init__(self): + self.session = lt.session() + self.session.listen_on(6881, 6891) + self.active_torrents: Dict[str, Dict] = {} + self.download_dir = Path("downloads/torrents") + self.download_dir.mkdir(parents=True, exist_ok=True) + + def add_torrent(self, torrent_data: bytes, download_path: Path) -> str: + """Add torrent to session and return info hash""" + if not LIBTORRENT_AVAILABLE: + raise Exception("libtorrent not available - install python-libtorrent") + + info = lt.torrent_info(torrent_data) + handle = self.session.add_torrent({ + 'ti': info, + 'save_path': str(download_path.parent), + 'storage_mode': lt.storage_mode_t.storage_mode_sparse, + }) + + info_hash = str(info.info_hash()) + self.active_torrents[info_hash] = { + 'handle': handle, + 'info': info, + 'start_time': time.time(), + 'download_path': download_path, + 'completed': False + } + + return info_hash + + def get_torrent_status(self, info_hash: str) -> Optional[Dict]: + """Get status of a torrent""" + if info_hash not in self.active_torrents: + return None + + handle = self.active_torrents[info_hash]['handle'] + status = handle.status() + + return { + 'name': self.active_torrents[info_hash]['info'].name(), + 'progress': status.progress, + 'download_rate': status.download_rate, + 'upload_rate': status.upload_rate, + 'num_peers': status.num_peers, + 'num_seeds': status.num_seeds, + 'total_size': self.active_torrents[info_hash]['info'].total_size(), + 'downloaded': status.total_done, + 'eta': self._calculate_eta(status), + 'state': str(status.state), + 'completed': status.is_finished + } + + def _calculate_eta(self, status) -> str: + """Calculate estimated time of arrival""" + if status.download_rate <= 0: + return "∞" + + remaining = status.total_wanted - status.total_done + eta_seconds = remaining / status.download_rate + + if eta_seconds < 60: + return f"{int(eta_seconds)}s" + elif eta_seconds < 3600: + return f"{int(eta_seconds/60)}m" + else: + return f"{int(eta_seconds/3600)}h {int((eta_seconds%3600)/60)}m" + + async def download_torrent(self, info_hash: str, progress_callback=None) -> List[Path]: + """Download torrent and return list of downloaded files""" + if info_hash not in self.active_torrents: + raise Exception("Torrent not found") + + handle = self.active_torrents[info_hash]['handle'] + + # Wait for torrent to complete + while not handle.status().is_finished: + status = self.get_torrent_status(info_hash) + + if progress_callback: + await progress_callback(status) + + await asyncio.sleep(2) + + # Get list of downloaded files + info = self.active_torrents[info_hash]['info'] + base_path = self.active_torrents[info_hash]['download_path'] + + downloaded_files = [] + for i in range(info.num_files()): + file_info = info.file_at(i) + file_path = base_path / file_info.path + if file_path.exists(): + downloaded_files.append(file_path) + + self.active_torrents[info_hash]['completed'] = True + return downloaded_files + + def remove_torrent(self, info_hash: str, delete_files: bool = False): + """Remove torrent from session""" + if info_hash in self.active_torrents: + handle = self.active_torrents[info_hash]['handle'] + + if delete_files: + self.session.remove_torrent(handle, lt.options_t.delete_files) + else: + self.session.remove_torrent(handle) + + del self.active_torrents[info_hash] + + +torrent_leecher = TorrentLeecher() + + +async def download_torrent_file(url: str) -> bytes: + """Download torrent file from URL""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + return await response.read() + else: + raise Exception(f"Failed to download torrent: {response.status}") + + +def parse_magnet_link(magnet_url: str) -> bytes: + """Convert magnet link to torrent data (simplified)""" + # This is a basic implementation - in practice, you'd need to resolve the magnet link + # For now, we'll raise an exception asking for .torrent file + raise Exception("Magnet links not supported yet. Please provide a .torrent file URL.") + + +@bot.add_cmd(cmd="torrent") +async def torrent_leech(bot: BOT, message: Message): + """ + CMD: TORRENT + INFO: Download torrents and optionally upload to PixelDrain (requires libtorrent) + FLAGS: + -pd: Upload to PixelDrain after download + -del: Delete local files after upload + USAGE: + .torrent [torrent_url] + .torrent -pd [torrent_url] + .torrent -pd -del [torrent_url] + """ + response = await message.reply("🔄 Processing torrent request...") + + if not LIBTORRENT_AVAILABLE: + await response.edit("❌ LibTorrent not available!\n\n" + "Install python-libtorrent to use torrent functionality.\n" + "Use .leech command instead for aria2c-based downloads.") + return + + if not message.input: + await response.edit("❌ No torrent URL provided!\n\n" + "Usage:\n" + "• .torrent [torrent_url]\n" + "• .torrent -pd [torrent_url] (upload to PixelDrain)\n" + "• .torrent -pd -del [torrent_url] (upload and delete local)") + return + + torrent_url = message.filtered_input.strip() + upload_to_pixeldrain = "-pd" in message.flags + delete_after_upload = "-del" in message.flags + + if upload_to_pixeldrain and not PIXELDRAIN_AVAILABLE: + await response.edit("❌ PixelDrain module not available!\n" + "Cannot upload to PixelDrain.") + return + + try: + # Download torrent file + await response.edit("📥 Downloading torrent file...") + + if torrent_url.startswith('magnet:'): + try: + torrent_data = parse_magnet_link(torrent_url) + except Exception as e: + await response.edit(f"❌ Magnet link error:\n{str(e)}") + return + else: + torrent_data = await download_torrent_file(torrent_url) + + # Create download directory + torrent_hash = hashlib.sha1(torrent_data).hexdigest()[:10] + download_path = torrent_leecher.download_dir / f"torrent_{torrent_hash}_{int(time.time())}" + download_path.mkdir(parents=True, exist_ok=True) + + # Add torrent to session + await response.edit("➕ Adding torrent to session...") + info_hash = torrent_leecher.add_torrent(torrent_data, download_path) + + # Progress tracking function + async def progress_callback(status): + progress_text = f"📊 Downloading Torrent\n\n" + progress_text += f"📁 Name: {status['name']}\n" + progress_text += f"📈 Progress: {status['progress']:.1%}\n" + progress_text += f"⬇️ Speed: {status['download_rate'] / 1024 / 1024:.1f} MB/s\n" + progress_text += f"⬆️ Upload: {status['upload_rate'] / 1024 / 1024:.1f} MB/s\n" + progress_text += f"👥 Peers: {status['num_peers']} ({status['num_seeds']} seeds)\n" + progress_text += f"📊 Size: {status['downloaded'] / 1024 / 1024:.1f}/{status['total_size'] / 1024 / 1024:.1f} MB\n" + progress_text += f"⏱ ETA: {status['eta']}\n" + progress_text += f"🔄 State: {status['state']}" + + try: + await response.edit(progress_text) + except: + pass # Ignore edit errors due to rate limiting + + # Start download + await response.edit("🚀 Starting torrent download...") + downloaded_files = await torrent_leecher.download_torrent(info_hash, progress_callback) + + if not downloaded_files: + await response.edit("❌ No files were downloaded!") + return + + # Upload to PixelDrain if requested + uploaded_links = [] + if upload_to_pixeldrain: + await response.edit("📤 Uploading files to PixelDrain...") + + for file_path in downloaded_files: + try: + file_info = await pixeldrain.upload_file(file_path, response) + uploaded_links.append({ + 'name': file_path.name, + 'url': file_info['url'], + 'size': file_info['size'] + }) + + # Delete local file if requested + if delete_after_upload and file_path.exists(): + file_path.unlink() + + except Exception as e: + uploaded_links.append({ + 'name': file_path.name, + 'error': str(e) + }) + + # Clean up torrent from session + torrent_leecher.remove_torrent(info_hash, delete_files=delete_after_upload) + + # Format final response + result_text = f"✅ Torrent download completed!\n\n" + + torrent_status = torrent_leecher.get_torrent_status(info_hash) + if torrent_status: + result_text += f"📁 Name: {torrent_status['name']}\n" + result_text += f"📊 Total Size: {torrent_status['total_size'] / 1024 / 1024:.1f} MB\n" + + result_text += f"📂 Files Downloaded: {len(downloaded_files)}\n\n" + + if upload_to_pixeldrain: + result_text += f"🔗 PixelDrain Links:\n" + for link_info in uploaded_links: + if 'error' in link_info: + result_text += f"❌ {link_info['name']}: {link_info['error']}\n" + else: + size_mb = link_info['size'] / 1024 / 1024 + result_text += f"✅ {link_info['name']} ({size_mb:.1f} MB)\n" + result_text += f" {link_info['url']}\n\n" + else: + result_text += f"📍 Local Path:\n{download_path}\n\n" + result_text += f"📋 Files:\n" + for file_path in downloaded_files[:10]: # Show max 10 files + size_mb = file_path.stat().st_size / 1024 / 1024 + result_text += f"• {file_path.name} ({size_mb:.1f} MB)\n" + + if len(downloaded_files) > 10: + result_text += f"... and {len(downloaded_files) - 10} more files" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Torrent download failed!\n\n{str(e)}") + + # Clean up on error + if 'info_hash' in locals(): + torrent_leecher.remove_torrent(info_hash, delete_files=True) + + +@bot.add_cmd(cmd="torrentlist") +async def torrent_list(bot: BOT, message: Message): + """ + CMD: TORRENTLIST + INFO: List active torrents + USAGE: .torrentlist + """ + if not torrent_leecher.active_torrents: + await message.reply("📭 No active torrents") + return + + list_text = f"📋 Active Torrents ({len(torrent_leecher.active_torrents)})\n\n" + + for info_hash, torrent_data in torrent_leecher.active_torrents.items(): + status = torrent_leecher.get_torrent_status(info_hash) + if status: + elapsed = int(time.time() - torrent_data['start_time']) + elapsed_str = f"{elapsed//3600}h {(elapsed%3600)//60}m" if elapsed > 3600 else f"{elapsed//60}m {elapsed%60}s" + + list_text += f"🔸 {status['name'][:30]}...\n" + list_text += f" 📈 Progress: {status['progress']:.1%}\n" + list_text += f" ⬇️ Speed: {status['download_rate'] / 1024 / 1024:.1f} MB/s\n" + list_text += f" ⏱ Runtime: {elapsed_str}\n" + list_text += f" 🆔 Hash: {info_hash[:12]}...\n\n" + + await message.reply(list_text) + + +@bot.add_cmd(cmd="torrentstop") +async def torrent_stop(bot: BOT, message: Message): + """ + CMD: TORRENTSTOP + INFO: Stop and remove a torrent + FLAGS: -del to delete downloaded files + USAGE: + .torrentstop [info_hash] + .torrentstop -del [info_hash] + """ + if not message.input: + await message.reply("❌ No torrent hash provided!\n\n" + "Use .torrentlist to see active torrents.") + return + + info_hash = message.filtered_input.strip() + delete_files = "-del" in message.flags + + if info_hash not in torrent_leecher.active_torrents: + await message.reply("❌ Torrent not found!\n\n" + "Use .torrentlist to see active torrents.") + return + + try: + torrent_name = torrent_leecher.active_torrents[info_hash]['info'].name() + torrent_leecher.remove_torrent(info_hash, delete_files) + + action = "stopped and files deleted" if delete_files else "stopped" + await message.reply(f"✅ Torrent {action}!\n\n" + f"📁 Name: {torrent_name}\n" + f"🆔 Hash: {info_hash[:12]}...") + + except Exception as e: + await message.reply(f"❌ Failed to stop torrent!\n\n{str(e)}") + + +@bot.add_cmd(cmd="torrenthelp") +async def torrent_help(bot: BOT, message: Message): + """ + CMD: TORRENTHELP + INFO: Show torrent commands help + USAGE: .torrenthelp + """ + help_text = f"📋 Torrent Leech Commands\n\n" + + help_text += f"🚀 Download Commands:\n" + help_text += f"• .torrent [url] - Download torrent\n" + help_text += f"• .torrent -pd [url] - Download and upload to PixelDrain\n" + help_text += f"• .torrent -pd -del [url] - Upload to PixelDrain and delete local\n\n" + + help_text += f"📊 Management Commands:\n" + help_text += f"• .torrentlist - List active torrents\n" + help_text += f"• .torrentstop [hash] - Stop torrent\n" + help_text += f"• .torrentstop -del [hash] - Stop and delete files\n\n" + + help_text += f"💡 Usage Examples:\n" + help_text += f"• .torrent https://site.com/file.torrent\n" + help_text += f"• .torrent -pd https://site.com/movie.torrent\n\n" + + help_text += f"⚠️ Notes:\n" + help_text += f"• Only .torrent file URLs supported (no magnet links yet)\n" + help_text += f"• PixelDrain integration requires pixeldrain module\n" + help_text += f"• Downloaded files stored in downloads/torrents/\n" + help_text += f"• Use responsibly and respect copyright laws" + + await message.reply(help_text) \ No newline at end of file diff --git a/app/plugins/files/upload.py b/app/plugins/files/upload.py new file mode 100644 index 0000000..b92db89 --- /dev/null +++ b/app/plugins/files/upload.py @@ -0,0 +1,203 @@ +import asyncio +import glob +import os +import time +from functools import partial +from typing import Union + +from pyrogram.types import ReplyParameters +from ub_core.utils import ( + Download, + DownloadedFile, + MediaType, + check_audio, + get_duration, + progress, + take_ss, +) + +from app import BOT, Config, Message + +UPLOAD_TYPES = Union[BOT.send_audio, BOT.send_document, BOT.send_photo, BOT.send_video] + + +async def video_upload(bot: BOT, file: DownloadedFile, has_spoiler: bool) -> UPLOAD_TYPES: + thumb = await take_ss(file.path, path=file.path) + if not await check_audio(file.path): + return partial( + bot.send_animation, + thumb=thumb, + unsave=True, + animation=file.path, + duration=await get_duration(file.path), + has_spoiler=has_spoiler, + ) + return partial( + bot.send_video, + thumb=thumb, + video=file.path, + duration=await get_duration(file.path), + has_spoiler=has_spoiler, + ) + + +async def photo_upload(bot: BOT, file: DownloadedFile, has_spoiler: bool) -> UPLOAD_TYPES: + return partial(bot.send_photo, photo=file.path, has_spoiler=has_spoiler) + + +async def audio_upload(bot: BOT, file: DownloadedFile, *_, **__) -> UPLOAD_TYPES: + return partial(bot.send_audio, audio=file.path, duration=await get_duration(file=file.path)) + + +async def doc_upload(bot: BOT, file: DownloadedFile, *_, **__) -> UPLOAD_TYPES: + return partial(bot.send_document, document=file.path, disable_content_type_detection=True) + + +FILE_TYPE_MAP = { + MediaType.PHOTO: photo_upload, + MediaType.DOCUMENT: doc_upload, + MediaType.GIF: video_upload, + MediaType.AUDIO: audio_upload, + MediaType.VIDEO: video_upload, +} + + +def file_exists(file: str) -> bool: + return os.path.isfile(file) + + +def size_over_limit(size: int | float, client: BOT) -> bool: + limit = 3999 if client.me.is_premium else 1999 + return size > limit + + +@BOT.add_cmd(cmd="upload") +async def upload(bot: BOT, message: Message): + """ + CMD: UPLOAD + INFO: Upload Media/Local Files/Plugins to TG. + FLAGS: + -d: to upload as doc. + -s: spoiler. + -bulk: for folder upload. + -r: file name regex [ to be used with -bulk only ] + USAGE: + .upload [-d] URL | Path to File | CMD + .upload -bulk downloads/videos + .upload -bulk -d -s downloads/videos + .upload -bulk -r -s downloads/videos/*.mp4 (only uploads mp4) + """ + input = message.filtered_input + + if not input: + await message.reply("give a file url | path to upload.") + return + + response = await message.reply("checking input...") + + if input in Config.CMD_DICT: + await message.reply_document(document=Config.CMD_DICT[input].cmd_path) + await response.delete() + return + + elif input.startswith("http") and not file_exists(input): + + try: + async with Download( + url=input, dir=os.path.join("downloads", str(time.time())), message_to_edit=response + ) as dl_obj: + if size_over_limit(dl_obj.size, client=bot): + await response.edit("Aborted, File size exceeds TG Limits!!!") + return + + await response.edit("URL detected in input, Starting Download....") + file: DownloadedFile = await dl_obj.download() + + except asyncio.exceptions.CancelledError: + await response.edit("Cancelled...") + return + + except TimeoutError: + await response.edit("Download Timeout...") + return + + except Exception as e: + await response.edit(str(e)) + return + + elif file_exists(input): + file = DownloadedFile(file=input) + + if size_over_limit(file.size, client=bot): + await response.edit("Aborted, File size exceeds TG Limits!!!") + return + + elif "-bulk" in message.flags: + await bulk_upload(message=message, response=response) + return + + else: + await response.edit("invalid `cmd` | `url` | `file path`!!!") + return + + await response.edit("Uploading....") + await upload_to_tg(file=file, message=message, response=response) + + +async def bulk_upload(message: Message, response: Message): + + if "-r" in message.flags: + path_regex = message.filtered_input + else: + path_regex = os.path.join(message.filtered_input, "*") + + file_list = [f for f in glob.glob(path_regex) if file_exists(f)] + + if not file_list: + await response.edit("Invalid Folder path/regex or Folder Empty") + return + + await response.edit(f"Preparing to upload {len(file_list)} files.") + + for file in file_list: + + file_info = DownloadedFile(file=file) + + if size_over_limit(file_info.size, client=message._client): + await response.reply(f"Skipping {file_info.name} due to size exceeding limit.") + continue + + temp_resp = await response.reply(f"starting to upload `{file_info.name}`") + + await upload_to_tg(file=file_info, message=message, response=temp_resp) + await asyncio.sleep(3) + + await response.delete() + + +async def upload_to_tg(file: DownloadedFile, message: Message, response: Message): + + progress_args = (response, "Uploading...", file.path) + + if "-d" in message.flags: + upload_method = partial( + message._client.send_document, document=file.path, disable_content_type_detection=True + ) + else: + upload_method: UPLOAD_TYPES = await FILE_TYPE_MAP[file.type]( + bot=message._client, file=file, has_spoiler="-s" in message.flags + ) + + try: + await upload_method( + chat_id=message.chat.id, + reply_parameters=ReplyParameters(message_id=message.reply_id), + progress=progress, + progress_args=progress_args, + caption=file.name, + ) + await response.delete() + + except asyncio.exceptions.CancelledError: + await response.edit("Cancelled....") + raise diff --git a/app/plugins/misc/alive.py b/app/plugins/misc/alive.py new file mode 100644 index 0000000..f523cba --- /dev/null +++ b/app/plugins/misc/alive.py @@ -0,0 +1,90 @@ +from sys import version_info + +from pyrogram import __version__ as pyro_version +from pyrogram import filters +from pyrogram.raw.types.messages import BotResults +from pyrogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + InlineQuery, + InlineQueryResultAnimation, + InlineQueryResultPhoto, + ReplyParameters, +) +from ub_core.utils import MediaType, get_type +from ub_core.version import __version__ as core_version + +from app import BOT, Config, Message, bot, extra_config + +PY_VERSION = f"{version_info.major}.{version_info.minor}.{version_info.micro}" + + +@bot.add_cmd(cmd="alive") +async def alive(bot: BOT, message: Message): + # Inline Alive if Dual Mode + if bot.is_user and getattr(bot, "has_bot", False): + inline_result: BotResults = await bot.get_inline_bot_results( + bot=bot.bot.me.username, query="inline_alive" + ) + await bot.send_inline_bot_result( + chat_id=message.chat.id, + result_id=inline_result.results[0].id, + query_id=inline_result.query_id, + ) + return + + kwargs = dict( + chat_id=message.chat.id, + caption=await get_alive_text(), + reply_markup=get_alive_buttons(client=bot), + reply_parameters=ReplyParameters(message_id=message.reply_id or message.id), + ) + + if get_type(url=extra_config.ALIVE_MEDIA) == MediaType.PHOTO: + await bot.send_photo(photo=extra_config.ALIVE_MEDIA, **kwargs) + else: + await bot.send_animation(animation=extra_config.ALIVE_MEDIA, unsave=True, **kwargs) + + +_bot = getattr(bot, "bot", bot) +if _bot.is_bot: + + @_bot.on_inline_query(filters=filters.regex("^inline_alive$"), group=2) + async def return_inline_alive_results(client: BOT, inline_query: InlineQuery): + kwargs = dict( + title=f"Send Alive Media.", + caption=await get_alive_text(), + reply_markup=get_alive_buttons(client), + ) + + if get_type(url=extra_config.ALIVE_MEDIA) == MediaType.PHOTO: + result_type = InlineQueryResultPhoto(photo_url=extra_config.ALIVE_MEDIA, **kwargs) + else: + result_type = InlineQueryResultAnimation( + animation_url=extra_config.ALIVE_MEDIA, **kwargs + ) + + await inline_query.answer(results=[result_type], cache_time=300) + + +async def get_alive_text() -> str: + user_info = await bot.get_users(user_ids=Config.OWNER_ID) + return ( + f"Plain-UB, " + f"A simple Telegram User-Bot by @overspend1.\n" + f"\n › User : {user_info.first_name}" + f"\n › Python : v{PY_VERSION}" + f"\n › Pyrogram : v{pyro_version}" + f"\n › Core : v{core_version}" + ) + + +def get_alive_buttons(client: BOT): + if not client.is_bot: + return + return InlineKeyboardMarkup( + [ + [InlineKeyboardButton(text=f"UB-Core", url=Config.UPDATE_REPO)], + [InlineKeyboardButton(text=f"Support Group", url="t.me/plainub")], + ] + ) diff --git a/app/plugins/misc/extra_module_updater.py b/app/plugins/misc/extra_module_updater.py new file mode 100644 index 0000000..54175d1 --- /dev/null +++ b/app/plugins/misc/extra_module_updater.py @@ -0,0 +1,13 @@ +from ub_core.utils import run_shell_cmd + +from app import BOT, Message + + +@BOT.add_cmd(cmd="extupdate", allow_sudo=False) +async def extra_modules_updater(bot: BOT, message: Message): + output = await run_shell_cmd(cmd="cd app/modules && git pull", timeout=10) + + await message.reply(f"
{output}
") + + if output.strip() != "Already up to date.": + bot.raise_sigint() diff --git a/app/plugins/misc/inline_bot_results.py b/app/plugins/misc/inline_bot_results.py new file mode 100644 index 0000000..45d55f9 --- /dev/null +++ b/app/plugins/misc/inline_bot_results.py @@ -0,0 +1,58 @@ +from functools import wraps + +from pyrogram.raw.types.messages import BotResults +from ub_core import BOT, Message + + +def run_with_timeout_guard(func): + @wraps(func) + async def inner(bot: BOT, message: Message): + try: + query_id, result_id, error = await func(bot, message) + + if error: + await message.reply(error) + return + + await bot.send_inline_bot_result( + chat_id=message.chat.id, query_id=query_id, result_id=result_id + ) + + except Exception as e: + await message.reply(str(e), del_in=10) + + return inner + + +@BOT.add_cmd("ln") +@run_with_timeout_guard +async def last_fm_now(bot: BOT, message: Message): + """ + CMD: LN + INFO: Check LastFM Status + USAGE: .ln + """ + + result: BotResults = await bot.get_inline_bot_results(bot="lastfmrobot") + + if not result.results: + return None, None, "No results found." + + return result.query_id, result.results[0].id, "" + + +@BOT.add_cmd("sn") +@run_with_timeout_guard +async def spotipie_now(bot: BOT, message: Message): + """ + CMD: SN + INFO: Check Spotipie Now + USAGE: .sn + """ + + result: BotResults = await bot.get_inline_bot_results(bot="spotipiebot") + + if not result.results: + return None, None, "No results found." + + return result.query_id, result.results[0].id, "" diff --git a/app/plugins/misc/song.py b/app/plugins/misc/song.py new file mode 100644 index 0000000..b921e5b --- /dev/null +++ b/app/plugins/misc/song.py @@ -0,0 +1,114 @@ +import asyncio +import json +import shutil +from pathlib import Path +from time import time +from urllib.parse import urlparse + +from pyrogram.enums import MessageEntityType +from pyrogram.types import InputMediaAudio +from ub_core.utils import aio, run_shell_cmd + +from app import BOT, Message + +domains = [ + "www.youtube.com", + "youtube.com", + "m.youtube.com", + "youtu.be", + "www.youtube-nocookie.com", + "music.youtube.com", +] + + +def is_yt_url(url: str) -> bool: + return urlparse(url).netloc in domains + + +def extract_link_from_reply(message: Message) -> str | None: + if not message: + return + + for link in message.text_list: + if is_yt_url(link): + return link + + for entity in message.entities or []: + if entity.type == MessageEntityType.TEXT_LINK and is_yt_url(entity.url): + return entity.url + + return None + + +@BOT.add_cmd(cmd="song") +async def song_dl(bot: BOT, message: Message) -> None | Message: + query = extract_link_from_reply(message.replied) or message.filtered_input + + if not query: + await message.reply("Give a song name or link to download.") + return + + response: Message = await message.reply("Searching....") + + download_path: str = Path("downloads") / str(time()) + + query_or_search: str = query if query.startswith("http") else f"ytsearch:{query}" + + song_info: dict = await get_download_info(query=query_or_search, path=download_path) + + audio_files: list = list(download_path.glob("*mp3")) + + if not audio_files: + await response.edit("Song Not found.") + return + + audio_file = audio_files[0] + + url = song_info.get("webpage_url") + + await response.edit(f"`Uploading {audio_file.name}....`") + + await response.edit_media( + InputMediaAudio( + media=str(audio_file), + caption=f"{audio_file.name}" if url else None, + duration=int(song_info.get("duration", 0)), + performer=song_info.get("channel", ""), + thumb=await aio.in_memory_dl(song_info.get("thumbnail")), + ) + ) + + shutil.rmtree(download_path, ignore_errors=True) + + +async def get_download_info(query: str, path: Path) -> dict: + download_cmd = ( + f"yt-dlp -o '{path/'%(title)s.%(ext)s'}' " + f"-f 'bestaudio' " + f"--no-warnings " + f"--ignore-errors " + f"--ignore-no-formats-error " + f"--quiet " + f"--no-playlist " + f"--audio-quality 0 " + f"--audio-format mp3 " + f"--extract-audio " + f"--embed-thumbnail " + f"--embed-metadata " + f"--print-json " + f"'{query}'" + ) + + try: + song_info = (await run_shell_cmd(download_cmd, timeout=60, ret_val="")).strip() + + serialised_json = json.loads(song_info) + return serialised_json + + except asyncio.TimeoutError: + shutil.rmtree(path=path, ignore_errors=True) + + except json.JSONDecodeError: + pass + + return {} diff --git a/app/plugins/network/network_tools.py b/app/plugins/network/network_tools.py new file mode 100644 index 0000000..ee955ea --- /dev/null +++ b/app/plugins/network/network_tools.py @@ -0,0 +1,420 @@ +import asyncio +import socket +import subprocess +import re +from datetime import datetime + +from app import BOT, Message + + +@BOT.add_cmd(cmd="ping") +async def enhanced_ping(bot: BOT, message: Message): + """ + CMD: PING + INFO: Enhanced ping command with detailed statistics. + FLAGS: -c [count] for number of pings (default: 4) + USAGE: .ping google.com + .ping -c 10 8.8.8.8 + """ + target = message.filtered_input + if not target: + await message.reply("❌ No target provided!\n" + "Usage: .ping [hostname/ip]\n" + "Example: .ping google.com") + return + + # Parse count flag + count = 4 # Default count + if "-c" in message.flags: + try: + count_index = message.flags.index("-c") + 1 + if count_index < len(message.flags): + count = min(int(message.flags[count_index]), 20) # Max 20 pings + except (ValueError, IndexError): + pass + + response = await message.reply(f"🔄 Pinging {target}...\nSending {count} packets...") + + try: + # Run ping command + if count == 1: + cmd = ['ping', '-c', '1', target] + else: + cmd = ['ping', '-c', str(count), target] + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode().strip() + await response.edit(f"❌ Ping failed!\n" + f"Target: {target}\n" + f"Error: {error_msg}") + return + + output = stdout.decode().strip() + + # Parse ping results + lines = output.split('\n') + + # Extract statistics + stats_line = next((line for line in lines if 'transmitted' in line), '') + time_line = next((line for line in lines if 'min/avg/max' in line), '') + + # Parse individual ping times + ping_times = [] + for line in lines: + if 'time=' in line: + time_match = re.search(r'time=([0-9.]+)', line) + if time_match: + ping_times.append(float(time_match.group(1))) + + # Build result + ping_text = f"🏓 Ping Results\n\n" + ping_text += f"Target: {target}\n" + ping_text += f"Packets: {count}\n" + + if stats_line: + ping_text += f"Statistics: {stats_line}\n" + + if time_line: + ping_text += f"Timing: {time_line}\n" + + if ping_times: + avg_time = sum(ping_times) / len(ping_times) + min_time = min(ping_times) + max_time = max(ping_times) + + ping_text += f"\n📊 Detailed Analysis:\n" + ping_text += f"Average: {avg_time:.2f} ms\n" + ping_text += f"Minimum: {min_time:.2f} ms\n" + ping_text += f"Maximum: {max_time:.2f} ms\n" + + # Simple quality assessment + if avg_time < 50: + quality = "🟢 Excellent" + elif avg_time < 100: + quality = "🟡 Good" + elif avg_time < 200: + quality = "🟠 Fair" + else: + quality = "🔴 Poor" + + ping_text += f"Quality: {quality}" + + ping_text += f"\n\n✅ Ping completed!" + + await response.edit(ping_text) + + except Exception as e: + await response.edit(f"❌ Ping error:\n{str(e)}") + + +@BOT.add_cmd(cmd="whois") +async def whois_lookup(bot: BOT, message: Message): + """ + CMD: WHOIS + INFO: Perform WHOIS lookup for domains and IP addresses. + USAGE: .whois google.com + .whois 8.8.8.8 + """ + target = message.filtered_input + if not target: + await message.reply("❌ No domain/IP provided!\n" + "Usage: .whois [domain/ip]\n" + "Example: .whois google.com") + return + + response = await message.reply(f"🔍 Looking up WHOIS for {target}...") + + try: + # Run whois command + process = await asyncio.create_subprocess_exec( + 'whois', target, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode().strip() + await response.edit(f"❌ WHOIS lookup failed!\n" + f"Target: {target}\n" + f"Error: {error_msg}") + return + + output = stdout.decode().strip() + + # Parse important information + lines = output.split('\n') + + # Extract key information + registrar = "" + creation_date = "" + expiration_date = "" + name_servers = [] + + for line in lines: + line = line.strip() + if 'Registrar:' in line: + registrar = line.split(':', 1)[1].strip() + elif 'Creation Date:' in line or 'Created:' in line: + creation_date = line.split(':', 1)[1].strip() + elif 'Expiration Date:' in line or 'Expires:' in line: + expiration_date = line.split(':', 1)[1].strip() + elif 'Name Server:' in line: + ns = line.split(':', 1)[1].strip().lower() + if ns not in name_servers: + name_servers.append(ns) + + # Truncate output if too long + if len(output) > 3000: + output = output[:3000] + "\n... (output truncated)" + + whois_text = f"🔍 WHOIS Lookup Results\n\n" + whois_text += f"Domain/IP: {target}\n" + + if registrar: + whois_text += f"Registrar: {registrar}\n" + if creation_date: + whois_text += f"Created: {creation_date}\n" + if expiration_date: + whois_text += f"Expires: {expiration_date}\n" + if name_servers: + whois_text += f"Name Servers:\n" + for ns in name_servers[:5]: # Show max 5 name servers + whois_text += f" • {ns}\n" + + whois_text += f"\n📋 Full WHOIS Data:\n
{output}
" + + await response.edit(whois_text) + + except Exception as e: + await response.edit(f"❌ WHOIS error:\n{str(e)}") + + +@BOT.add_cmd(cmd="nslookup") +async def dns_lookup(bot: BOT, message: Message): + """ + CMD: NSLOOKUP + INFO: Perform DNS lookup for domains. + FLAGS: -type [A/AAAA/MX/NS/TXT] for specific record types + USAGE: .nslookup google.com + .nslookup -type MX gmail.com + """ + target = message.filtered_input + if not target: + await message.reply("❌ No domain provided!\n" + "Usage: .nslookup [domain]\n" + "Example: .nslookup google.com") + return + + # Parse record type + record_type = "A" # Default + if "-type" in message.flags: + try: + type_index = message.flags.index("-type") + 1 + if type_index < len(message.flags): + record_type = message.flags[type_index].upper() + except (ValueError, IndexError): + pass + + response = await message.reply(f"🔍 DNS lookup for {target} ({record_type})...") + + try: + # Run nslookup command + process = await asyncio.create_subprocess_exec( + 'nslookup', '-type=' + record_type, target, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + output = stdout.decode().strip() + + if process.returncode != 0 or "can't find" in output.lower(): + await response.edit(f"❌ DNS lookup failed!\n" + f"Domain: {target}\n" + f"Type: {record_type}\n" + f"Output:
{output}
") + return + + # Parse results for cleaner display + lines = output.split('\n') + + # Extract relevant information + results = [] + in_answer_section = False + + for line in lines: + line = line.strip() + if 'Non-authoritative answer:' in line: + in_answer_section = True + continue + elif in_answer_section and line: + if not line.startswith('Name:') and not line.startswith('Address:'): + results.append(line) + + dns_text = f"🔍 DNS Lookup Results\n\n" + dns_text += f"Domain: {target}\n" + dns_text += f"Record Type: {record_type}\n\n" + + if results: + dns_text += f"📋 Results:\n" + for result in results[:10]: # Limit to 10 results + dns_text += f"{result}\n" + + dns_text += f"\n📋 Full Output:\n
{output}
" + + await response.edit(dns_text) + + except Exception as e: + await response.edit(f"❌ DNS lookup error:\n{str(e)}") + + +@BOT.add_cmd(cmd="ipinfo") +async def ip_info(bot: BOT, message: Message): + """ + CMD: IPINFO + INFO: Get information about an IP address or domain. + USAGE: .ipinfo 8.8.8.8 + .ipinfo google.com + """ + target = message.filtered_input + if not target: + await message.reply("❌ No IP/domain provided!\n" + "Usage: .ipinfo [ip/domain]\n" + "Example: .ipinfo 8.8.8.8") + return + + response = await message.reply(f"🔍 Getting IP information for {target}...") + + try: + # First, resolve domain to IP if needed + try: + ip_address = socket.gethostbyname(target) + except socket.gaierror: + # Assume it's already an IP + ip_address = target + + # Validate IP address + try: + socket.inet_aton(ip_address) + except socket.error: + await response.edit(f"❌ Invalid IP address or domain!\n" + f"Target: {target}") + return + + # Get basic IP information + info_text = f"🌐 IP Address Information\n\n" + info_text += f"Target: {target}\n" + info_text += f"IP Address: {ip_address}\n" + + # Check if it's a private IP + ip_parts = ip_address.split('.') + if len(ip_parts) == 4: + first_octet = int(ip_parts[0]) + second_octet = int(ip_parts[1]) + + if (first_octet == 10 or + (first_octet == 172 and 16 <= second_octet <= 31) or + (first_octet == 192 and second_octet == 168) or + first_octet == 127): + info_text += f"Type: 🏠 Private/Local IP\n" + else: + info_text += f"Type: 🌍 Public IP\n" + + # Try to get hostname if different from input + if target != ip_address: + try: + hostname = socket.gethostbyaddr(ip_address)[0] + info_text += f"Hostname: {hostname}\n" + except socket.herror: + pass + + # Try reverse DNS lookup + try: + reverse_dns = socket.gethostbyaddr(ip_address)[0] + if reverse_dns != target: + info_text += f"Reverse DNS: {reverse_dns}\n" + except socket.herror: + info_text += f"Reverse DNS: Not available\n" + + info_text += f"\n✅ IP information retrieved!" + + await response.edit(info_text) + + except Exception as e: + await response.edit(f"❌ IP info error:\n{str(e)}") + + +@BOT.add_cmd(cmd="traceroute") +async def trace_route(bot: BOT, message: Message): + """ + CMD: TRACEROUTE + INFO: Trace the route to a destination. + USAGE: .traceroute google.com + """ + target = message.filtered_input + if not target: + await message.reply("❌ No target provided!\n" + "Usage: .traceroute [hostname/ip]\n" + "Example: .traceroute google.com") + return + + response = await message.reply(f"🔄 Tracing route to {target}...\nThis may take a moment...") + + try: + # Run traceroute command (use traceroute on Linux/Mac, tracert on Windows) + try: + # Try traceroute first (Linux/Mac) + process = await asyncio.create_subprocess_exec( + 'traceroute', '-m', '15', target, # Max 15 hops + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + except FileNotFoundError: + # Try tracert (Windows) + process = await asyncio.create_subprocess_exec( + 'tracert', '-h', '15', target, # Max 15 hops + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0) + except asyncio.TimeoutError: + process.kill() + await response.edit("⏰ Traceroute timed out after 60 seconds!") + return + + if process.returncode != 0: + error_msg = stderr.decode().strip() + await response.edit(f"❌ Traceroute failed!\n" + f"Target: {target}\n" + f"Error: {error_msg}") + return + + output = stdout.decode().strip() + + # Truncate if too long + if len(output) > 3500: + output = output[:3500] + "\n... (output truncated)" + + trace_text = f"🗺️ Traceroute Results\n\n" + trace_text += f"Target: {target}\n" + trace_text += f"Max Hops: 15\n\n" + trace_text += f"📋 Route Trace:\n
{output}
" + trace_text += f"\n✅ Traceroute completed!" + + await response.edit(trace_text) + + except Exception as e: + await response.edit(f"❌ Traceroute error:\n{str(e)}") \ No newline at end of file diff --git a/app/plugins/sudo/commands.py b/app/plugins/sudo/commands.py new file mode 100644 index 0000000..ea70c6f --- /dev/null +++ b/app/plugins/sudo/commands.py @@ -0,0 +1,118 @@ +from app import BOT, Config, CustomDB, Message + +DB = CustomDB["SUDO_CMD_LIST"] + + +async def init_task(): + async for sudo_cmd in DB.find(): + cmd_object = Config.CMD_DICT.get(sudo_cmd["_id"]) + if cmd_object: + cmd_object.loaded = True + + +@BOT.add_cmd(cmd="addscmd", allow_sudo=False) +async def add_scmd(bot: BOT, message: Message): + """ + CMD: ADDSCMD + INFO: Add Sudo Commands. + FLAGS: -all to instantly add all Commands. + USAGE: + .addscmd ping | .addscmd -all + """ + if "-all" in message.flags: + cmds = [] + + for cmd_name, cmd_object in Config.CMD_DICT.items(): + if cmd_object.sudo: + cmd_object.loaded = True + cmds.append({"_id": cmd_name}) + + await DB.drop() + await DB.insert_many(cmds) + + await (await message.reply("All Commands Added to Sudo!")).log() + return + + cmd_name = message.filtered_input + cmd_object = Config.CMD_DICT.get(cmd_name) + + response = await message.reply(f"Adding {cmd_name} to sudo....") + + if not cmd_object: + await response.edit(text=f"{cmd_name} not a valid command.", del_in=10) + return + + elif not cmd_object.sudo: + await response.edit(text=f"{cmd_name} is disabled for sudo users.", del_in=10) + return + + elif cmd_object.loaded: + await response.edit(text=f"{cmd_name} already in Sudo!", del_in=10) + return + + resp_str = f"#SUDO\n{cmd_name} added to Sudo!" + + if "-temp" in message.flags: + resp_str += "\nTemp: True" + else: + await DB.add_data(data={"_id": cmd_name}) + + cmd_object.loaded = True + + await (await response.edit(resp_str)).log() + + +@BOT.add_cmd(cmd="delscmd", allow_sudo=False) +async def del_scmd(bot: BOT, message: Message): + """ + CMD: DELSCMD + INFO: Remove Sudo Commands. + FLAGS: -all to instantly remove all Commands. + USAGE: + .delscmd ping | .delscmd -all + """ + if "-all" in message.flags: + + for cmd_object in Config.CMD_DICT.values(): + cmd_object.loaded = False + + await DB.drop() + await (await message.reply("All Commands Removed from Sudo!")).log() + return + + cmd_name = message.filtered_input + cmd_object = Config.CMD_DICT.get(cmd_name) + + if not cmd_object: + return + + response = await message.reply(f"Removing {cmd_name} from sudo....") + + if not cmd_object.loaded: + await response.edit(f"{cmd_name} not in Sudo!") + return + + cmd_object.loaded = False + resp_str = f"#SUDO\n{cmd_name} removed from Sudo!" + + if "-temp" in message.flags: + resp_str += "\nTemp: True" + else: + await DB.delete_data(cmd_name) + + await (await response.edit(resp_str)).log() + + +@BOT.add_cmd(cmd="vscmd") +async def view_sudo_cmd(bot: BOT, message: Message): + cmds = [cmd_name for cmd_name, cmd_obj in Config.CMD_DICT.items() if cmd_obj.loaded] + + if not cmds: + await message.reply("No Commands in SUDO!") + return + + await message.reply( + text=f"List of {len(cmds)}:\n
{cmds}
", + del_in=30, + block=False, + ) diff --git a/app/plugins/sudo/superuser_toggle.py b/app/plugins/sudo/superuser_toggle.py new file mode 100644 index 0000000..ccd5733 --- /dev/null +++ b/app/plugins/sudo/superuser_toggle.py @@ -0,0 +1,38 @@ +from pyrogram import filters + +from app import BOT, Config, Message, bot +from app.plugins.sudo.users import SUDO_USERS + + +@BOT.add_cmd(cmd="disable_su", allow_sudo=False) +async def disable_su(bot: BOT, message: Message): + u_id = message.from_user.id + + if u_id in Config.DISABLED_SUPERUSERS: + return + + Config.DISABLED_SUPERUSERS.append(u_id) + + await SUDO_USERS.add_data({"_id": u_id, "disabled": True}) + + await message.reply( + text="Your SuperUser Access is now Disabled.", del_in=10 + ) + + +@bot.on_message( + filters=filters.command(commands="enable_su", prefixes=Config.SUDO_TRIGGER) + & filters.create(lambda _, __, m: m.from_user and m.from_user.id in Config.DISABLED_SUPERUSERS), + group=1, + is_command=True, + filters_edited=True, + check_for_reactions=True, +) +async def enable_su(bot: BOT, message: Message): + u_id = message.from_user.id + + Config.DISABLED_SUPERUSERS.remove(u_id) + + await SUDO_USERS.add_data({"_id": u_id, "disabled": False}) + + await message.reply(text="Your SuperUser Access is now Enabled.", del_in=10) diff --git a/app/plugins/sudo/users.py b/app/plugins/sudo/users.py new file mode 100644 index 0000000..56ebc1d --- /dev/null +++ b/app/plugins/sudo/users.py @@ -0,0 +1,187 @@ +from pyrogram.types import User +from ub_core.utils.helpers import extract_user_data, get_name + +from app import BOT, Config, CustomDB, Message + +SUDO = CustomDB["COMMON_SETTINGS"] +SUDO_USERS = CustomDB["SUDO_USERS"] + + +async def init_task(): + sudo = await SUDO.find_one({"_id": "sudo_switch"}) or {} + Config.SUDO = sudo.get("value", False) + + async for sudo_user in SUDO_USERS.find(): + config = Config.SUPERUSERS if sudo_user.get("super") else Config.SUDO_USERS + config.append(sudo_user["_id"]) + + if sudo_user.get("disabled"): + Config.DISABLED_SUPERUSERS.append(sudo_user["_id"]) + + +@BOT.add_cmd(cmd="sudo", allow_sudo=False) +async def sudo(bot: BOT, message: Message): + """ + CMD: SUDO + INFO: Enable/Disable sudo.. + FLAGS: -c to check sudo status. + USAGE: + .sudo | .sudo -c + """ + if "-c" in message.flags: + await message.reply(text=f"Sudo is enabled: {Config.SUDO}!", del_in=8) + return + + value = not Config.SUDO + + Config.SUDO = value + + await SUDO.add_data({"_id": "sudo_switch", "value": value}) + + await (await message.reply(text=f"Sudo is enabled: {value}!", del_in=8)).log() + + +@BOT.add_cmd(cmd="addsudo", allow_sudo=False) +async def add_sudo(bot: BOT, message: Message) -> Message | None: + """ + CMD: ADDSUDO + INFO: Add Sudo User. + FLAGS: + -temp: to temporarily add until bot restarts. + -su: to give SuperUser access. + USAGE: + .addsudo [-temp | -su] [ UID | @ | Reply to Message ] + """ + response = await message.reply("Extracting User info...") + + user, _ = await message.extract_user_n_reason() + + if not isinstance(user, User): + await response.edit("unable to extract user info.") + return + + if "-su" in message.flags: + add_list, remove_list = Config.SUPERUSERS, Config.SUDO_USERS + text = "Super Users" + else: + add_list, remove_list = Config.SUDO_USERS, Config.SUPERUSERS + text = "Sudo Users" + + if user.id in add_list: + await response.edit( + text=f"{get_name(user)} already in Sudo with same privileges!", del_in=5 + ) + return + + response_str = f"#SUDO\n{user.mention} added to {text} List." + + add_and_remove(user.id, add_list, remove_list) + + if "-temp" not in message.flags: + await SUDO_USERS.add_data( + { + "_id": user.id, + **extract_user_data(user), + "disabled": False, + "super": "-su" in message.flags, + } + ) + else: + response_str += "\nTemporary: True" + + await response.edit(text=response_str, del_in=5) + await response.log() + + +@BOT.add_cmd(cmd="delsudo", allow_sudo=False) +async def remove_sudo(bot: BOT, message: Message) -> Message | None: + """ + CMD: DELSUDO + INFO: Add Remove User. + FLAGS: + -temp: to temporarily remove until bot restarts. + -su: to Remove SuperUser Access. + -f: force rm an id + USAGE: + .delsudo [-temp] [ UID | @ | Reply to Message ] + """ + + if "-f" in message.flags: + await SUDO_USERS.delete_data(id=int(message.filtered_input)) + await message.reply(f"Forcefully deleted {message.filtered_input} from sudo users.") + return + + response = await message.reply("Extracting User info...") + user, _ = await message.extract_user_n_reason() + + if isinstance(user, str): + await response.edit(user) + return + + if not isinstance(user, User): + await response.edit("unable to extract user info.") + return + + if user.id not in {*Config.SUDO_USERS, *Config.SUPERUSERS}: + await response.edit(text=f"{get_name(user)} not in Sudo!", del_in=5) + return + + if "-su" in message.flags: + response_str = f"{user.mention}'s Super User access is revoked to Sudo only." + add_and_remove(user.id, Config.SUDO_USERS, Config.SUPERUSERS) + else: + add_and_remove(user.id, remove_list=Config.SUPERUSERS) + add_and_remove(user.id, remove_list=Config.SUDO_USERS) + response_str = f"{user.mention}'s access to bot has been removed." + + if "-temp" not in message.flags: + if "-su" in message.flags: + await SUDO_USERS.add_data({"_id": user.id, "super": False}) + else: + await SUDO_USERS.delete_data(id=user.id) + + else: + response_str += "\nTemporary: True" + + await response.edit(text=response_str, del_in=5) + await response.log() + + +def add_and_remove(u_id: int, add_list: list | None = None, remove_list: list | None = None): + if add_list is not None and u_id not in add_list: + add_list.append(u_id) + + if remove_list is not None and u_id in remove_list: + remove_list.remove(u_id) + + +@BOT.add_cmd(cmd="vsudo") +async def sudo_list(bot: BOT, message: Message): + """ + CMD: VSUDO + INFO: View Sudo Users. + FLAGS: -id to get UIDs + USAGE: + .vsudo | .vsudo -id + """ + output: str = "" + total = 0 + + async for user in SUDO_USERS.find(): + output += f'\n• {user["name"]}' + + if "-id" in message.flags: + output += f'\n ID: {user["_id"]}' + + output += f'\n Super: {user.get("super", False)}' + + output += f'\n Disabled: {user.get("disabled", False)}\n' + + total += 1 + + if not total: + await message.reply("You don't have any SUDO USERS.") + return + + output: str = f"List of {total} SUDO USERS:\n{output}" + await message.reply(output, del_in=30, block=True) diff --git a/app/plugins/system/neofetch.py b/app/plugins/system/neofetch.py new file mode 100644 index 0000000..59cfda1 --- /dev/null +++ b/app/plugins/system/neofetch.py @@ -0,0 +1,246 @@ +import platform +import psutil +from datetime import datetime + +from app import BOT, Message + + +@BOT.add_cmd(cmd="neofetch") +async def neofetch_info(bot: BOT, message: Message): + """ + CMD: NEOFETCH + INFO: Display system information in neofetch style with ASCII art. + USAGE: .neofetch + """ + response = await message.reply("🔄 Generating neofetch output...") + + try: + # Get system information + system = platform.system() + distro = platform.platform() + hostname = platform.node() + kernel = platform.release() + architecture = platform.machine() + + # CPU info + cpu_info = platform.processor() or "Unknown" + cpu_cores = psutil.cpu_count(logical=True) + cpu_usage = psutil.cpu_percent(interval=1) + + # Memory info + memory = psutil.virtual_memory() + memory_used_gb = memory.used / (1024**3) + memory_total_gb = memory.total / (1024**3) + + # Uptime + boot_time = psutil.boot_time() + uptime = datetime.now() - datetime.fromtimestamp(boot_time) + uptime_str = str(uptime).split('.')[0] + + # Choose ASCII art based on OS + if "Windows" in system: + ascii_art = """🪟 Windows +██╗ ██╗██╗███╗ ██╗ +██║ ██║██║████╗ ██║ +██║ █╗ ██║██║██╔██╗ ██║ +██║███╗██║██║██║╚██╗██║ +╚███╔███╔╝██║██║ ╚████║ + ╚══╝╚══╝ ╚═╝╚═╝ ╚═══╝""" + elif "Darwin" in system: + ascii_art = """🍎 macOS + 'c. + ,xNMM. + .OMMMMo + OMMM0, + .;loddo:' loolloddol;. + cKMMMMMMMMMMNWMMMMMMMMMM0: + .KMMMMMMMMMMMMMMMMMMMMMMMWd. + XMMMMMMMMMMMMMMMMMMMMMMMX. +;MMMMMMMMMMMMMMMMMMMMMMMM: +:MMMMMMMMMMMMMMMMMMMMMMMM: +.MMMMMMMMMMMMMMMMMMMMMMMMX. + kMMMMMMMMMMMMMMMMMMMMMMMMWd. + .XMMMMMMMMMMMMMMMMMMMMMMMMMMk + .XMMMMMMMMMMMMMMMMMMMMMMMMK. + kMMMMMMMMMMMMMMMMMMMMMMd + ;KMMMMMMMWXXWMMMMMMMk. + .cooc,. .,coo:.""" + else: # Linux/Unix + ascii_art = """🐧 Linux + ##### + ####### + ##O#O## + ####### + ########### + ############# + ############### + ################ + ################# +##################### +##################### + #################""" + + # Create the neofetch-style output + info_lines = [ + f"OS: {distro}", + f"Host: {hostname}", + f"Kernel: {kernel}", + f"Uptime: {uptime_str}", + f"CPU: {cpu_info} ({cpu_cores} cores)", + f"CPU Usage: {cpu_usage}%", + f"Memory: {memory_used_gb:.1f}GB / {memory_total_gb:.1f}GB ({memory.percent}%)", + f"Architecture: {architecture}", + ] + + # Combine ASCII art with info + art_lines = ascii_art.strip().split('\n') + max_art_width = max(len(line) for line in art_lines) + + # Pad art lines and combine with info + output_lines = [] + for i in range(max(len(art_lines), len(info_lines))): + art_part = art_lines[i] if i < len(art_lines) else " " * max_art_width + info_part = info_lines[i] if i < len(info_lines) else "" + output_lines.append(f"{art_part} {info_part}") + + neofetch_output = f"
{chr(10).join(output_lines)}
" + + await response.edit(neofetch_output) + + except Exception as e: + await response.edit(f"❌ Error generating neofetch:\n{str(e)}") + + +@BOT.add_cmd(cmd="fetch") +async def minimal_fetch(bot: BOT, message: Message): + """ + CMD: FETCH + INFO: Display minimal system information. + USAGE: .fetch + """ + response = await message.reply("🔄 Getting system info...") + + try: + # Get basic info + system = platform.system() + release = platform.release() + hostname = platform.node() + cpu_cores = psutil.cpu_count(logical=True) + + # Memory + memory = psutil.virtual_memory() + memory_gb = memory.total / (1024**3) + + # Uptime + boot_time = psutil.boot_time() + uptime = datetime.now() - datetime.fromtimestamp(boot_time) + days = uptime.days + hours, remainder = divmod(uptime.seconds, 3600) + minutes, _ = divmod(remainder, 60) + + # Simple system emoji + if "Windows" in system: + emoji = "🪟" + elif "Darwin" in system: + emoji = "🍎" + else: + emoji = "🐧" + + fetch_text = f"""{emoji} {hostname} +━━━━━━━━━━━━━━━━━━━━━ +OS: {system} {release} +CPU: {cpu_cores} cores +Memory: {memory_gb:.1f} GB +Uptime: {days}d {hours}h {minutes}m""" + + await response.edit(fetch_text) + + except Exception as e: + await response.edit(f"❌ Error: {str(e)}") + + +@BOT.add_cmd(cmd="logo") +async def system_logo(bot: BOT, message: Message): + """ + CMD: LOGO + INFO: Display ASCII logo based on the operating system. + USAGE: .logo + """ + response = await message.reply("🔄 Generating system logo...") + + try: + system = platform.system() + + if "Windows" in system: + logo = """
+██████╗ ██████╗ ███████╗████████╗████████╗██╗   ██╗    ██╗   ██╗██████╗ 
+██╔══██╗██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝    ██║   ██║██╔══██╗
+██████╔╝██████╔╝█████╗     ██║      ██║    ╚████╔╝     ██║   ██║██████╔╝
+██╔═══╝ ██╔══██╗██╔══╝     ██║      ██║     ╚██╔╝      ██║   ██║██╔══██╗
+██║     ██║  ██║███████╗   ██║      ██║      ██║       ╚██████╔╝██████╔╝
+╚═╝     ╚═╝  ╚═╝╚══════╝   ╚═╝      ╚═╝      ╚═╝        ╚═════╝ ╚═════╝ 
+                                                                          
+                    🪟 Windows System 🪟                                  
+
""" + elif "Darwin" in system: + logo = """
+                                     ████████                             
+                                 ████████████████                         
+                               ██████████████████████                     
+                             ████████████████████████████                 
+                           ██████████████████████████████████             
+                         ████████████████████████████████████████         
+                       ████████████████    ████████████████████████       
+                     ████████████████        ████████████████████████     
+                   ████████████████            ████████████████████████   
+                 ████████████████                ████████████████████████ 
+               ████████████████                    ████████████████████████
+             ████████████████                        ████████████████████
+           ████████████████                            ████████████████
+         ████████████████                                ████████████████
+       ████████████████                                    ████████████████
+     ████████████████                                        ████████████████
+   ████████████████                                            ████████████████
+ ████████████████                                                ████████████████
+████████████████                                                  ████████████████
+
+                             🍎 macOS System 🍎                            
+
""" + else: # Linux + logo = """
+                .-/+oossssoo+/-.               
+            `:+ssssssssssssssssss+:`           
+          -+ssssssssssssssssssyyssss+-         
+        .ossssssssssssssssss+.  .+sssso.       
+       /sssssssssss+++++++/       /ssssso      
+      +sssssssss+.                 .sssssso    
+     +sssssss+.                      +ssssss   
+    +sssss+.                           +sssss  
+   +ssss/                               sssss- 
+  .sss+                                  +sss. 
+ `ss+          .-.                        .ss`
+`ss.         .ossso-                        ss`
+.ss         `ssssss+                        ss.
++ss         `ssssss+                        ss+
++ss         `ssssss+                        ss+
+.ss         `ssssss+                        ss.
+`ss.         .ossso-                        ss`
+ `ss+          .-.                        .ss`
+  .sss+                                  +sss. 
+   +ssss/                               sssss- 
+    +sssss+.                           +sssss  
+     +sssssss+.                      +ssssss   
+      +sssssssss+.                 .sssssso    
+       /sssssssssss+++++++/       /ssssso      
+        .ossssssssssssssssss+.  .+sssso.       
+          -+ssssssssssssssssssyyssss+-         
+            `:+ssssssssssssssssss+:`           
+                .-/+oossssoo+/-.               
+
+                      🐧 Linux System 🐧                       
+
""" + + await response.edit(logo) + + except Exception as e: + await response.edit(f"❌ Error: {str(e)}") \ No newline at end of file diff --git a/app/plugins/system/shell.py b/app/plugins/system/shell.py new file mode 100644 index 0000000..28d007a --- /dev/null +++ b/app/plugins/system/shell.py @@ -0,0 +1,232 @@ +import asyncio +import os +import shlex +from datetime import datetime + +from app import BOT, Config, Message + + +# Dangerous commands that should be blocked +DANGEROUS_COMMANDS = [ + 'rm', 'sudo rm', 'del', 'format', 'fdisk', 'mkfs', + 'dd', 'sudo dd', 'chmod 000', 'sudo chmod', + ':(){ :|:& };:', 'shutdown', 'reboot', 'halt', + 'sudo shutdown', 'sudo reboot', 'sudo halt', + 'passwd', 'sudo passwd', 'userdel', 'sudo userdel' +] + + +def is_dangerous_command(command: str) -> bool: + """Check if command contains dangerous operations""" + command_lower = command.lower().strip() + for dangerous in DANGEROUS_COMMANDS: + if command_lower.startswith(dangerous): + return True + return False + + +@BOT.add_cmd(cmd="sh", allow_sudo=False) +async def shell_command(bot: BOT, message: Message): + """ + CMD: SH + INFO: Execute shell commands safely (Owner only). + FLAGS: -o for output only, -t for with timestamps + USAGE: .sh ls -la + """ + # Only allow owner to use shell commands + if message.from_user.id != Config.OWNER_ID: + await message.reply("❌ Access Denied: Only the bot owner can execute shell commands.") + return + + command = message.filtered_input + if not command: + await message.reply("❌ No command provided.\nUsage: .sh [command]") + return + + # Check for dangerous commands + if is_dangerous_command(command): + await message.reply("⚠️ Dangerous command blocked for safety!\n" + f"Command: {command}\n\n" + "This command could harm your system.") + return + + response = await message.reply(f"🔄 Executing: {command}") + + try: + start_time = datetime.now() + + # Execute command with timeout + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=os.getcwd() + ) + + # Wait for completion with timeout + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30.0) + except asyncio.TimeoutError: + process.kill() + await response.edit("⏰ Command timed out after 30 seconds!") + return + + end_time = datetime.now() + execution_time = (end_time - start_time).total_seconds() + + # Decode output + stdout_text = stdout.decode('utf-8', errors='ignore').strip() + stderr_text = stderr.decode('utf-8', errors='ignore').strip() + + # Prepare output + result_text = f"💻 Shell Command Result\n\n" + result_text += f"Command: {command}\n" + result_text += f"Exit Code: {process.returncode}\n" + result_text += f"Execution Time: {execution_time:.2f}s\n" + + if "-t" in message.flags: + result_text += f"Started: {start_time.strftime('%H:%M:%S')}\n" + result_text += f"Finished: {end_time.strftime('%H:%M:%S')}\n" + + result_text += "\n" + + # Add stdout if present + if stdout_text: + if len(stdout_text) > 3000: + stdout_text = stdout_text[:3000] + "\n... (output truncated)" + result_text += f"📤 Output:\n
{stdout_text}
\n" + + # Add stderr if present + if stderr_text: + if len(stderr_text) > 1000: + stderr_text = stderr_text[:1000] + "\n... (error truncated)" + result_text += f"❌ Error:\n
{stderr_text}
\n" + + # If no output + if not stdout_text and not stderr_text: + result_text += "No output produced.\n" + + # Success indicator + if process.returncode == 0: + result_text += "\n✅ Command executed successfully!" + else: + result_text += f"\n❌ Command failed with exit code {process.returncode}" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Error executing command:\n{str(e)}") + + +@BOT.add_cmd(cmd="exec", allow_sudo=False) +async def python_exec(bot: BOT, message: Message): + """ + CMD: EXEC + INFO: Execute Python code safely (Owner only). + USAGE: .exec print("Hello World") + """ + # Only allow owner + if message.from_user.id != Config.OWNER_ID: + await message.reply("❌ Access Denied: Only the bot owner can execute Python code.") + return + + code = message.filtered_input + if not code: + await message.reply("❌ No code provided.\nUsage: .exec [python_code]") + return + + response = await message.reply(f"🐍 Executing Python code...\n
{code}
") + + try: + # Capture output + import io + import sys + from contextlib import redirect_stdout, redirect_stderr + + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + start_time = datetime.now() + + # Execute the code + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + exec(code) + + end_time = datetime.now() + execution_time = (end_time - start_time).total_seconds() + + # Get captured output + stdout_output = stdout_capture.getvalue() + stderr_output = stderr_capture.getvalue() + + result_text = f"🐍 Python Execution Result\n\n" + result_text += f"Execution Time: {execution_time:.3f}s\n\n" + + if stdout_output: + if len(stdout_output) > 3500: + stdout_output = stdout_output[:3500] + "\n... (output truncated)" + result_text += f"📤 Output:\n
{stdout_output}
\n" + + if stderr_output: + if len(stderr_output) > 1000: + stderr_output = stderr_output[:1000] + "\n... (error truncated)" + result_text += f"❌ Error:\n
{stderr_output}
\n" + + if not stdout_output and not stderr_output: + result_text += "Code executed without output.\n" + + result_text += "\n✅ Python code executed successfully!" + + await response.edit(result_text) + + except Exception as e: + error_text = f"🐍 Python Execution Error\n\n" + error_text += f"Error: {type(e).__name__}: {str(e)}\n" + error_text += f"Code:\n
{code}
" + + await response.edit(error_text) + + +@BOT.add_cmd(cmd="eval", allow_sudo=False) +async def python_eval(bot: BOT, message: Message): + """ + CMD: EVAL + INFO: Evaluate Python expressions safely (Owner only). + USAGE: .eval 2 + 2 + """ + # Only allow owner + if message.from_user.id != Config.OWNER_ID: + await message.reply("❌ Access Denied: Only the bot owner can evaluate Python expressions.") + return + + expression = message.filtered_input + if not expression: + await message.reply("❌ No expression provided.\nUsage: .eval [expression]") + return + + response = await message.reply(f"🧮 Evaluating: {expression}") + + try: + start_time = datetime.now() + + # Evaluate the expression + result = eval(expression) + + end_time = datetime.now() + execution_time = (end_time - start_time).total_seconds() + + result_text = f"🧮 Python Evaluation Result\n\n" + result_text += f"Expression: {expression}\n" + result_text += f"Result: {repr(result)}\n" + result_text += f"Type: {type(result).__name__}\n" + result_text += f"Execution Time: {execution_time:.3f}s\n" + result_text += "\n✅ Expression evaluated successfully!" + + await response.edit(result_text) + + except Exception as e: + error_text = f"🧮 Python Evaluation Error\n\n" + error_text += f"Expression: {expression}\n" + error_text += f"Error: {type(e).__name__}: {str(e)}" + + await response.edit(error_text) \ No newline at end of file diff --git a/app/plugins/system/speedtest.py b/app/plugins/system/speedtest.py new file mode 100644 index 0000000..ee72616 --- /dev/null +++ b/app/plugins/system/speedtest.py @@ -0,0 +1,180 @@ +import asyncio +import subprocess +from datetime import datetime + +from app import BOT, Message + + +@BOT.add_cmd(cmd="speedtest") +async def internet_speedtest(bot: BOT, message: Message): + """ + CMD: SPEEDTEST + INFO: Test internet connection speed using speedtest-cli. + USAGE: .speedtest + """ + response = await message.reply("🔄 Starting internet speed test...\nThis may take a few moments...") + + try: + # Update status + await response.edit("🔍 Finding best server...") + + # Run speedtest command + process = await asyncio.create_subprocess_exec( + 'speedtest-cli', '--simple', '--secure', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode().strip() + await response.edit(f"❌ Speedtest failed:\n{error_msg}") + return + + # Parse results + output = stdout.decode().strip() + lines = output.split('\n') + + # Extract values + ping_line = next((line for line in lines if 'Ping:' in line), '') + download_line = next((line for line in lines if 'Download:' in line), '') + upload_line = next((line for line in lines if 'Upload:' in line), '') + + # Parse values + ping = ping_line.split(':')[1].strip() if ping_line else 'N/A' + download = download_line.split(':')[1].strip() if download_line else 'N/A' + upload = upload_line.split(':')[1].strip() if upload_line else 'N/A' + + # Get current time + test_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + result_text = f"""🚀 Internet Speed Test Results + +📊 Test Results: +📡 Ping: {ping} +⬇️ Download: {download} +⬆️ Upload: {upload} + +🕐 Test Time: {test_time} +🔧 Powered by: speedtest.net + +Test completed successfully! ✅""" + + await response.edit(result_text) + + except Exception as e: + await response.edit(f"❌ Error running speedtest:\n{str(e)}") + + +@BOT.add_cmd(cmd="speedtest-server") +async def speedtest_with_server(bot: BOT, message: Message): + """ + CMD: SPEEDTEST-SERVER + INFO: Test internet speed with detailed server information. + USAGE: .speedtest-server + """ + response = await message.reply("🔄 Starting detailed speed test...") + + try: + # Run detailed speedtest + process = await asyncio.create_subprocess_exec( + 'speedtest-cli', '--secure', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode().strip() + await response.edit(f"❌ Speedtest failed:\n{error_msg}") + return + + # Parse detailed output + output = stdout.decode().strip() + + # Extract server info, speeds, etc. + lines = output.split('\n') + + server_info = "" + results_info = "" + + for line in lines: + if 'Testing from' in line: + server_info += f"ISP: {line.split('Testing from')[1].strip()}\n" + elif 'Hosted by' in line: + server_info += f"Server: {line.split('Hosted by')[1].strip()}\n" + elif 'Download:' in line: + results_info += f"⬇️ Download: {line.split('Download:')[1].strip()}\n" + elif 'Upload:' in line: + results_info += f"⬆️ Upload: {line.split('Upload:')[1].strip()}\n" + + # Get share URL if available + share_url = "" + for line in lines: + if 'Share results:' in line: + share_url = f"\n🔗 Share URL: {line.split('Share results:')[1].strip()}" + break + + test_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + detailed_result = f"""🚀 Detailed Internet Speed Test + +🌐 Connection Info: +{server_info} + +📊 Speed Results: +{results_info} + +🕐 Test Time: {test_time}{share_url} + +Detailed test completed! ✅""" + + await response.edit(detailed_result) + + except Exception as e: + await response.edit(f"❌ Error running detailed speedtest:\n{str(e)}") + + +@BOT.add_cmd(cmd="speedtest-list") +async def speedtest_servers_list(bot: BOT, message: Message): + """ + CMD: SPEEDTEST-LIST + INFO: List available speedtest servers. + USAGE: .speedtest-list + """ + response = await message.reply("🔄 Getting available servers...") + + try: + # Get server list + process = await asyncio.create_subprocess_exec( + 'speedtest-cli', '--list', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode().strip() + await response.edit(f"❌ Failed to get server list:\n{error_msg}") + return + + output = stdout.decode().strip() + lines = output.split('\n') + + # Take only first 15 servers to avoid message being too long + server_lines = [line for line in lines if line.strip() and ')' in line][:15] + + servers_text = "🌐 Available Speedtest Servers\n\n" + + for line in server_lines: + servers_text += f"{line.strip()}\n" + + servers_text += f"\nShowing first 15 servers. Use speedtest-cli --server [ID] for specific server." + + await response.edit(servers_text) + + except Exception as e: + await response.edit(f"❌ Error getting server list:\n{str(e)}") \ No newline at end of file diff --git a/app/plugins/system/sysinfo.py b/app/plugins/system/sysinfo.py new file mode 100644 index 0000000..053b6ec --- /dev/null +++ b/app/plugins/system/sysinfo.py @@ -0,0 +1,433 @@ +import platform +import psutil +import os +import subprocess +from datetime import datetime, timedelta + +from app import BOT, Message + + +def is_termux(): + """Check if running on Termux (Android)""" + return os.path.exists("/data/data/com.termux") or "TERMUX_VERSION" in os.environ + + +def get_android_info(): + """Get Android-specific information""" + android_info = {} + + try: + # Try to get Android version + if os.path.exists("/system/build.prop"): + with open("/system/build.prop", "r") as f: + for line in f: + if "ro.build.version.release" in line: + android_info["version"] = line.split("=")[1].strip() + elif "ro.product.model" in line: + android_info["model"] = line.split("=")[1].strip() + elif "ro.product.brand" in line: + android_info["brand"] = line.split("=")[1].strip() + except: + pass + + # Try using getprop command + try: + result = subprocess.run(["getprop", "ro.build.version.release"], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + android_info["version"] = result.stdout.strip() + except: + pass + + try: + result = subprocess.run(["getprop", "ro.product.model"], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + android_info["model"] = result.stdout.strip() + except: + pass + + return android_info + + +@BOT.add_cmd(cmd="sysinfo") +async def system_info(bot: BOT, message: Message): + """ + CMD: SYSINFO + INFO: Get detailed system information including CPU, RAM, disk usage, and uptime. + USAGE: .sysinfo + """ + response = await message.reply("🔄 Gathering system information...") + + try: + # Detect if running on Termux/Android + is_android = is_termux() + + # System Info + system = platform.system() + node = platform.node() + release = platform.release() + version = platform.version() + machine = platform.machine() + processor = platform.processor() or "Unknown" + + # Get Android-specific info if applicable + android_info = {} + if is_android: + android_info = get_android_info() + + # CPU Info with fallbacks + try: + cpu_count = psutil.cpu_count(logical=False) or psutil.cpu_count(logical=True) + cpu_count_logical = psutil.cpu_count(logical=True) + except: + cpu_count = "Unknown" + cpu_count_logical = "Unknown" + + try: + cpu_freq = psutil.cpu_freq() + if cpu_freq: + freq_current = cpu_freq.current + freq_max = cpu_freq.max + else: + freq_current = freq_max = None + except: + freq_current = freq_max = None + + try: + cpu_percent = psutil.cpu_percent(interval=1) + except: + cpu_percent = "Unknown" + + # Memory Info with fallbacks + try: + memory = psutil.virtual_memory() + except: + memory = None + + try: + swap = psutil.swap_memory() + except: + swap = None + + # Disk Info with fallbacks + try: + # Try different paths for Termux + if is_android: + # Termux typically uses /data/data/com.termux + disk_paths = ["/data/data/com.termux", "/", "/sdcard"] + disk = None + for path in disk_paths: + try: + if os.path.exists(path): + disk = psutil.disk_usage(path) + break + except: + continue + else: + disk = psutil.disk_usage('/') + except: + disk = None + + # Boot time and uptime with fallbacks + try: + boot_time = psutil.boot_time() + boot_time_str = datetime.fromtimestamp(boot_time).strftime("%Y-%m-%d %H:%M:%S") + uptime = datetime.now() - datetime.fromtimestamp(boot_time) + uptime_str = str(uptime).split('.')[0] # Remove microseconds + except: + boot_time_str = "Unknown" + uptime_str = "Unknown" + + # Load average (Unix systems) with fallbacks + try: + load_avg = psutil.getloadavg() + load_str = f"Load Average: {load_avg[0]:.2f}, {load_avg[1]:.2f}, {load_avg[2]:.2f}" + except (AttributeError, OSError): + load_str = "Load Average: Not available on this system" + + # Format sizes + def bytes_to_gb(bytes_val): + return bytes_val / (1024**3) + + # Build system info with Android detection + if is_android: + system_emoji = "📱" + system_title = "Android System Information (Termux)" + else: + system_emoji = "🖥️" + system_title = "System Information" + + info_text = f"""{system_emoji} {system_title} + +🔧 System Details: +OS: {system} {release}""" + + # Add Android-specific info if available + if is_android and android_info: + if "version" in android_info: + info_text += f"\nAndroid: {android_info['version']}" + if "brand" in android_info and "model" in android_info: + info_text += f"\nDevice: {android_info['brand']} {android_info['model']}" + elif "model" in android_info: + info_text += f"\nDevice: {android_info['model']}" + + info_text += f""" +Hostname: {node} +Architecture: {machine} +Processor: {processor} + +⚡ CPU Information:""" + + if cpu_count != "Unknown": + info_text += f"\nCores: {cpu_count} physical, {cpu_count_logical} logical" + else: + info_text += f"\nCores: {cpu_count_logical} logical" + + if freq_current and freq_max: + info_text += f"\nFrequency: {freq_current:.0f} MHz (Max: {freq_max:.0f} MHz)" + elif freq_current: + info_text += f"\nFrequency: {freq_current:.0f} MHz" + else: + info_text += f"\nFrequency: Not available" + + info_text += f"\nUsage: {cpu_percent}%" + + info_text += f"\n\n🧠 Memory Information:" + + if memory: + info_text += f""" +Total RAM: {bytes_to_gb(memory.total):.2f} GB +Available: {bytes_to_gb(memory.available):.2f} GB ({memory.percent}% used)""" + else: + info_text += f"\nMemory: Information not accessible" + + if swap and swap.total > 0: + info_text += f""" +Swap Total: {bytes_to_gb(swap.total):.2f} GB +Swap Used: {bytes_to_gb(swap.used):.2f} GB ({swap.percent}% used)""" + elif is_android: + info_text += f"\nSwap: Not typically used on Android" + else: + info_text += f"\nSwap: Not available" + + info_text += f"\n\n💾 Disk Information:" + + if disk: + storage_path = "Termux Storage" if is_android else "Root" + info_text += f""" +Path: {storage_path} +Total: {bytes_to_gb(disk.total):.2f} GB +Used: {bytes_to_gb(disk.used):.2f} GB ({disk.percent}% used) +Free: {bytes_to_gb(disk.free):.2f} GB""" + else: + info_text += f"\nStorage: Information not accessible" + + info_text += f""" + +⏱️ System Uptime: +Boot Time: {boot_time_str} +Uptime: {uptime_str} + +{load_str}""" + + # Add Termux-specific note + if is_android: + info_text += f"\n\n📱 Running on Android via Termux" + + await response.edit(info_text) + + except Exception as e: + await response.edit(f"❌ Error getting system info:\n{str(e)}") + + +@BOT.add_cmd(cmd="disk") +async def disk_usage(bot: BOT, message: Message): + """ + CMD: DISK + INFO: Show disk usage for all mounted drives. + USAGE: .disk + """ + response = await message.reply("🔄 Getting disk usage...") + + try: + is_android = is_termux() + + if is_android: + # Special handling for Termux/Android + disk_info = "💾 Storage Usage Information (Termux)\n\n" + + # Check common Termux paths + termux_paths = [ + ("/data/data/com.termux", "Termux App Storage"), + ("/sdcard", "Internal Storage"), + ("/storage/emulated/0", "Emulated Storage"), + ("/", "Root") + ] + + for path, description in termux_paths: + try: + if os.path.exists(path): + usage = psutil.disk_usage(path) + total_gb = usage.total / (1024**3) + used_gb = usage.used / (1024**3) + free_gb = usage.free / (1024**3) + percent = (usage.used / usage.total) * 100 if usage.total > 0 else 0 + + # Create progress bar + bar_length = 15 + filled_length = int(bar_length * percent / 100) + bar = "█" * filled_length + "░" * (bar_length - filled_length) + + disk_info += f"""📱 {description} +Path: {path} +Total: {total_gb:.2f} GB +Used: {used_gb:.2f} GB ({percent:.1f}%) +Free: {free_gb:.2f} GB +{bar} {percent:.1f}% + +""" + except (PermissionError, OSError): + disk_info += f"📱 {description}\n❌ Access denied or not mounted\n\n" + else: + # Regular disk usage for non-Android systems + try: + partitions = psutil.disk_partitions() + except: + partitions = [] + + disk_info = "💾 Disk Usage Information\n\n" + + for partition in partitions: + try: + usage = psutil.disk_usage(partition.mountpoint) + total_gb = usage.total / (1024**3) + used_gb = usage.used / (1024**3) + free_gb = usage.free / (1024**3) + percent = (usage.used / usage.total) * 100 + + # Create a simple progress bar + bar_length = 15 + filled_length = int(bar_length * percent / 100) + bar = "█" * filled_length + "░" * (bar_length - filled_length) + + disk_info += f"""📁 {partition.mountpoint} +Device: {partition.device} +Filesystem: {partition.fstype} +Total: {total_gb:.2f} GB +Used: {used_gb:.2f} GB ({percent:.1f}%) +Free: {free_gb:.2f} GB +{bar} {percent:.1f}% + +""" + except PermissionError: + disk_info += f"📁 {partition.mountpoint}\n❌ Permission denied\n\n" + + await response.edit(disk_info) + + except Exception as e: + await response.edit(f"❌ Error getting disk usage:\n{str(e)}") + + +@BOT.add_cmd(cmd="mem") +async def memory_info(bot: BOT, message: Message): + """ + CMD: MEM + INFO: Show detailed memory usage information. + USAGE: .mem + """ + response = await message.reply("🔄 Getting memory information...") + + try: + memory = psutil.virtual_memory() + swap = psutil.swap_memory() + + def bytes_to_gb(bytes_val): + return bytes_val / (1024**3) + + def bytes_to_mb(bytes_val): + return bytes_val / (1024**2) + + # Memory progress bar + mem_percent = memory.percent + bar_length = 20 + filled_length = int(bar_length * mem_percent / 100) + mem_bar = "█" * filled_length + "░" * (bar_length - filled_length) + + # Swap progress bar + swap_percent = swap.percent + swap_filled_length = int(bar_length * swap_percent / 100) + swap_bar = "█" * swap_filled_length + "░" * (bar_length - swap_filled_length) + + mem_text = f"""🧠 Memory Usage Information + +📊 Virtual Memory: +Total: {bytes_to_gb(memory.total):.2f} GB +Available: {bytes_to_gb(memory.available):.2f} GB +Used: {bytes_to_gb(memory.used):.2f} GB +Cached: {bytes_to_mb(memory.cached):.0f} MB +Buffers: {bytes_to_mb(memory.buffers):.0f} MB +Usage: {mem_percent}% +{mem_bar} {mem_percent}% + +💿 Swap Memory: +Total: {bytes_to_gb(swap.total):.2f} GB +Used: {bytes_to_gb(swap.used):.2f} GB +Free: {bytes_to_gb(swap.free):.2f} GB +Usage: {swap_percent}% +{swap_bar} {swap_percent}%""" + + await response.edit(mem_text) + + except Exception as e: + await response.edit(f"❌ Error getting memory info:\n{str(e)}") + + +@BOT.add_cmd(cmd="cpu") +async def cpu_info(bot: BOT, message: Message): + """ + CMD: CPU + INFO: Show detailed CPU usage and information. + USAGE: .cpu + """ + response = await message.reply("🔄 Getting CPU information...") + + try: + # Get CPU info + cpu_percent = psutil.cpu_percent(interval=1, percpu=True) + cpu_count_physical = psutil.cpu_count(logical=False) + cpu_count_logical = psutil.cpu_count(logical=True) + cpu_freq = psutil.cpu_freq() + + # Overall CPU usage + overall_cpu = psutil.cpu_percent(interval=1) + + # CPU progress bar + bar_length = 20 + filled_length = int(bar_length * overall_cpu / 100) + cpu_bar = "█" * filled_length + "░" * (bar_length - filled_length) + + cpu_text = f"""⚡ CPU Usage Information + +🖥️ General Info: +Physical Cores: {cpu_count_physical} +Logical Cores: {cpu_count_logical} +Current Frequency: {cpu_freq.current:.0f} MHz +Max Frequency: {cpu_freq.max:.0f} MHz +Min Frequency: {cpu_freq.min:.0f} MHz + +📊 Overall Usage: {overall_cpu}% +{cpu_bar} {overall_cpu}% + +🔄 Per-Core Usage:""" + + # Add per-core usage + for i, percent in enumerate(cpu_percent): + core_filled = int(10 * percent / 100) # Smaller bars for cores + core_bar = "█" * core_filled + "░" * (10 - core_filled) + cpu_text += f"\nCore {i+1}: {core_bar} {percent}%" + + await response.edit(cpu_text) + + except Exception as e: + await response.edit(f"❌ Error getting CPU info:\n{str(e)}") \ No newline at end of file diff --git a/app/plugins/system/termux.py b/app/plugins/system/termux.py new file mode 100644 index 0000000..0c5c593 --- /dev/null +++ b/app/plugins/system/termux.py @@ -0,0 +1,295 @@ +import os +import subprocess +import json +from pathlib import Path + +from app import BOT, Message + + +def is_termux(): + """Check if running on Termux (Android)""" + return os.path.exists("/data/data/com.termux") or "TERMUX_VERSION" in os.environ + + +@BOT.add_cmd(cmd="termux") +async def termux_info(bot: BOT, message: Message): + """ + CMD: TERMUX + INFO: Show Termux and Android-specific information. + USAGE: .termux + """ + if not is_termux(): + await message.reply("❌ This command only works on Termux (Android)!") + return + + response = await message.reply("📱 Gathering Termux information...") + + try: + termux_info = "📱 Termux & Android Information\n\n" + + # Termux version + try: + termux_version = os.environ.get("TERMUX_VERSION", "Unknown") + termux_info += f"🤖 Termux Version: {termux_version}\n" + except: + pass + + # Android version and device info + android_props = {} + prop_commands = { + "Android Version": "ro.build.version.release", + "Android SDK": "ro.build.version.sdk", + "Device Model": "ro.product.model", + "Device Brand": "ro.product.brand", + "Device Name": "ro.product.name", + "CPU ABI": "ro.product.cpu.abi", + "Build ID": "ro.build.id", + "Build Date": "ro.build.date" + } + + for display_name, prop in prop_commands.items(): + try: + result = subprocess.run(["getprop", prop], + capture_output=True, text=True, timeout=3) + if result.returncode == 0 and result.stdout.strip(): + android_props[display_name] = result.stdout.strip() + except: + pass + + if android_props: + termux_info += f"\n📋 Android Properties:\n" + for name, value in android_props.items(): + termux_info += f"{name}: {value}\n" + + # Termux environment variables + termux_vars = {} + important_vars = [ + "TERMUX_VERSION", "PREFIX", "HOME", "TMPDIR", + "LD_LIBRARY_PATH", "PATH", "ANDROID_DATA", "ANDROID_ROOT" + ] + + for var in important_vars: + value = os.environ.get(var) + if value: + termux_vars[var] = value + + if termux_vars: + termux_info += f"\n🔧 Termux Environment:\n" + for var, value in termux_vars.items(): + # Truncate long paths + if len(value) > 50: + value = value[:47] + "..." + termux_info += f"{var}: {value}\n" + + # Package information + try: + pkg_result = subprocess.run(["pkg", "list-installed"], + capture_output=True, text=True, timeout=5) + if pkg_result.returncode == 0: + packages = pkg_result.stdout.strip().split('\n') + pkg_count = len([p for p in packages if p.strip()]) + termux_info += f"\n📦 Installed Packages: {pkg_count}\n" + except: + pass + + # Storage information + termux_info += f"\n💾 Storage Paths:\n" + + storage_paths = [ + ("$HOME", os.path.expanduser("~"), "Termux Home"), + ("$PREFIX", os.environ.get("PREFIX", "/data/data/com.termux/files/usr"), "Termux Prefix"), + ("/sdcard", "/sdcard", "Internal Storage"), + ("/storage/emulated/0", "/storage/emulated/0", "Emulated Storage") + ] + + for var_name, path, description in storage_paths: + if os.path.exists(path): + try: + import psutil + usage = psutil.disk_usage(path) + free_gb = usage.free / (1024**3) + total_gb = usage.total / (1024**3) + termux_info += f"{description}: {free_gb:.1f}GB free / {total_gb:.1f}GB total\n" + except: + termux_info += f"{description}: {path} ✅\n" + else: + termux_info += f"{description}: {path} ❌\n" + + # Permissions and capabilities + termux_info += f"\n🔐 Capabilities:\n" + + capabilities = [ + ("Storage Access", "/sdcard", "Can access internal storage"), + ("Network Access", None, "Internet connectivity"), + ("Root Access", "/system", "System directory access"), + ("Termux-API", None, "Android API access") + ] + + for cap_name, test_path, description in capabilities: + if cap_name == "Network Access": + # Test basic network + try: + import socket + socket.create_connection(("8.8.8.8", 53), timeout=3) + status = "✅" + except: + status = "❌" + elif cap_name == "Termux-API": + # Check if termux-api is available + try: + result = subprocess.run(["termux-telephony-deviceinfo"], + capture_output=True, timeout=3) + status = "✅" if result.returncode == 0 else "❌" + except: + status = "❌" + elif test_path: + status = "✅" if os.path.exists(test_path) else "❌" + else: + status = "❓" + + termux_info += f"{cap_name}: {status} {description}\n" + + # Python environment + termux_info += f"\n🐍 Python Environment:\n" + + try: + import sys + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + termux_info += f"Python Version: {python_version}\n" + termux_info += f"Python Path: {sys.executable}\n" + + # Check important modules + modules_to_check = ["psutil", "requests", "pyrogram", "qrcode"] + available_modules = [] + + for module in modules_to_check: + try: + __import__(module) + available_modules.append(f"✅ {module}") + except ImportError: + available_modules.append(f"❌ {module}") + + termux_info += f"Key Modules: {', '.join(available_modules)}\n" + + except Exception as e: + termux_info += f"Python: Error getting info\n" + + termux_info += f"\n📱 Termux information collected successfully!" + + await response.edit(termux_info) + + except Exception as e: + await response.edit(f"❌ Error getting Termux info:\n{str(e)}") + + +@BOT.add_cmd(cmd="androidinfo") +async def android_device_info(bot: BOT, message: Message): + """ + CMD: ANDROIDINFO + INFO: Get detailed Android device information. + USAGE: .androidinfo + """ + if not is_termux(): + await message.reply("❌ This command only works on Termux (Android)!") + return + + response = await message.reply("📱 Getting Android device info...") + + try: + device_info = "📱 Android Device Information\n\n" + + # Comprehensive device properties + device_props = { + "Device Information": [ + ("ro.product.brand", "Brand"), + ("ro.product.manufacturer", "Manufacturer"), + ("ro.product.model", "Model"), + ("ro.product.name", "Product Name"), + ("ro.product.device", "Device Codename") + ], + "Android System": [ + ("ro.build.version.release", "Android Version"), + ("ro.build.version.sdk", "SDK Level"), + ("ro.build.version.security_patch", "Security Patch"), + ("ro.build.id", "Build ID"), + ("ro.build.type", "Build Type"), + ("ro.build.tags", "Build Tags") + ], + "Hardware": [ + ("ro.product.cpu.abi", "CPU Architecture"), + ("ro.product.cpu.abilist", "Supported ABIs"), + ("ro.board.platform", "Platform"), + ("ro.hardware", "Hardware"), + ("ro.revision", "Hardware Revision") + ], + "Display": [ + ("ro.sf.lcd_density", "LCD Density"), + ("ro.config.small_battery", "Small Battery"), + ("ro.config.low_ram", "Low RAM Device") + ] + } + + for category, props in device_props.items(): + device_info += f"📋 {category}:\n" + + for prop, display_name in props: + try: + result = subprocess.run(["getprop", prop], + capture_output=True, text=True, timeout=3) + if result.returncode == 0 and result.stdout.strip(): + value = result.stdout.strip() + # Truncate very long values + if len(value) > 40: + value = value[:37] + "..." + device_info += f" {display_name}: {value}\n" + except: + pass + + device_info += "\n" + + # Memory information (from /proc/meminfo if accessible) + try: + if os.path.exists("/proc/meminfo"): + with open("/proc/meminfo", "r") as f: + meminfo = f.read() + + for line in meminfo.split('\n')[:3]: # First 3 lines usually contain key info + if line.strip(): + device_info += f"💾 {line.strip()}\n" + device_info += "\n" + except: + pass + + # Battery information (if termux-api is available) + try: + result = subprocess.run(["termux-battery-status"], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + battery_data = json.loads(result.stdout) + device_info += f"🔋 Battery Information:\n" + device_info += f" Level: {battery_data.get('percentage', 'Unknown')}%\n" + device_info += f" Status: {battery_data.get('status', 'Unknown')}\n" + device_info += f" Health: {battery_data.get('health', 'Unknown')}\n" + device_info += f" Temperature: {battery_data.get('temperature', 'Unknown')}°C\n\n" + except: + pass + + # Network information + try: + result = subprocess.run(["termux-wifi-connectioninfo"], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + wifi_data = json.loads(result.stdout) + device_info += f"📶 WiFi Information:\n" + device_info += f" SSID: {wifi_data.get('ssid', 'Unknown')}\n" + device_info += f" IP Address: {wifi_data.get('ip', 'Unknown')}\n" + device_info += f" Signal: {wifi_data.get('rssi', 'Unknown')} dBm\n\n" + except: + pass + + device_info += f"📱 Device information collected via Termux API and getprop" + + await response.edit(device_info) + + except Exception as e: + await response.edit(f"❌ Error getting Android info:\n{str(e)}") \ No newline at end of file diff --git a/app/plugins/tg_tools/chat.py b/app/plugins/tg_tools/chat.py new file mode 100644 index 0000000..f04421d --- /dev/null +++ b/app/plugins/tg_tools/chat.py @@ -0,0 +1,61 @@ +import asyncio +import os + +from pyrogram.errors import BadRequest +from ub_core.utils import get_name + +from app import BOT, Message + + +@BOT.add_cmd(cmd="ids") +async def get_ids(bot: BOT, message: Message) -> None: + reply: Message = message.replied + if reply: + resp_str: str = "" + + reply_user, reply_forward = reply.forward_from_chat, reply.from_user + + resp_str += f"{get_name(reply.chat)}: {reply.chat.id}\n" + + if reply_forward: + resp_str += f"{get_name(reply_forward)}: {reply_forward.id}\n" + + if reply_user: + resp_str += f"{get_name(reply_user)}: {reply_user.id}" + elif message.input: + resp_str: int = (await bot.get_chat(message.input[1:])).id + else: + resp_str: str = f"{get_name(message.chat)}: {message.chat.id}" + await message.reply(resp_str) + + +@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()) + except Exception as e: + await message.reply(str(e)) + return + await message.reply("Joined") + + +@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( + text=f"Leaving current chat in 5\nReply with `{message.trigger}c` to cancel", + del_in=5, + block=True, + ) + await asyncio.sleep(5) + try: + await bot.leave_chat(chat) + except Exception as e: + await message.reply(str(e)) diff --git a/app/plugins/tg_tools/click.py b/app/plugins/tg_tools/click.py new file mode 100644 index 0000000..f43ea9b --- /dev/null +++ b/app/plugins/tg_tools/click.py @@ -0,0 +1,14 @@ +from app import BOT, Message + + +@BOT.add_cmd(cmd="click") +async def click(bot: BOT, message: Message): + if not message.input or not message.replied: + await message.reply("reply to a message containing a button and give a button to click") + return + try: + button_name = message.input.strip() + button = int(button_name) if button_name.isdigit() else button_name + await message.replied.click(button) + except Exception as e: + await message.reply(str(e), del_in=5) diff --git a/app/plugins/tg_tools/delete.py b/app/plugins/tg_tools/delete.py new file mode 100644 index 0000000..4bdaa90 --- /dev/null +++ b/app/plugins/tg_tools/delete.py @@ -0,0 +1,77 @@ +import asyncio + +from pyrogram.enums import ChatType +from ub_core.utils.helpers import create_chunks + +from app import BOT, Message +from app.plugins.tg_tools.get_message import parse_link + + +@BOT.add_cmd(cmd="del") +async def delete_message(bot: BOT, message: Message) -> None: + """ + CMD: DEL + INFO: Delete the replied message. + FLAGS: -r to remotely delete a text using its link. + USAGE: + .del | .del -r t.me/...... + """ + if "-r" in message.flags: + chat_id, _, message_id = parse_link(message.filtered_input) + await bot.delete_messages(chat_id=chat_id, message_ids=message_id, revoke=True) + return + await message.delete(reply=True) + + +@BOT.add_cmd(cmd="purge") +async def purge_(bot: BOT, message: Message) -> None: + """ + CMD: PURGE + INFO: DELETE MULTIPLE MESSAGES + USAGE: + .purge [reply to message] + """ + chat_id = message.chat.id + + start_message: int = message.reply_id + + # Not replied to a message + if not start_message: + await message.reply("Reply to a message.") + return + + # Replied was topic creation message + if message.thread_origin_message: + await message.reply("Reply to a message.") + return + + # Get Topic messages till replied + if message.is_topic_message: + message_ids = [] + + async for _message in bot.get_discussion_replies( + chat_id=message.chat.id, message_id=message.message_thread_id, limit=100 + ): + message_ids.append(_message.id) + if _message.id == message.reply_id or len(message_ids) > 100: + break + else: + # Generate Message Ids + message_ids: list[int] = list(range(start_message, message.id)) + + # Get messages from server if chat is private or ids are too big. + if message.chat.type in {ChatType.PRIVATE, ChatType.BOT} or len(message_ids) > 100: + messages = await bot.get_messages(chat_id=chat_id, message_ids=message_ids, replies=0) + message_ids = [message.id for message in messages] + + # Perform Quick purge of bigger chunks + if len(message_ids) < 100: + chunk_size = 50 + sleep_interval = 2 + else: + chunk_size = 25 + sleep_interval = 5 + + for chunk in create_chunks(message_ids, chunk_size=chunk_size): + await bot.delete_messages(chat_id=chat_id, message_ids=chunk, revoke=True) + await asyncio.sleep(sleep_interval) diff --git a/app/plugins/tg_tools/get_message.py b/app/plugins/tg_tools/get_message.py new file mode 100644 index 0000000..731b757 --- /dev/null +++ b/app/plugins/tg_tools/get_message.py @@ -0,0 +1,50 @@ +from urllib.parse import urlparse + +from app import BOT, Message + + +def parse_link(link: str) -> tuple[int | str, int, int]: + parsed_url: str = urlparse(link).path.strip("/") + link_chunks = parsed_url.lstrip("c/").split("/") + + thread = "0" + if len(link_chunks) == 3: + chat, thread, message = link_chunks + else: + chat, message = link_chunks + + if chat.isdigit(): + chat = int(f"-100{chat}") + + if thread.isdigit(): + thread = int(thread) + + return chat, thread, int(message) + + +@BOT.add_cmd(cmd="gm") +async def get_message(bot: BOT, message: Message): + """ + CMD: Get Message + INFO: Get a Message Json/Attr by providing link. + USAGE: + .gm t.me/.... | .gm t.me/... text [Returns message text] + """ + if not message.input: + await message.reply("Give a Message link.") + return + + attr = None + + if len(message.text_list) == 3: + link, attr = message.text_list[1:] + else: + link = message.input.strip() + + remote_message = Message(await bot.get_messages(link=link)) + + if not attr: + await message.reply(f"```\n{remote_message}```") + return + + await message.reply(f"```\n{getattr(remote_message, attr, None)}```") diff --git a/app/plugins/tg_tools/kang.py b/app/plugins/tg_tools/kang.py new file mode 100644 index 0000000..08f873c --- /dev/null +++ b/app/plugins/tg_tools/kang.py @@ -0,0 +1,228 @@ +import asyncio +import os +import random +import shutil +import time +from io import BytesIO +from pathlib import Path + +from PIL import Image +from pyrogram.enums import MessageMediaType +from pyrogram.errors import StickersetInvalid +from pyrogram.raw import functions +from pyrogram.raw import types as raw_types +from pyrogram.raw.base.messages import StickerSet as BaseStickerSet +from pyrogram.types import User +from pyrogram.utils import FileId +from ub_core import utils as core_utils + +from app import BOT, Config, Message, bot, extra_config + +EMOJIS = ("☕", "🤡", "🙂", "🤔", "🔪", "😂", "💀") + + +async def save_sticker(file: Path | BytesIO) -> str: + client = getattr(bot, "bot", bot) + + sent_file = await client.send_document( + chat_id=Config.LOG_CHAT, document=file, message_thread_id=Config.LOG_CHAT_THREAD_ID + ) + + if isinstance(file, Path) and file.is_file(): + shutil.rmtree(file.parent, ignore_errors=True) + + return sent_file.document.file_id + + +def resize_photo(input_file: BytesIO) -> BytesIO: + image = Image.open(input_file) + maxsize = 512 + scale = maxsize / max(image.width, image.height) + new_size = (int(image.width * scale), int(image.height * scale)) + image = image.resize(new_size, Image.LANCZOS) + resized_photo = BytesIO() + resized_photo.name = "sticker.png" + image.save(resized_photo, format="PNG") + return resized_photo + + +async def photo_kang(message: Message, **_) -> tuple[str, None]: + file = await message.download(in_memory=True) + file.seek(0) + resized_file = await asyncio.to_thread(resize_photo, file) + return await save_sticker(resized_file), None + + +async def video_kang(message: Message, ff=False) -> tuple[str, None]: + video = message.video or message.animation or message.document + + if video.file_size > 5242880: + raise MemoryError("File Size exceeds 5MB.") + + download_path = Path("downloads") / str(time.time()) + input_file = download_path / "input.mp4" + output_file = download_path / "sticker.webm" + + download_path.mkdir(parents=True, exist_ok=True) + + await message.download(str(input_file)) + + duration = getattr(video, "duration", None) + if not duration: + duration = await core_utils.get_duration(file=str(input_file)) + + await resize_video(input_file=input_file, output_file=output_file, duration=duration, ff=ff) + + return await save_sticker(output_file), None + + +async def resize_video( + input_file: Path | str, output_file: Path | str, duration: int, ff: bool = False +): + cmd = f"ffmpeg -hide_banner -loglevel error -i '{input_file}' -vf " + if ff: + cmd += '"scale=w=512:h=512:force_original_aspect_ratio=decrease,setpts=0.3*PTS" ' + cmd += "-ss 0 -t 3 -r 30 -loop 0 -an -c:v libvpx-vp9 -b:v 256k -fs 256k " + elif duration < 3: + cmd += '"scale=w=512:h=512:force_original_aspect_ratio=decrease" ' + cmd += "-ss 0 -r 30 -an -c:v libvpx-vp9 -b:v 256k -fs 256k " + else: + cmd += '"scale=w=512:h=512:force_original_aspect_ratio=decrease" ' + cmd += "-ss 0 -t 3 -r 30 -an -c:v libvpx-vp9 -b:v 256k -fs 256k " + await core_utils.run_shell_cmd(cmd=f"{cmd}'{output_file}'") + + +async def document_kang(message: Message, ff: bool = False) -> tuple[str, None]: + name, ext = os.path.splitext(core_utils.get_tg_media_details(message).file_name) + if ext.lower() in core_utils.MediaExts.PHOTO: + return await photo_kang(message) + elif ext.lower() in {*core_utils.MediaExts.VIDEO, *core_utils.MediaExts.GIF}: + return await video_kang(message=message, ff=ff) + + +async def sticker_kang(message: Message, **_) -> tuple[str, str]: + sticker = message.sticker + if sticker.is_animated: + raise TypeError("Animated Stickers Not Supported.") + return sticker.file_id, sticker.emoji + + +MEDIA_TYPE_MAP = { + MessageMediaType.PHOTO: photo_kang, + MessageMediaType.VIDEO: video_kang, + MessageMediaType.ANIMATION: video_kang, + MessageMediaType.DOCUMENT: document_kang, + MessageMediaType.STICKER: sticker_kang, +} + + +async def get_sticker_set( + client: BOT, user: User +) -> tuple[str, str, bool, raw_types.StickerSet | None]: + count = 0 + create_new = False + suffix = f"_by_{client.me.username}" if client.is_bot else "" + + while True: + shortname = f"P_UB_{user.id}_mixpack_{count}{suffix}" + try: + sticker_set: BaseStickerSet = await client.invoke( + functions.messages.GetStickerSet( + stickerset=raw_types.InputStickerSetShortName(short_name=shortname), + hash=0, + ) + ) + sticker_set = sticker_set.set + if sticker_set.count < 120: + break + count += 1 + except StickersetInvalid: + create_new = True + sticker_set: BaseStickerSet | None = None + break + + if extra_config.CUSTOM_PACK_NAME: + pack_title = extra_config.CUSTOM_PACK_NAME + else: + pack_title = f"{user.username or core_utils.get_name(user)}'s kang pack vol {count}" + + return shortname, pack_title, create_new, sticker_set + + +async def kang_sticker( + client: BOT, media_file_id: str, emoji: str = None, user: User = None +) -> BaseStickerSet: + shortname, pack_title, create_new, sticker_set = await get_sticker_set(client, user) + + file_id = FileId.decode(media_file_id) + + document = raw_types.InputDocument( + access_hash=file_id.access_hash, + id=file_id.media_id, + file_reference=file_id.file_reference, + ) + + set_item = raw_types.InputStickerSetItem( + document=document, emoji=emoji or random.choice(EMOJIS) + ) + + if create_new: + query = functions.stickers.CreateStickerSet( + user_id=await bot.resolve_peer(peer_id=user.id), + short_name=shortname, + title=pack_title, + stickers=[set_item], + ) + else: + query = functions.stickers.AddStickerToSet( + stickerset=raw_types.InputStickerSetID( + id=sticker_set.id, access_hash=sticker_set.access_hash + ), + sticker=set_item, + ) + + return await client.invoke(query) + + +async def kang(bot: BOT, message: Message): + """ + CMD: KANG + INFO: Save a sticker/image/gif/video to your sticker pack. + FLAGS: -f to fastforward video tp fit 3 sec duration. + USAGE: .kang | .kang -f + + Diffrences to legacy version: + • Is almost instantaneous because uses built-in methods. + • Sudo users get their own packs. + • If in dual mode pack ownership is given to respective Sudo users. + • Kangs both photo and video stickers into a single pack. + • As a result video stickers are not limited to the limit of 50. + + Note: if you still would like to use old style set USE_LEGACY_KANG=1 + """ + replied = message.replied + + media_func = MEDIA_TYPE_MAP.get(replied.media) + + if not media_func: + await message.reply("Unsupported Media...") + return + + response = await message.reply("Processing...") + + bot = getattr(bot, "bot", bot) + + file_id, emoji = await media_func(message=replied, ff="-f" in message.flags) + + try: + stickers = await kang_sticker(bot, file_id, emoji, user=message.from_user) + await response.edit( + f"Kanged: here", + disable_preview=True, + ) + except Exception as e: + await response.edit(str(e)) + + +if not extra_config.USE_LEGACY_KANG: + BOT.add_cmd("kang")(kang) diff --git a/app/plugins/tg_tools/legacy_kang.py b/app/plugins/tg_tools/legacy_kang.py new file mode 100644 index 0000000..7e2cf7b --- /dev/null +++ b/app/plugins/tg_tools/legacy_kang.py @@ -0,0 +1,216 @@ +import asyncio +import os +import random +import shutil +import time +from io import BytesIO + +from PIL import Image +from pyrogram import raw +from pyrogram.enums import MessageMediaType +from pyrogram.errors import StickersetInvalid +from ub_core import utils as core_utils + +from app import BOT, Message, bot, extra_config + +EMOJIS = ("☕", "🤡", "🙂", "🤔", "🔪", "😂", "💀") + + +async def get_sticker_set(limit: int, is_video=False) -> tuple[str, str, bool]: + count = 0 + pack_name = f"PUB_{bot.me.id}_pack" + video = "_video" if is_video else "" + create_new = False + while True: + try: + sticker = await bot.invoke( + raw.functions.messages.GetStickerSet( + stickerset=raw.types.InputStickerSetShortName( + short_name=f"{pack_name}{video}_{count}" + ), + hash=0, + ) + ) + if sticker.set.count < limit: + break + count += 1 + except StickersetInvalid: + create_new = True + break + if cus_nick := os.environ.get("CUSTOM_PACK_NAME"): + pack_title = cus_nick + video + else: + pack_title = ( + f"{bot.me.username or core_utils.get_name(bot.me)}'s {video}kang pack vol {count}" + ) + return pack_title, f"{pack_name}{video}_{count}", create_new + + +async def photo_kang(message: Message, **_) -> dict: + download_path = os.path.join("downloads", str(time.time())) + os.makedirs(download_path, exist_ok=True) + + input_file = os.path.join(download_path, "photo.jpg") + await message.download(input_file) + + file = await asyncio.to_thread(resize_photo, input_file) + + return dict(cmd="/newpack", limit=120, is_video=False, file=file, path=download_path) + + +def resize_photo(input_file: str) -> BytesIO: + image = Image.open(input_file) + maxsize = 512 + scale = maxsize / max(image.width, image.height) + new_size = (int(image.width * scale), int(image.height * scale)) + image = image.resize(new_size, Image.LANCZOS) + resized_photo = BytesIO() + resized_photo.name = "sticker.png" + image.save(resized_photo, format="PNG") + return resized_photo + + +async def video_kang(message: Message, ff=False) -> dict: + video = message.video or message.animation or message.document + if video.file_size > 5242880: + raise MemoryError("File Size exceeds 5MB.") + + download_path = os.path.join("downloads", f"{time.time()}") + os.makedirs(download_path, exist_ok=True) + + input_file = os.path.join(download_path, "input.mp4") + output_file = os.path.join(download_path, "sticker.webm") + + await message.download(input_file) + + if not hasattr(video, "duration"): + duration = await core_utils.get_duration(file=input_file) + else: + duration = video.duration + await resize_video(input_file=input_file, output_file=output_file, duration=duration, ff=ff) + return dict(cmd="/newvideo", limit=50, is_video=True, file=output_file, path=download_path) + + +async def resize_video(input_file: str, output_file: str, duration: int, ff: bool = False): + cmd = f"ffmpeg -hide_banner -loglevel error -i '{input_file}' -vf " + if ff: + cmd += '"scale=w=512:h=512:force_original_aspect_ratio=decrease,setpts=0.3*PTS" ' + cmd += "-ss 0 -t 3 -r 30 -loop 0 -an -c:v libvpx-vp9 -b:v 256k -fs 256k " + elif duration < 3: + cmd += '"scale=w=512:h=512:force_original_aspect_ratio=decrease" ' + cmd += "-ss 0 -r 30 -an -c:v libvpx-vp9 -b:v 256k -fs 256k " + else: + cmd += '"scale=w=512:h=512:force_original_aspect_ratio=decrease" ' + cmd += "-ss 0 -t 3 -r 30 -an -c:v libvpx-vp9 -b:v 256k -fs 256k " + await core_utils.run_shell_cmd(cmd=f"{cmd}'{output_file}'") + + +async def document_kang(message: Message, ff: bool = False) -> dict: + name, ext = os.path.splitext(message.document.file_name) + if ext.lower() in core_utils.MediaExts.PHOTO: + return await photo_kang(message) + elif ext.lower() in {*core_utils.MediaExts.VIDEO, *core_utils.MediaExts.GIF}: + return await video_kang(message=message, ff=ff) + + +async def sticker_kang(message: Message, **_) -> dict: + emoji = message.sticker.emoji + sticker = message.sticker + + if sticker.is_animated: + raise TypeError("Animated Stickers Not Supported.") + + if sticker.is_video: + input_file: BytesIO = await message.download(in_memory=True) + input_file.seek(0) + return dict(cmd="/newvideo", emoji=emoji, is_video=True, file=input_file, limit=50) + + return dict(cmd="/newpack", emoji=emoji, is_video=False, sticker=sticker, limit=120) + + +MEDIA_TYPE_MAP = { + MessageMediaType.PHOTO: photo_kang, + MessageMediaType.VIDEO: video_kang, + MessageMediaType.ANIMATION: video_kang, + MessageMediaType.DOCUMENT: document_kang, + MessageMediaType.STICKER: sticker_kang, +} + + +async def create_n_kang(kwargs: dict, pack_title: str, pack_name: str, message: Message): + async with bot.Convo(client=bot, chat_id="stickers", timeout=5) as convo: + await convo.send_message(text=kwargs["cmd"], get_response=True) + await convo.send_message(text=pack_title, get_response=True) + + if kwargs.get("sticker"): + await message.reply_to_message.copy(chat_id="stickers", caption="") + await convo.get_response() + else: + await convo.send_document(document=kwargs["file"], get_response=True) + + await convo.send_message( + text=kwargs.get("emoji") or random.choice(EMOJIS), get_response=True + ) + await convo.send_message(text="/publish", get_response=True) + await convo.send_message("/skip") + await convo.send_message(pack_name, get_response=True) + + if kwargs.get("path"): + shutil.rmtree(kwargs["path"], ignore_errors=True) + + +async def kang_sticker(bot: BOT, message: Message): + """ + CMD: LEGACY KANG + INFO: Save a sticker/image/gif/video to your sticker pack. + FLAGS: -f to fastforward video tp fit 3 sec duration. + USAGE: .kang | .kang -f + """ + replied = message.replied + + media_func = MEDIA_TYPE_MAP.get(replied.media) + + if not media_func: + await message.reply("Unsupported Media.") + return + + response: Message = await message.reply("Processing...") + + kwargs: dict = await media_func(message=replied, ff="-f" in message.flags) + + pack_title, pack_name, create_new = await get_sticker_set( + limit=kwargs["limit"], is_video=kwargs["is_video"] + ) + + if create_new: + await create_n_kang( + kwargs=kwargs, pack_title=pack_title, pack_name=pack_name, message=message + ) + await response.edit(text=f"Kanged: here") + return + + async with bot.Convo(client=bot, chat_id="stickers", timeout=5) as convo: + await convo.send_message(text="/addsticker", get_response=True) + await convo.send_message(text=pack_name, get_response=True) + + if kwargs.get("sticker"): + await replied.copy(chat_id="stickers", caption="") + await convo.get_response() + else: + await convo.send_document(document=kwargs["file"], get_response=True) + + await convo.send_message( + text=kwargs.get("emoji") or random.choice(EMOJIS), get_response=True + ) + await convo.send_message(text="/done", get_response=True) + + if kwargs.get("path"): + shutil.rmtree(kwargs["path"], ignore_errors=True) + + await response.edit( + text=f"Kanged: here", disable_preview=True + ) + + +if extra_config.USE_LEGACY_KANG: + BOT.add_cmd("kang")(kang_sticker) diff --git a/app/plugins/tg_tools/ping.py b/app/plugins/tg_tools/ping.py new file mode 100644 index 0000000..91c95ef --- /dev/null +++ b/app/plugins/tg_tools/ping.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from app import BOT, Message + + +# Not my Code +# Prolly from Userge/UX/VenomX IDK +@BOT.add_cmd(cmd="ping") +async def ping_bot(bot: BOT, message: Message): + start = datetime.now() + resp: Message = await message.reply("Checking Ping.....") + end = (datetime.now() - start).microseconds / 1000 + await resp.edit(f"Pong! {end} ms.") diff --git a/app/plugins/tg_tools/pm_n_tag_logger.py b/app/plugins/tg_tools/pm_n_tag_logger.py new file mode 100644 index 0000000..b00a07b --- /dev/null +++ b/app/plugins/tg_tools/pm_n_tag_logger.py @@ -0,0 +1,223 @@ +import asyncio +from collections import defaultdict + +from pyrogram import filters +from pyrogram.enums import ChatType, MessageEntityType, ParseMode +from pyrogram.errors import MessageIdInvalid +from ub_core.utils.helpers import get_name + +from app import BOT, Config, CustomDB, Message, bot, extra_config + +LOGGER = CustomDB["COMMON_SETTINGS"] + +MESSAGE_CACHE: dict[int, list[Message]] = defaultdict(list) +FLOOD_LIST: list[int] = [] + + +async def init_task(): + tag_check = await LOGGER.find_one({"_id": "tag_logger_switch"}) + pm_check = await LOGGER.find_one({"_id": "pm_logger_switch"}) + if tag_check: + extra_config.TAG_LOGGER = tag_check["value"] + if pm_check: + extra_config.PM_LOGGER = pm_check["value"] + Config.BACKGROUND_TASKS.append(asyncio.create_task(runner(), name="pm_tag_logger")) + + +@bot.add_cmd(cmd=["taglogger", "pmlogger"]) +async def logger_switch(bot: BOT, message: Message): + """ + CMD: TAGLOGGER | PMLOGGER + INFO: Enable/Disable PM or Tag Logger. + FLAGS: -c to check status. + """ + text = "pm" if message.cmd == "pmlogger" else "tag" + conf_str = f"{text.upper()}_LOGGER" + + if "-c" in message.flags: + await message.reply( + text=f"{text.capitalize()} Logger is enabled: {getattr(extra_config, conf_str)}!", + del_in=8, + ) + return + + value: bool = not getattr(extra_config, conf_str) + setattr(extra_config, conf_str, value) + + await asyncio.gather( + LOGGER.add_data({"_id": f"{text}_logger_switch", "value": value}), + message.reply(text=f"{text.capitalize()} Logger is enabled: {value}!", del_in=8), + bot.log_text(text=f"#{text.capitalize()}Logger is enabled: {value}!", type="info"), + ) + + for task in Config.BACKGROUND_TASKS: + if task.get_name() == "pm_tag_logger" and task.done(): + Config.BACKGROUND_TASKS.append(asyncio.create_task(runner(), name="pm_tag_logger")) + + +BASIC_FILTERS = ( + ~filters.channel + & ~filters.bot + & ~filters.service + & ~filters.chat(chats=[bot.me.id]) + & ~filters.me + & ~filters.create(lambda _, __, m: m.chat.is_support) +) + + +@bot.on_message( + filters=BASIC_FILTERS + & filters.private + & filters.create(lambda _, __, ___: extra_config.PM_LOGGER), +) +async def pm_logger(bot: BOT, message: Message): + cache_message(message) + + +TAG_FILTER = filters.create(lambda _, __, ___: extra_config.TAG_LOGGER) + + +@bot.on_message( + filters=(BASIC_FILTERS & filters.reply & TAG_FILTER) & ~filters.private, +) +async def reply_logger(bot: BOT, message: Message): + if ( + message.reply_to_message + and message.reply_to_message.from_user + and message.reply_to_message.from_user.id == bot.me.id + ): + cache_message(message) + message.continue_propagation() + + +@bot.on_message( + filters=(BASIC_FILTERS & filters.mentioned & TAG_FILTER) & ~filters.private, +) +async def mention_logger(bot: BOT, message: Message): + for entity in message.entities or []: + if entity.type == MessageEntityType.MENTION and entity.user and entity.user.id == bot.me.id: + cache_message(message) + message.continue_propagation() + + +@bot.on_message( + filters=(BASIC_FILTERS & (filters.text | filters.media) & TAG_FILTER) & ~filters.private, +) +async def username_logger(bot: BOT, message: Message): + text = message.text or message.caption or "" + if bot.me.username and f"@{bot.me.username}" in text: + cache_message(message) + message.continue_propagation() + + +def cache_message(message: Message): + chat_id = message.chat.id + if len(MESSAGE_CACHE[chat_id]) >= 10 and chat_id not in FLOOD_LIST: + bot.log.error(f"Message not Logged from chat: {get_name(message.chat)}") + FLOOD_LIST.append(chat_id) + return + if chat_id in FLOOD_LIST: + FLOOD_LIST.remove(chat_id) + MESSAGE_CACHE[chat_id].append(message) + + +async def runner(): + if not (extra_config.TAG_LOGGER or extra_config.PM_LOGGER): + return + last_pm_logged_id = 0 + + while True: + cached_keys = list(MESSAGE_CACHE.keys()) + if not cached_keys: + await asyncio.sleep(5) + continue + + first_key = cached_keys[0] + cached_list = MESSAGE_CACHE.copy()[first_key] + if not cached_list: + MESSAGE_CACHE.pop(first_key) + + for idx, msg in enumerate(cached_list): + if msg.chat.type == ChatType.PRIVATE: + + if last_pm_logged_id != first_key: + last_pm_logged_id = first_key + log_info = True + else: + log_info = False + + coro = log_pm(message=msg, log_info=log_info) + + else: + coro = log_chat(message=msg) + + try: + await coro + except BaseException: + pass + + MESSAGE_CACHE[first_key].remove(msg) + await asyncio.sleep(5) + + await asyncio.sleep(15) + + +async def log_pm(message: Message, log_info: bool): + if log_info: + await bot.send_message( + chat_id=extra_config.MESSAGE_LOGGER_CHAT, + text=f"#PM\n{message.from_user.mention} [{message.from_user.id}]", + message_thread_id=extra_config.PM_LOGGER_THREAD_ID, + ) + notice = ( + f"{message.from_user.mention} [{message.from_user.id}] deleted this message." + f"\n\n---\n\n" + f"Message: \n{message.chat.title or message.chat.first_name} ({message.chat.id})" + f"\n\n---\n\n" + f"Caption:\n{message.caption or 'No Caption in media.'}" + ) + await log_message(message=message, notice=notice, thread_id=extra_config.PM_LOGGER_THREAD_ID) + + +async def log_chat(message: Message): + if message.sender_chat: + mention, u_id = message.sender_chat.title, message.sender_chat.id + else: + mention, u_id = message.from_user.mention, message.from_user.id + notice = ( + f"{mention} [{u_id}] deleted this message." + f"\n\n---\n\n" + f"Message: \n{message.chat.title or message.chat.first_name} ({message.chat.id})" + f"\n\n---\n\n" + f"Caption:\n{message.caption or 'No Caption in media.'}" + ) + + if message.reply_to_message: + await log_message(message.reply_to_message, thread_id=extra_config.TAG_LOGGER_THREAD_ID) + + await log_message( + message=message, + notice=notice, + extra_info=f"#TAG\n{mention} [{u_id}]\nMessage: \n{message.chat.title} ({message.chat.id})", + thread_id=extra_config.TAG_LOGGER_THREAD_ID, + ) + + +async def log_message( + message: Message, + notice: str | None = None, + extra_info: str | None = None, + thread_id: int = None, +): + try: + logged_message: Message = await message.forward( + extra_config.MESSAGE_LOGGER_CHAT, message_thread_id=thread_id + ) + if extra_info: + await logged_message.reply(extra_info, parse_mode=ParseMode.HTML) + except MessageIdInvalid: + logged_message = await message.copy( + extra_config.MESSAGE_LOGGER_CHAT, message_thread_id=thread_id + ) + if notice: + await logged_message.reply(notice, parse_mode=ParseMode.HTML) diff --git a/app/plugins/tg_tools/pm_permit.py b/app/plugins/tg_tools/pm_permit.py new file mode 100644 index 0000000..aed5efb --- /dev/null +++ b/app/plugins/tg_tools/pm_permit.py @@ -0,0 +1,153 @@ +import asyncio +from collections import defaultdict + +from pyrogram import filters +from pyrogram.enums import ChatType +from ub_core.utils.helpers import get_name + +from app import BOT, CustomDB, Message, bot, extra_config + +PM_USERS = CustomDB["PM_USERS"] +PM_GUARD = CustomDB["COMMON_SETTINGS"] + +ALLOWED_USERS: list[int] = [] +RECENT_USERS: dict = defaultdict(int) + + +async def init_task(): + guard = (await PM_GUARD.find_one({"_id": "guard_switch"})) or {} + extra_config.PM_GUARD = guard.get("value", False) + [ALLOWED_USERS.append(user_id["_id"]) async for user_id in PM_USERS.find()] + + +async def pm_permit_filter(_, __, message: Message): + # Return False if: + if ( + # PM_GUARD is False + not extra_config.PM_GUARD + # Chat is not Private + or message.chat.type != ChatType.PRIVATE + # Chat is already approved + or message.chat.id in ALLOWED_USERS + # Saved Messages + or message.chat.id == bot.me.id + # PM is BOT + or message.from_user.is_bot + # Telegram Service Messages like OTPs. + or message.from_user.is_support + # Chat Service Messages like pinned a pic etc + or message.service + ): + return False + return True + + +PERMIT_FILTER = filters.create(pm_permit_filter) + + +@bot.on_message(PERMIT_FILTER & filters.incoming, group=0) +async def handle_new_pm(bot: BOT, message: Message): + user_id = message.from_user.id + if RECENT_USERS[user_id] == 0: + await bot.log_text( + text=f"#PMGUARD\n{message.from_user.mention} [{user_id}] has messaged you.", type="info" + ) + RECENT_USERS[user_id] += 1 + + if message.chat.is_support: + return + + if RECENT_USERS[user_id] >= 5: + await message.reply("You've been blocked for spamming.") + await bot.block_user(user_id) + RECENT_USERS.pop(user_id) + await bot.log_text( + text=f"#PMGUARD\n{message.from_user.mention} [{user_id}] has been blocked for spamming.", + type="info", + ) + return + if RECENT_USERS[user_id] % 2: + await message.reply("You are not authorised to PM.") + + +@bot.on_message(PERMIT_FILTER & filters.outgoing, group=2) +async def auto_approve(bot: BOT, message: Message): + message = Message(message=message) + ALLOWED_USERS.append(message.chat.id) + await asyncio.gather( + PM_USERS.insert_one({"_id": message.chat.id}), + message.reply(text="Auto-Approved to PM.", del_in=5), + ) + + +@bot.add_cmd(cmd="pmguard") +async def pm_guard(bot: BOT, message: Message): + """ + CMD: PMGUARD + INFO: Enable/Disable PM GUARD. + FLAGS: -c to check guard status. + USAGE: + .pmguard | .pmguard -c + """ + if "-c" in message.flags: + await message.reply(text=f"PM Guard is enabled: {extra_config.PM_GUARD}", del_in=8) + return + value = not extra_config.PM_GUARD + extra_config.PM_GUARD = value + await asyncio.gather( + PM_GUARD.add_data({"_id": "guard_switch", "value": value}), + message.reply(text=f"PM Guard is enabled: {value}!", del_in=8), + ) + + +@bot.add_cmd(cmd=["a", "allow"]) +async def allow_pm(bot: BOT, message: Message): + """ + CMD: A | ALLOW + INFO: Approve a User to PM. + USAGE: .a|.allow [reply to a user or in pm] + """ + user_id, name = get_userID_name(message) + if not user_id: + await message.reply( + "Unable to extract User to allow.\nGive user id | Reply to a user | use in PM." + ) + return + if user_id in ALLOWED_USERS: + await message.reply(f"{name} is already approved.") + return + ALLOWED_USERS.append(user_id) + RECENT_USERS.pop(user_id, 0) + await asyncio.gather( + message.reply(text=f"{name} allowed to PM.", del_in=8), + PM_USERS.insert_one({"_id": user_id}), + ) + + +@bot.add_cmd(cmd="nopm") +async def no_pm(bot: BOT, message: Message): + user_id, name = get_userID_name(message) + if not user_id: + await message.reply( + "Unable to extract User to Dis-allow.\nGive user id | Reply to a user | use in PM." + ) + return + if user_id not in ALLOWED_USERS: + await message.reply(f"{name} is not approved to PM.") + return + ALLOWED_USERS.remove(user_id) + await asyncio.gather( + message.reply(text=f"{name} Dis-allowed to PM.", del_in=8), PM_USERS.delete_data(user_id) + ) + + +def get_userID_name(message: Message) -> tuple: + if message.filtered_input and message.filtered_input.isdigit(): + user_id = int(message.filtered_input) + return user_id, user_id + elif message.replied: + return message.replied.from_user.id, get_name(message.replied.from_user) + elif message.chat.type == ChatType.PRIVATE: + return message.chat.id, get_name(message.chat) + else: + return 0, 0 diff --git a/app/plugins/tg_tools/reply.py b/app/plugins/tg_tools/reply.py new file mode 100644 index 0000000..c5ced57 --- /dev/null +++ b/app/plugins/tg_tools/reply.py @@ -0,0 +1,33 @@ +from app import BOT, Message +from app.plugins.tg_tools.get_message import parse_link + + +@BOT.add_cmd(cmd="reply") +async def reply(bot: BOT, message: Message) -> None: + """ + CMD: REPLY + INFO: Reply to a Message. + FLAGS: + -r: reply remotely using message link. + USAGE: + .reply HI | .reply -r t.me/... HI + """ + if "-r" in message.flags: + input: list[str] = message.filtered_input.split(" ", maxsplit=1) + + if len(input) < 2: + await message.reply("The '-r' flag requires a message link and text.") + return + + message_link, text = input + chat_id, _, reply_to_id = parse_link(message_link.strip()) + + else: + chat_id, text, reply_to_id = message.chat.id, message.input, message.reply_id + + if not text: + return + + await bot.send_message( + chat_id=chat_id, text=text, reply_to_id=reply_to_id, disable_preview=True + ) diff --git a/app/plugins/tg_tools/respond.py b/app/plugins/tg_tools/respond.py new file mode 100644 index 0000000..1c95b0c --- /dev/null +++ b/app/plugins/tg_tools/respond.py @@ -0,0 +1,27 @@ +import re + +from app import BOT, Message + + +@BOT.add_cmd(cmd="resp") +async def respond(bot: BOT, message: Message): + """ + CMD: RESP + INFO: Respond to a Logged Message. + USAGE: + .resp [chat_id | reply to a message containing info] hi + """ + if message.replied: + inp_text = message.replied.text + pattern = r"\((-\d+)\)" if "#TAG" in inp_text else r"\[(\d+)\]" + match = re.search(pattern=pattern, string=inp_text) + if match: + chat_id = match.group(1) + text = message.input + elif message.input: + chat_id, text = message.input.split(" ", maxsplit=1) + else: + await message.reply("Unable to extract chat_id and text.") + return + + await bot.send_message(chat_id=int(chat_id), text=text, disable_preview=True) diff --git a/app/plugins/utils/calc.py b/app/plugins/utils/calc.py new file mode 100644 index 0000000..e8cfbe3 --- /dev/null +++ b/app/plugins/utils/calc.py @@ -0,0 +1,263 @@ +import math +import re +from decimal import Decimal + +from app import BOT, Message + + +@BOT.add_cmd(cmd="calc") +async def calculator(bot: BOT, message: Message): + """ + CMD: CALC + INFO: Advanced calculator with support for mathematical functions. + USAGE: .calc 2 + 2 * 3 + .calc sqrt(16) + pi + .calc sin(30 * pi / 180) + """ + expression = message.filtered_input + if not expression: + await message.reply("❌ No expression provided.\n" + "Usage: .calc [expression]\n" + "Examples:\n" + "• .calc 2 + 2 * 3\n" + "• .calc sqrt(16) + pi\n" + "• .calc sin(45 * pi / 180)") + return + + response = await message.reply(f"🧮 Calculating...\n{expression}") + + try: + # Prepare safe evaluation environment + safe_dict = { + # Basic math functions + 'abs': abs, 'round': round, 'min': min, 'max': max, + 'sum': sum, 'pow': pow, + + # Math constants + 'pi': math.pi, 'e': math.e, 'tau': math.tau, + 'inf': math.inf, 'nan': math.nan, + + # Trigonometric functions + 'sin': math.sin, 'cos': math.cos, 'tan': math.tan, + 'asin': math.asin, 'acos': math.acos, 'atan': math.atan, + 'atan2': math.atan2, + + # Hyperbolic functions + 'sinh': math.sinh, 'cosh': math.cosh, 'tanh': math.tanh, + 'asinh': math.asinh, 'acosh': math.acosh, 'atanh': math.atanh, + + # Exponential and logarithmic + 'exp': math.exp, 'log': math.log, 'log10': math.log10, + 'log2': math.log2, 'sqrt': math.sqrt, + + # Other functions + 'ceil': math.ceil, 'floor': math.floor, + 'factorial': math.factorial, 'degrees': math.degrees, + 'radians': math.radians, 'gcd': math.gcd, + + # Constants for convenience + '__builtins__': {} # Remove access to builtins for security + } + + # Replace common mathematical notation + expression = expression.replace('^', '**') # Power operator + expression = expression.replace('×', '*') # Multiplication + expression = expression.replace('÷', '/') # Division + + # Evaluate the expression + result = eval(expression, safe_dict) + + # Format the result + if isinstance(result, float): + if result.is_integer(): + result_str = str(int(result)) + else: + # Round to reasonable precision + result_str = f"{result:.10g}" + else: + result_str = str(result) + + # Create result message + calc_text = f"🧮 Calculator Result\n\n" + calc_text += f"Expression: {message.filtered_input}\n" + calc_text += f"Result: {result_str}\n" + + # Add additional info for special values + if isinstance(result, float): + if result == math.pi: + calc_text += f"Note: This is π (pi)\n" + elif result == math.e: + calc_text += f"Note: This is e (Euler's number)\n" + elif math.isinf(result): + calc_text += f"Note: Result is infinity\n" + elif math.isnan(result): + calc_text += f"Note: Result is not a number (NaN)\n" + + calc_text += "\n✅ Calculation completed!" + + await response.edit(calc_text) + + except ZeroDivisionError: + await response.edit("❌ Division by zero error!\n" + f"Expression: {expression}") + except (ValueError, TypeError) as e: + await response.edit(f"❌ Invalid expression!\n" + f"Expression: {expression}\n" + f"Error: {str(e)}") + except Exception as e: + await response.edit(f"❌ Calculation error!\n" + f"Expression: {expression}\n" + f"Error: {str(e)}") + + +@BOT.add_cmd(cmd="convert") +async def unit_converter(bot: BOT, message: Message): + """ + CMD: CONVERT + INFO: Convert between different units (temperature, length, weight, etc.). + USAGE: .convert 100 c f (Celsius to Fahrenheit) + .convert 5 km mi (Kilometers to Miles) + .convert 10 kg lb (Kilograms to Pounds) + """ + args = message.filtered_input.split() + if len(args) != 3: + await message.reply("❌ Invalid format!\n" + "Usage: .convert [value] [from_unit] [to_unit]\n\n" + "Supported conversions:\n" + "• Temperature: c, f, k (Celsius, Fahrenheit, Kelvin)\n" + "• Length: m, km, ft, mi, in, cm, mm\n" + "• Weight: kg, g, lb, oz\n" + "• Area: m2, km2, ft2, in2, acre\n" + "• Volume: l, ml, gal, qt, pt, cup") + return + + try: + value = float(args[0]) + from_unit = args[1].lower() + to_unit = args[2].lower() + except ValueError: + await message.reply("❌ Invalid value! Please provide a numeric value.") + return + + response = await message.reply(f"🔄 Converting {value} {from_unit} to {to_unit}...") + + try: + result = None + conversion_type = None + + # Temperature conversions + if from_unit in ['c', 'f', 'k'] and to_unit in ['c', 'f', 'k']: + conversion_type = "Temperature" + + # Convert to Celsius first + if from_unit == 'f': + celsius = (value - 32) * 5/9 + elif from_unit == 'k': + celsius = value - 273.15 + else: + celsius = value + + # Convert from Celsius to target + if to_unit == 'f': + result = celsius * 9/5 + 32 + elif to_unit == 'k': + result = celsius + 273.15 + else: + result = celsius + + # Length conversions (convert to meters first) + elif from_unit in ['m', 'km', 'ft', 'mi', 'in', 'cm', 'mm'] and to_unit in ['m', 'km', 'ft', 'mi', 'in', 'cm', 'mm']: + conversion_type = "Length" + + # To meters + to_meters = { + 'm': 1, 'km': 1000, 'cm': 0.01, 'mm': 0.001, + 'ft': 0.3048, 'in': 0.0254, 'mi': 1609.34 + } + + meters = value * to_meters[from_unit] + result = meters / to_meters[to_unit] + + # Weight conversions (convert to grams first) + elif from_unit in ['kg', 'g', 'lb', 'oz'] and to_unit in ['kg', 'g', 'lb', 'oz']: + conversion_type = "Weight" + + # To grams + to_grams = { + 'g': 1, 'kg': 1000, 'lb': 453.592, 'oz': 28.3495 + } + + grams = value * to_grams[from_unit] + result = grams / to_grams[to_unit] + + else: + await response.edit("❌ Unsupported conversion!\n" + "Please check the supported units in the help message.") + return + + # Format result + if result.is_integer(): + result_str = str(int(result)) + else: + result_str = f"{result:.6g}" + + # Unit names for display + unit_names = { + 'c': '°C', 'f': '°F', 'k': 'K', + 'm': 'meters', 'km': 'kilometers', 'cm': 'centimeters', 'mm': 'millimeters', + 'ft': 'feet', 'in': 'inches', 'mi': 'miles', + 'kg': 'kilograms', 'g': 'grams', 'lb': 'pounds', 'oz': 'ounces' + } + + from_name = unit_names.get(from_unit, from_unit) + to_name = unit_names.get(to_unit, to_unit) + + convert_text = f"🔄 Unit Conversion Result\n\n" + convert_text += f"Type: {conversion_type}\n" + convert_text += f"From: {value} {from_name}\n" + convert_text += f"To: {result_str} {to_name}\n" + convert_text += "\n✅ Conversion completed!" + + await response.edit(convert_text) + + except Exception as e: + await response.edit(f"❌ Conversion error!\n{str(e)}") + + +@BOT.add_cmd(cmd="math") +async def math_help(bot: BOT, message: Message): + """ + CMD: MATH + INFO: Show available mathematical functions and constants. + USAGE: .math + """ + help_text = """🧮 Mathematical Functions & Constants + +📐 Basic Operations: ++, -, *, /, **, %, // + +🔢 Functions: +abs(), round(), min(), max(), sum(), pow() + +📊 Constants: +pi, e, tau, inf, nan + +📐 Trigonometric: +sin(), cos(), tan(), asin(), acos(), atan() + +📈 Exponential & Logarithmic: +exp(), log(), log10(), log2(), sqrt() + +🔄 Other Functions: +ceil(), floor(), factorial(), degrees(), radians() + +🌡️ Unit Conversions: +Use .convert for temperature, length, weight conversions + +💡 Examples: +• .calc 2**8 - Calculate 2 to the power of 8 +• .calc sin(pi/2) - Sine of 90 degrees +• .calc sqrt(144) + log10(100) - Complex expression +• .convert 100 c f - Convert 100°C to Fahrenheit""" + + await message.reply(help_text) \ No newline at end of file diff --git a/app/plugins/utils/encode.py b/app/plugins/utils/encode.py new file mode 100644 index 0000000..edd8da1 --- /dev/null +++ b/app/plugins/utils/encode.py @@ -0,0 +1,278 @@ +import base64 +import hashlib +import urllib.parse +import binascii +import json +from html import escape, unescape + +from app import BOT, Message + + +@BOT.add_cmd(cmd="encode") +async def encode_text(bot: BOT, message: Message): + """ + CMD: ENCODE + INFO: Encode text using various encoding methods. + FLAGS: -b64 (base64), -url (URL encode), -hex (hexadecimal), -html (HTML entities) + USAGE: .encode -b64 Hello World + .encode -url Hello World! + .encode -hex Secret Text + """ + text = message.filtered_input + if not text: + await message.reply("❌ No text provided!\n" + "Usage: .encode -[method] [text]\n\n" + "Available methods:\n" + "• -b64 - Base64 encoding\n" + "• -url - URL encoding\n" + "• -hex - Hexadecimal encoding\n" + "• -html - HTML entities encoding") + return + + response = await message.reply("🔄 Encoding text...") + + try: + results = [] + + # Base64 encoding + if "-b64" in message.flags: + encoded = base64.b64encode(text.encode('utf-8')).decode('utf-8') + results.append(f"📦 Base64:\n{encoded}") + + # URL encoding + if "-url" in message.flags: + encoded = urllib.parse.quote(text, safe='') + results.append(f"🌐 URL Encoded:\n{encoded}") + + # Hexadecimal encoding + if "-hex" in message.flags: + encoded = text.encode('utf-8').hex() + results.append(f"🔢 Hexadecimal:\n{encoded}") + + # HTML entities encoding + if "-html" in message.flags: + encoded = escape(text) + results.append(f"🌐 HTML Entities:\n{encoded}") + + if not results: + # Default to base64 if no method specified + encoded = base64.b64encode(text.encode('utf-8')).decode('utf-8') + results.append(f"📦 Base64 (default):\n{encoded}") + + encode_text = f"🔐 Text Encoding Results\n\n" + encode_text += f"Original Text:\n{text}\n\n" + encode_text += "\n\n".join(results) + encode_text += "\n\n✅ Encoding completed!" + + await response.edit(encode_text) + + except Exception as e: + await response.edit(f"❌ Encoding error:\n{str(e)}") + + +@BOT.add_cmd(cmd="decode") +async def decode_text(bot: BOT, message: Message): + """ + CMD: DECODE + INFO: Decode text using various decoding methods. + FLAGS: -b64 (base64), -url (URL decode), -hex (hexadecimal), -html (HTML entities) + USAGE: .decode -b64 SGVsbG8gV29ybGQ= + .decode -url Hello%20World%21 + .decode -hex 48656c6c6f20576f726c64 + """ + text = message.filtered_input + if not text: + await message.reply("❌ No text provided!\n" + "Usage: .decode -[method] [encoded_text]\n\n" + "Available methods:\n" + "• -b64 - Base64 decoding\n" + "• -url - URL decoding\n" + "• -hex - Hexadecimal decoding\n" + "• -html - HTML entities decoding") + return + + response = await message.reply("🔄 Decoding text...") + + try: + results = [] + + # Base64 decoding + if "-b64" in message.flags: + try: + decoded = base64.b64decode(text).decode('utf-8') + results.append(f"📦 Base64 Decoded:\n{decoded}") + except Exception as e: + results.append(f"📦 Base64:{str(e)}") + + # URL decoding + if "-url" in message.flags: + try: + decoded = urllib.parse.unquote(text) + results.append(f"🌐 URL Decoded:\n{decoded}") + except Exception as e: + results.append(f"🌐 URL:{str(e)}") + + # Hexadecimal decoding + if "-hex" in message.flags: + try: + decoded = bytes.fromhex(text).decode('utf-8') + results.append(f"🔢 Hexadecimal Decoded:\n{decoded}") + except Exception as e: + results.append(f"🔢 Hexadecimal:{str(e)}") + + # HTML entities decoding + if "-html" in message.flags: + try: + decoded = unescape(text) + results.append(f"🌐 HTML Entities Decoded:\n{decoded}") + except Exception as e: + results.append(f"🌐 HTML:{str(e)}") + + if not results: + # Try to auto-detect and decode + # Try base64 first + try: + decoded = base64.b64decode(text).decode('utf-8') + results.append(f"📦 Auto-detected Base64:\n{decoded}") + except: + # Try hex + try: + decoded = bytes.fromhex(text).decode('utf-8') + results.append(f"🔢 Auto-detected Hexadecimal:\n{decoded}") + except: + # Try URL decode + try: + decoded = urllib.parse.unquote(text) + if decoded != text: # Only show if actually decoded something + results.append(f"🌐 Auto-detected URL:\n{decoded}") + else: + results.append("❌ Could not auto-detect encoding method.") + except: + results.append("❌ Could not auto-detect encoding method.") + + decode_text = f"🔓 Text Decoding Results\n\n" + decode_text += f"Encoded Text:\n{text}\n\n" + decode_text += "\n\n".join(results) + decode_text += "\n\n✅ Decoding completed!" + + await response.edit(decode_text) + + except Exception as e: + await response.edit(f"❌ Decoding error:\n{str(e)}") + + +@BOT.add_cmd(cmd="hash") +async def hash_text(bot: BOT, message: Message): + """ + CMD: HASH + INFO: Generate various hash values for text. + FLAGS: -md5, -sha1, -sha256, -sha512, -all (for all hashes) + USAGE: .hash -sha256 Hello World + .hash -all Secret Text + """ + text = message.filtered_input + if not text: + await message.reply("❌ No text provided!\n" + "Usage: .hash -[method] [text]\n\n" + "Available methods:\n" + "• -md5 - MD5 hash\n" + "• -sha1 - SHA1 hash\n" + "• -sha256 - SHA256 hash\n" + "• -sha512 - SHA512 hash\n" + "• -all - All hash methods") + return + + response = await message.reply("🔄 Generating hashes...") + + try: + text_bytes = text.encode('utf-8') + results = [] + + # Generate specific hashes based on flags + if "-md5" in message.flags or "-all" in message.flags: + md5_hash = hashlib.md5(text_bytes).hexdigest() + results.append(f"🔐 MD5:\n{md5_hash}") + + if "-sha1" in message.flags or "-all" in message.flags: + sha1_hash = hashlib.sha1(text_bytes).hexdigest() + results.append(f"🔐 SHA1:\n{sha1_hash}") + + if "-sha256" in message.flags or "-all" in message.flags: + sha256_hash = hashlib.sha256(text_bytes).hexdigest() + results.append(f"🔐 SHA256:\n{sha256_hash}") + + if "-sha512" in message.flags or "-all" in message.flags: + sha512_hash = hashlib.sha512(text_bytes).hexdigest() + results.append(f"🔐 SHA512:\n{sha512_hash}") + + if not results: + # Default to SHA256 if no method specified + sha256_hash = hashlib.sha256(text_bytes).hexdigest() + results.append(f"🔐 SHA256 (default):\n{sha256_hash}") + + hash_text = f"🔐 Hash Generation Results\n\n" + hash_text += f"Original Text:\n{text}\n\n" + hash_text += "\n\n".join(results) + hash_text += "\n\n✅ Hash generation completed!" + + await response.edit(hash_text) + + except Exception as e: + await response.edit(f"❌ Hash generation error:\n{str(e)}") + + +@BOT.add_cmd(cmd="json") +async def json_formatter(bot: BOT, message: Message): + """ + CMD: JSON + INFO: Format, validate and minify JSON data. + FLAGS: -pretty (format), -minify (minify), -validate (validate only) + USAGE: .json -pretty {"name":"John","age":30} + .json -minify { "formatted": "json" } + """ + json_text = message.filtered_input + if not json_text: + await message.reply("❌ No JSON provided!\n" + "Usage: .json -[method] [json_data]\n\n" + "Available methods:\n" + "• -pretty - Format JSON with indentation\n" + "• -minify - Minify JSON (remove whitespace)\n" + "• -validate - Validate JSON syntax only") + return + + response = await message.reply("🔄 Processing JSON...") + + try: + # Parse JSON to validate + parsed_json = json.loads(json_text) + + results = [] + + if "-pretty" in message.flags: + pretty_json = json.dumps(parsed_json, indent=2, ensure_ascii=False) + results.append(f"📋 Pretty Formatted:\n
{pretty_json}
") + + if "-minify" in message.flags: + minified_json = json.dumps(parsed_json, separators=(',', ':'), ensure_ascii=False) + results.append(f"🗜️ Minified:\n{minified_json}") + + if "-validate" in message.flags: + results.append("✅ JSON is valid!") + + if not results: + # Default to pretty format + pretty_json = json.dumps(parsed_json, indent=2, ensure_ascii=False) + results.append(f"📋 Pretty Formatted (default):\n
{pretty_json}
") + + json_result = f"📋 JSON Processing Results\n\n" + json_result += "\n\n".join(results) + json_result += "\n\n✅ JSON processing completed!" + + await response.edit(json_result) + + except json.JSONDecodeError as e: + await response.edit(f"❌ Invalid JSON!\n" + f"Error: {str(e)}\n" + f"Input: {json_text}") + except Exception as e: + await response.edit(f"❌ JSON processing error:\n{str(e)}") \ No newline at end of file diff --git a/app/plugins/utils/qr.py b/app/plugins/utils/qr.py new file mode 100644 index 0000000..73cf715 --- /dev/null +++ b/app/plugins/utils/qr.py @@ -0,0 +1,186 @@ +import io +import qrcode +from PIL import Image + +from app import BOT, Message + + +@BOT.add_cmd(cmd="qr") +async def generate_qr_code(bot: BOT, message: Message): + """ + CMD: QR + INFO: Generate QR code from text or URL. + FLAGS: -s [size] for custom size (default: 10), -b [border] for border size + USAGE: .qr https://example.com + .qr -s 15 -b 2 Hello World! + """ + text = message.filtered_input + if not text: + await message.reply("❌ No text provided!\n" + "Usage: .qr [text/url]\n" + "Example: .qr https://telegram.org") + return + + response = await message.reply("🔄 Generating QR code...") + + try: + # Parse flags for customization + box_size = 10 # Default size + border = 4 # Default border + + if "-s" in message.flags: + try: + size_index = message.flags.index("-s") + 1 + if size_index < len(message.flags): + box_size = int(message.flags[size_index]) + except (ValueError, IndexError): + pass + + if "-b" in message.flags: + try: + border_index = message.flags.index("-b") + 1 + if border_index < len(message.flags): + border = int(message.flags[border_index]) + except (ValueError, IndexError): + pass + + # Generate QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=box_size, + border=border, + ) + + qr.add_data(text) + qr.make(fit=True) + + # Create image + img = qr.make_image(fill_color="black", back_color="white") + + # Convert to bytes + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Send QR code image + caption = f"📱 QR Code Generated\n\n" + caption += f"Content: {text}\n" + caption += f"Size: {img.size[0]}x{img.size[1]} pixels\n" + caption += f"Box Size: {box_size}\n" + caption += f"Border: {border}" + + await response.delete() + await message.reply_photo( + photo=img_bytes, + caption=caption + ) + + except Exception as e: + await response.edit(f"❌ Error generating QR code:\n{str(e)}") + + +@BOT.add_cmd(cmd="barcode") +async def generate_barcode(bot: BOT, message: Message): + """ + CMD: BARCODE + INFO: Generate a simple text-based barcode representation. + USAGE: .barcode 1234567890 + """ + text = message.filtered_input + if not text: + await message.reply("❌ No text provided!\n" + "Usage: .barcode [text/numbers]\n" + "Example: .barcode 1234567890") + return + + response = await message.reply("🔄 Generating barcode...") + + try: + # Simple ASCII barcode representation + # This is a basic representation, not a real barcode standard + + # Create a pattern based on the text + barcode_lines = [] + + # Header + barcode_lines.append("█" * (len(text) * 8 + 4)) + barcode_lines.append("█" + " " * (len(text) * 8 + 2) + "█") + + # Generate pattern for each character + for char in text: + ascii_val = ord(char) + # Create a simple pattern based on ASCII value + pattern = "" + for i in range(8): + if (ascii_val >> i) & 1: + pattern += "█" + else: + pattern += " " + barcode_lines.append("█ " + pattern + " █") + + # Footer + barcode_lines.append("█" + " " * (len(text) * 8 + 2) + "█") + barcode_lines.append("█" * (len(text) * 8 + 4)) + + # Number display + number_line = " " + for char in text: + number_line += f"{char:<8}" + barcode_lines.append(number_line) + + barcode_display = "\n".join(barcode_lines) + + barcode_text = f"📊 Text Barcode Generated\n\n" + barcode_text += f"Content: {text}\n" + barcode_text += f"Length: {len(text)} characters\n\n" + barcode_text += f"
{barcode_display}
\n\n" + barcode_text += "📝 This is a simple text representation, not a standard barcode format." + + await response.edit(barcode_text) + + except Exception as e: + await response.edit(f"❌ Error generating barcode:\n{str(e)}") + + +@BOT.add_cmd(cmd="qrinfo") +async def qr_code_info(bot: BOT, message: Message): + """ + CMD: QRINFO + INFO: Show information about QR code generation and usage. + USAGE: .qrinfo + """ + info_text = """📱 QR Code Generator Information + +🔧 Available Commands: +• .qr [text] - Generate QR code from text +• .qr -s [size] [text] - Custom box size (1-50) +• .qr -b [border] [text] - Custom border size (1-20) + +📊 QR Code Features: +• Supports text, URLs, phone numbers, email +• Error correction level: Low (faster generation) +• Output format: PNG image +• Black and white design + +💡 Usage Examples: +• .qr https://telegram.org +• .qr -s 15 My Secret Message +• .qr -s 8 -b 2 tel:+1234567890 +• .qr mailto:user@example.com + +📏 Size Guidelines: +• Small: 5-8 (faster, lower quality) +• Medium: 10-12 (default, balanced) +• Large: 15-20 (slower, higher quality) + +🎯 Best Practices: +• Keep text short for better scanning +• Test QR codes before sharing +• Use higher sizes for small text +• URLs work best with QR codes + +📊 Barcode Command: +• .barcode [text] - Simple ASCII barcode representation""" + + await message.reply(info_text) \ No newline at end of file diff --git a/assets/dark.png b/assets/dark.png new file mode 100644 index 0000000..0b7c84f Binary files /dev/null and b/assets/dark.png differ diff --git a/assets/light.png b/assets/light.png new file mode 100644 index 0000000..a7c6594 Binary files /dev/null and b/assets/light.png differ diff --git a/docker_start.sh b/docker_start.sh new file mode 100755 index 0000000..c12a344 --- /dev/null +++ b/docker_start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +export PIP_ROOT_USER_ACTION=ignore + +if [ -d "ubot" ]; then + rm -rf ubot + echo "removed older ubot dir" +fi + +run_extra_boot_scripts() { + local directory="scripts" + + if [[ -d "$directory" ]]; then + + if [[ -n "$(ls -A "$directory")" ]]; then + + for file in "$directory"/*; do + + if [[ -f "$file" && -x "$file" ]]; then + + echo "Executing $file" + "$file" + + fi + done + fi + fi +} + + +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 +run_extra_boot_scripts + +bash run diff --git a/install_service.sh b/install_service.sh new file mode 100755 index 0000000..a3416a9 --- /dev/null +++ b/install_service.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Copy service file to systemd directory +sudo cp userbot.service /etc/systemd/system/ + +# Reload systemd to recognize the new service +sudo systemctl daemon-reload + +# Enable the service to start on boot +sudo systemctl enable userbot.service + +echo "Service installed successfully!" +echo "To start the service: sudo systemctl start userbot" +echo "To check status: sudo systemctl status userbot" +echo "To view logs: sudo journalctl -u userbot -f" \ No newline at end of file diff --git a/req.txt b/req.txt new file mode 100644 index 0000000..22f5547 --- /dev/null +++ b/req.txt @@ -0,0 +1,14 @@ +# BoilerPlate Code for UB +# git+https://github.com/thedragonsinn/ub-core.git +# moved to scripts/install_ub_core.sh + +yt-dlp>=2024.5.27 +pillow +openai +aiohttp +python-libtorrent + +google-auth-oauthlib +google-auth-httplib2 +google-api-python-client +google-genai diff --git a/run b/run new file mode 100755 index 0000000..1943829 --- /dev/null +++ b/run @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +if ! [ -d ".git" ] ; then + git init +fi + +while true; do + python -m app + exit_code=$? + [ $exit_code -ne 69 ] && break +done diff --git a/sample-config.env b/sample-config.env new file mode 100644 index 0000000..2f77560 --- /dev/null +++ b/sample-config.env @@ -0,0 +1,88 @@ +API_ID= + + +API_HASH= + + +# API_PORT= +# To pass Health checks of Koyeb, Render and other hosts. +# Use the port listed in your app configuration. + + +CMD_TRIGGER=. + + +#CUSTOM_PACK_NAME= +# to set a different pack title when kanging. + + +# USE_LEGACY_KANG=1 +# Uncomment to use old kang method that kangs to separate packs. + + +DEV_MODE=0 +# py, sh, shell commands + + +DB_URL= +# Mongo DB cluster URL + + +# DRIVE_ROOT_ID = +# ID of the default working dir for bot in google drive +# ID can be found by copying the link of the folder +# The random string of characters after folder/ is ID + + +# EXTRA_MODULES_REPO= +# To add extra modules or mini bots that require stuff in ub. +# Only For Advance Users. + + +# FBAN_LOG_CHANNEL= +# Optional FedBan Proof and logs. + + +# FBAN_SUDO_ID= +# FBAN_SUDO_TRIGGER= +# Optional sudo fban vars to initiate ban in 2nd user-bot. + + +# GEMINI_API_KEY= +# Optional API Key +# Get from https://ai.google.dev/ + + +LOG_CHAT= +# Bot logs chat/channel + + +# LOG_CHAT_THREAD_ID= +# if you want to log to a specific topic. + + +# MESSAGE_LOGGER_CHAT= +# For PM and Tag logger +# Defaults to sending in Log Channel Above. + + +# PM_LOGGER_THREAD_ID= +# TAG_LOGGER_THREAD_ID= +# Extra customisation for separated logging. +# can be used with the var above or directly with log chat. + + +OWNER_ID= +# Your user ID + + +SESSION_STRING= +# Your string session + + +SUDO_TRIGGER=! +# Sudo Trigger for bot + + +UPSTREAM_REPO=https://github.com/thedragonsinn/plain-ub +# Keep default unless you maintain your own fork. diff --git a/scripts/install_external_modules.sh b/scripts/install_external_modules.sh new file mode 100755 index 0000000..6c6a02a --- /dev/null +++ b/scripts/install_external_modules.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +[ -z "$EXTRA_MODULES_REPO" ] && { echo "EXTRA_MODULES_REPO not set, Skipping..."; exit; } + +repo_name=$(basename "$EXTRA_MODULES_REPO") + +echo "Installing ${repo_name} to app/modules" + +git clone -q "$EXTRA_MODULES_REPO" "app/modules" || { echo "Failed to clone external repo"; exit; } + +pip -q install --no-cache-dir -r app/modules/req*.txt + +echo "Done" + diff --git a/scripts/install_termux_reqs.sh b/scripts/install_termux_reqs.sh new file mode 100755 index 0000000..48888b9 --- /dev/null +++ b/scripts/install_termux_reqs.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +grep -qi "com.termux" <<< "$PATH" || { echo "Not a termux Env, Skipping..."; exit; } + +mkdir -p "${HOME}/.config/pip" > /dev/null 2>&1 + +echo -e '[global]\nextra-index-url = https://termux-user-repository.github.io/pypi/' > "${HOME}/.config/pip/pip.conf" + +./scripts/install_ub_core.sh + +grep -Ev "^#|openai" req.txt | xargs -n 1 pip install diff --git a/scripts/install_ub_core.sh b/scripts/install_ub_core.sh new file mode 100755 index 0000000..fa5e801 --- /dev/null +++ b/scripts/install_ub_core.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +dual_mode_arg="" + +[ -n "$USE_DUAL_BRANCH" ] && dual_mode_arg="@dual_mode" + +pip -q install --no-cache-dir --force-reinstall "git+https://github.com/thedragonsinn/ub-core${dual_mode_arg}" \ No newline at end of file diff --git a/ubot b/ubot new file mode 160000 index 0000000..e0a8b22 --- /dev/null +++ b/ubot @@ -0,0 +1 @@ +Subproject commit e0a8b22b2e66c0bc58407d56a78530b6a5d37b0c diff --git a/userbot.service b/userbot.service new file mode 100644 index 0000000..e3188a8 --- /dev/null +++ b/userbot.service @@ -0,0 +1,15 @@ +[Unit] +Description=Telegram Userbot +After=network.target + +[Service] +Type=simple +User=wiktoro +WorkingDirectory=/home/wiktoro/Pobrane/plain-ub-main +ExecStart=/bin/bash /home/wiktoro/Pobrane/plain-ub-main/run +Restart=always +RestartSec=10 +Environment=PATH=/home/wiktoro/bin:/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=multi-user.target \ No newline at end of file