import asyncio
import builtins
import importlib
import inspect
import io
import logging
import os
import subprocess
import sys
import traceback
import typing
from io import BytesIO
from sys import version_info
import git
try:
from PIL import Image
except Exception:
PIP_AVAILABLE = False
else:
PIP_AVAILABLE = True
from pyrogram import Client, errors, types
from .. import version
from .._internal import restart
from ..database import Database
from ..tl_cache import CustomTelegramClient
from ..types import JSONSerializable
DRAGON_EMOJI = "🐲"
native_import = builtins.__import__
logger = logging.getLogger(__name__)
# This is used to ensure, that dynamic dragon import passes in
# the right client. Whenever one of the clients attempts to install
# dragon-specific module, it must aqcuire the `import_lock` or wait
# until it's released. Then set the `current_client` variable to self.
class ImportLock:
def __init__(self):
self.lock = asyncio.Lock()
self.current_client = None
def __call__(self, client: CustomTelegramClient) -> typing.ContextManager:
self.current_client = client
return self
async def __aenter__(self):
await self.lock.acquire()
async def __aexit__(self, *_):
self.current_client = None
self.lock.release()
import_lock = ImportLock()
class DragonDb:
def __init__(self, db: Database):
self.db = db
def get(
self,
module: str,
variable: str,
default: typing.Optional[typing.Any] = None,
) -> JSONSerializable:
return self.db.get(f"dragon.{module}", variable, default)
def set(self, module: str, variable: str, value: JSONSerializable) -> bool:
return self.db.set(f"dragon.{module}", variable, value)
def get_collection(self, module: str) -> typing.Dict[str, JSONSerializable]:
return dict.get(self.db, f"dragon.{module}", {})
def remove(self, module: str, variable: str) -> JSONSerializable:
if f"dragon.{module}" not in self.db:
return None
return self.db[f"dragon.{module}"].pop(variable, None)
def close(self):
pass
class DragonDbWrapper:
def __init__(self, db: DragonDb):
self.db = db
class Notifier:
def __init__(self, modules_help: "ModulesHelpDict"):
self.modules_help = modules_help
self.cache = {}
def __enter__(self):
self.modules_help.notifier = self
return self
def __exit__(self, *_):
self.modules_help.notifier = None
def notify(self, key: str, value: dict):
self.cache[key] = value
@property
def modname(self):
return next(iter(self.cache), "Unknown")
@property
def commands(self):
return {
key.split()[0]: (
((key.split()[1] + " - ") if len(key.split()) > 1 else "") + value
)
for key, value in self.cache[self.modname].items()
}
class ModulesHelpDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.notifier = None
def append(self, obj: dict):
# convert help from old to new type
module_name = list(obj.keys())[0]
cmds = obj[module_name]
commands = {}
for cmd in cmds:
cmd_name = list(cmd.keys())[0]
cmd_desc = cmd[cmd_name]
commands[cmd_name] = cmd_desc
self[module_name] = commands
def __setitem__(self, key, value):
super().__setitem__(key, value)
if self.notifier:
self.notifier.notify(key, value)
def get_notifier(self) -> Notifier:
return Notifier(self)
class DragonMisc:
def __init__(self, client: CustomTelegramClient):
self.client = client
self.modules_help = ModulesHelpDict()
self.requirements_list = []
self.python_version = f"{version_info[0]}.{version_info[1]}.{version_info[2]}"
self.gitrepo = git.Repo(
path=os.path.abspath(os.path.join(os.path.dirname(version.__file__), ".."))
)
self.commits_since_tag = 0
self.userbot_version = version.__version__
@property
def prefix(self):
return self.client.loader.get_prefix("dragon")
class DragonConfig:
def __init__(self, client: CustomTelegramClient):
self.api_id = client.api_id
self.api_hash = client.api_hash
self.db_type = ""
self.db_url = ""
self.db_name = ""
self.test_server = False
class DragonScripts:
def __init__(self, misc: DragonMisc):
self.interact_with_to_delete = []
self.misc = misc
@staticmethod
def text(message: types.Message):
return message.text if message.text else message.caption
@staticmethod
def restart():
restart()
@staticmethod
def format_exc(e: Exception, hint: str = None):
traceback.print_exc()
if isinstance(e, errors.RPCError):
return (
"Telegram API error!\n"
f"[{e.CODE} {e.ID or e.NAME}] - {e.MESSAGE}
"
)
else:
if hint:
hint_text = f"\n\nHint: {hint}"
else:
hint_text = ""
return (
f"Error!\n{e.__class__.__name__}: {e}
" + hint_text
)
@staticmethod
def with_reply(func):
async def wrapped(client: Client, message: types.Message):
if not message.reply_to_message:
await message.edit("Reply to message is required")
else:
return await func(client, message)
return wrapped
async def interact_with(self, message: types.Message) -> types.Message:
"""
Check history with bot and return bot's response
Example:
.. code-block:: python
bot_msg = await interact_with(await bot.send_message("@BotFather", "/start"))
:param message: already sent message to bot
:return: bot's response
"""
await asyncio.sleep(1)
# noinspection PyProtectedMember
response = await message._client.get_history(message.chat.id, limit=1)
seconds_waiting = 0
while response[0].from_user.is_self:
seconds_waiting += 1
if seconds_waiting >= 5:
raise RuntimeError("bot didn't answer in 5 seconds")
await asyncio.sleep(1)
# noinspection PyProtectedMember
response = await message._client.get_history(message.chat.id, limit=1)
self.interact_with_to_delete.append(message.message_id)
self.interact_with_to_delete.append(response[0].message_id)
return response[0]
def format_module_help(self, module_name: str):
commands = self.misc.modules_help[module_name]
help_text = (
f"{DRAGON_EMOJI} Help for"
f" {module_name}
\n\nUsage:\n"
)
for command, desc in commands.items():
cmd = command.split(maxsplit=1)
args = " " + cmd[1] + "
" if len(cmd) > 1 else ""
help_text += (
f"{self.misc.prefix}{cmd[0]}
{args} — {desc}\n"
)
return help_text
def format_small_module_help(self, module_name: str):
commands = self.misc.modules_help[module_name]
help_text = (
f"{DRAGON_EMOJI }Help for {module_name}
\n\nCommands"
" list:\n"
)
for command, desc in commands.items():
cmd = command.split(maxsplit=1)
args = " " + cmd[1] + "
" if len(cmd) > 1 else ""
help_text += f"{self.misc.prefix}{cmd[0]}
{args}\n"
help_text += (
f"\nGet full usage: {self.misc.prefix}help"
f" {module_name}
"
)
return help_text
def import_library(
self,
library_name: str,
package_name: typing.Optional[str] = None,
):
"""
Loads a library, or installs it in ImportError case
:param library_name: library name (import example...)
:param package_name: package name in PyPi (pip install example)
:return: loaded module
"""
if package_name is None:
package_name = library_name
self.misc.requirements_list.append(package_name)
try:
return importlib.import_module(library_name)
except (ImportError, ModuleNotFoundError):
completed = subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--upgrade",
"-q",
"--disable-pip-version-check",
"--no-warn-script-location",
*(
["--user"]
if "PIP_TARGET" not in os.environ
and "VIRTUAL_ENV" not in os.environ
else []
),
package_name,
],
)
if completed.returncode != 0:
raise AssertionError(
f"Failed to install library {package_name} (pip exited with code"
f" {completed.returncode})"
)
return importlib.import_module(library_name)
@staticmethod
def resize_image(
input_img: typing.Union[bytes, io.BytesIO],
output: typing.Optional[io.BytesIO] = None,
img_type: str = "PNG",
) -> io.BytesIO:
if not PIP_AVAILABLE:
raise RuntimeError("Install Pillow with: pip install Pillow -U")
if output is None:
output = BytesIO()
output.name = f"sticker.{img_type.lower()}"
with Image.open(input_img) as img:
# We used to use thumbnail(size) here, but it returns with a *max* dimension of 512,512
# rather than making one side exactly 512 so we have to calculate dimensions manually :(
if img.width == img.height:
size = (512, 512)
elif img.width < img.height:
size = (max(512 * img.width // img.height, 1), 512)
else:
size = (512, max(512 * img.height // img.width, 1))
img.resize(size).save(output, img_type)
return output
class DragonCompat:
def __init__(self, client: CustomTelegramClient):
self.client = client
self.db = DragonDbWrapper(DragonDb(client.loader.db))
self.misc = DragonMisc(client)
self.scripts = DragonScripts(self.misc)
self.config = DragonConfig(client)
def patched_import(name: str, *args, **kwargs):
caller = inspect.currentframe().f_back
caller_name = caller.f_globals.get("__name__")
if name.startswith("utils") and caller_name.startswith("dragon"):
if not import_lock.current_client:
raise RuntimeError("Dragon client not set")
if name.split(".", maxsplit=1)[1] in {"db", "misc", "scripts", "config"}:
return getattr(
import_lock.current_client.dragon_compat,
name.split(".", maxsplit=1)[1],
)
raise ImportError(f"Unknown module {name}")
return native_import(name, *args, **kwargs)
builtins.__import__ = patched_import
def apply_compat(client: CustomTelegramClient):
client.dragon_compat = DragonCompat(client)