API usage overhaul #27

Merged
profitroll merged 17 commits from overhaul into dev 2023-06-28 00:57:31 +03:00
7 changed files with 83 additions and 384 deletions
Showing only changes of commit a871773a81 - Show all commits

View File

@ -1,29 +0,0 @@
from dataclasses import dataclass
from typing import List, Union
from pyrogram.types import (
BotCommandScopeAllChatAdministrators,
BotCommandScopeAllGroupChats,
BotCommandScopeAllPrivateChats,
BotCommandScopeChat,
BotCommandScopeChatAdministrators,
BotCommandScopeChatMember,
BotCommandScopeDefault,
BotCommand,
)
@dataclass
class CommandSet:
"""Command stored in PyroClient's 'commands' attribute"""
commands: List[BotCommand]
scope: Union[
BotCommandScopeDefault,
BotCommandScopeAllPrivateChats,
BotCommandScopeAllGroupChats,
BotCommandScopeAllChatAdministrators,
BotCommandScopeChat,
BotCommandScopeChatAdministrators,
BotCommandScopeChatMember,
] = BotCommandScopeDefault
language_code: str = ""

View File

@ -1,49 +1,31 @@
import contextlib import contextlib
import logging import logging
from datetime import datetime, timedelta from datetime import datetime
from io import BytesIO from io import BytesIO
from os import getpid, makedirs, remove, sep from os import makedirs, remove, sep
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from time import time from time import time
from traceback import format_exc from traceback import format_exc
from typing import List, Tuple, Union from typing import Dict, List, Tuple, Union
import aiofiles
import pyrogram import aiofiles
from aiohttp import ClientSession from aiohttp import ClientSession
from bson import ObjectId from bson import ObjectId
from dateutil.relativedelta import relativedelta from libbot import json_write
from classes.enums.submission_types import SubmissionType
from libbot import json_read, json_write
from libbot.i18n import BotLocale
from libbot.i18n.sync import _ from libbot.i18n.sync import _
from photosapi_client.errors import UnexpectedStatus from photosapi_client.errors import UnexpectedStatus
from pyrogram.client import Client from pyrogram.errors import bad_request_400
from pyrogram.errors import BadRequest, bad_request_400 from pyrogram.types import Message
from pyrogram.handlers.message_handler import MessageHandler
from pyrogram.raw.all import layer
from pyrogram.types import (
BotCommand,
BotCommandScopeAllChatAdministrators,
BotCommandScopeAllGroupChats,
BotCommandScopeAllPrivateChats,
BotCommandScopeChat,
BotCommandScopeChatAdministrators,
BotCommandScopeChatMember,
BotCommandScopeDefault,
Message,
)
from pytimeparse.timeparse import timeparse from pytimeparse.timeparse import timeparse
from ujson import dumps, loads from ujson import dumps, loads
from classes.commandset import CommandSet from classes.enums.submission_types import SubmissionType
from classes.exceptions import ( from classes.exceptions import (
SubmissionDuplicatesError, SubmissionDuplicatesError,
SubmissionUnavailableError, SubmissionUnavailableError,
SubmissionUnsupportedError, SubmissionUnsupportedError,
) )
from classes.pyrocommand import PyroCommand
from modules.api_client import ( from modules.api_client import (
BodyPhotoUpload, BodyPhotoUpload,
BodyVideoUpload, BodyVideoUpload,
@ -56,28 +38,21 @@ from modules.api_client import (
) )
from modules.database import col_submitted from modules.database import col_submitted
from modules.http_client import http_session from modules.http_client import http_session
from modules.scheduler import scheduler
from modules.sender import send_content from modules.sender import send_content
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PyroClient(Client): from datetime import datetime
def __init__(self): from typing import List, Union
with open("config.json", "r", encoding="utf-8") as f:
self.config: dict = loads(f.read())
super().__init__( from apscheduler.schedulers.asyncio import AsyncIOScheduler
name="bot_client", from libbot.pyrogram.classes import PyroClient
api_id=self.config["bot"]["api_id"],
api_hash=self.config["bot"]["api_hash"],
bot_token=self.config["bot"]["bot_token"], class PyroClient(PyroClient):
plugins=dict(root="plugins", exclude=self.config["disabled_plugins"]), def __init__(self, scheduler: AsyncIOScheduler):
sleep_threshold=120, super().__init__(scheduler=scheduler)
max_concurrent_transmissions=self.config["bot"][
"max_concurrent_transmissions"
],
)
self.version: float = 0.2 self.version: float = 0.2
@ -85,142 +60,75 @@ class PyroClient(Client):
self.admins: List[int] = self.config["bot"]["admins"] + [ self.admins: List[int] = self.config["bot"]["admins"] + [
self.config["bot"]["owner"] self.config["bot"]["owner"]
] ]
self.commands: List[PyroCommand] = []
self.scoped_commands: bool = self.config["bot"]["scoped_commands"]
self.start_time: float = 0
self.bot_locale: BotLocale = BotLocale(Path(self.config["locations"]["locale"]))
self.default_locale: str = self.bot_locale.default
self.locales: dict = self.bot_locale.locales
self._ = self.bot_locale._
self.in_all_locales = self.bot_locale.in_all_locales
self.in_every_locale = self.bot_locale.in_every_locale
self.sender_session = ClientSession() self.sender_session = ClientSession()
self.scopes_placeholders: Dict[str, int] = {
"owner": self.owner,
"comments": self.config["posting"]["comments"],
}
async def start(self): async def start(self):
await super().start() await super().start()
self.start_time = time() if self.config["reports"]["update"]:
try:
async with ClientSession(
json_serialize=dumps,
) as http_session:
check_update = await http_session.get(
"https://git.end-play.xyz/api/v1/repos/profitroll/TelegramPoster/releases?page=1&limit=1"
)
logger.info( response = await check_update.json()
"Bot is running with Pyrogram v%s (Layer %s) and has started as @%s on PID %s.",
pyrogram.__version__,
layer,
self.me.username,
getpid(),
)
try: if len(response) == 0:
if Path(f"{self.config['locations']['cache']}/shutdown_time").exists(): raise ValueError("No bot releases on git found.")
downtime = relativedelta(
datetime.now(),
datetime.fromtimestamp(
(
await json_read(
Path(
f"{self.config['locations']['cache']}/shutdown_time"
)
)
)["timestamp"]
),
)
if downtime.days >= 1: if float(response[0]["tag_name"].replace("v", "")) > self.version:
startup_message = self._( logger.info(
"startup_downtime_days", "New version %s found (current %s)",
"message", response[0]["tag_name"].replace("v", ""),
).format(getpid(), downtime.days) self.version,
elif downtime.hours >= 1: )
startup_message = self._( await self.send_message(
"startup_downtime_hours", self.owner,
"message", self._(
).format(getpid(), downtime.hours) "update_available",
"message",
).format(
response[0]["tag_name"],
response[0]["html_url"],
response[0]["body"],
),
)
else: else:
startup_message = self._( logger.info("No updates found, bot is up to date.")
"startup_downtime_minutes", except bad_request_400.PeerIdInvalid:
"message", logger.warning(
).format(getpid(), downtime.minutes) "Could not send startup message to bot owner. Perhaps user has not started the bot yet."
)
except Exception as exp:
logger.exception("Update check failed due to %s: %s", exp, format_exc())
if self.config["mode"]["post"]:
if self.config["posting"]["use_interval"]:
self.scheduler.add_job(
send_content,
"interval",
seconds=timeparse(self.config["posting"]["interval"]),
args=[self, self.sender_session],
)
else: else:
startup_message = (self._("startup", "message").format(getpid()),) for entry in self.config["posting"]["time"]:
dt_obj = datetime.strptime(entry, "%H:%M")
await self.send_message( self.scheduler.add_job(
chat_id=self.config["reports"]["chat_id"],
text=startup_message,
)
if self.config["reports"]["update"]:
try:
async with ClientSession(
json_serialize=dumps,
) as http_session:
check_update = await http_session.get(
"https://git.end-play.xyz/api/v1/repos/profitroll/TelegramPoster/releases?page=1&limit=1"
)
response = await check_update.json()
if len(response) == 0:
raise ValueError("No bot releases on git found.")
if float(response[0]["tag_name"].replace("v", "")) > self.version:
logger.info(
"New version %s found (current %s)",
response[0]["tag_name"].replace("v", ""),
self.version,
)
await self.send_message(
self.owner,
self._(
"update_available",
"message",
).format(
response[0]["tag_name"],
response[0]["html_url"],
response[0]["body"],
),
)
else:
logger.info("No updates found, bot is up to date.")
except bad_request_400.PeerIdInvalid:
logger.warning(
"Could not send startup message to bot owner. Perhaps user has not started the bot yet."
)
except Exception as exp:
logger.exception(
"Update check failed due to %s: %s", exp, format_exc()
)
scheduler.add_job(
self.register_commands,
trigger="date",
run_date=datetime.now() + timedelta(seconds=5),
kwargs={"command_sets": await self.collect_commands()},
)
if self.config["mode"]["post"]:
if self.config["posting"]["use_interval"]:
scheduler.add_job(
send_content, send_content,
"interval", "cron",
seconds=timeparse(self.config["posting"]["interval"]), hour=dt_obj.hour,
minute=dt_obj.minute,
args=[self, self.sender_session], args=[self, self.sender_session],
) )
else:
for entry in self.config["posting"]["time"]:
dt_obj = datetime.strptime(entry, "%H:%M")
scheduler.add_job(
send_content,
"cron",
hour=dt_obj.hour,
minute=dt_obj.minute,
args=[self, self.sender_session],
)
scheduler.start()
except BadRequest:
logger.warning("Unable to send message to report chat.")
async def stop(self): async def stop(self):
makedirs(self.config["locations"]["cache"], exist_ok=True) makedirs(self.config["locations"]["cache"], exist_ok=True)
@ -229,180 +137,10 @@ class PyroClient(Client):
Path(f"{self.config['locations']['cache']}/shutdown_time"), Path(f"{self.config['locations']['cache']}/shutdown_time"),
) )
try:
await self.send_message(
chat_id=self.config["reports"]["chat_id"],
text=f"Bot stopped with PID `{getpid()}`",
)
except BadRequest:
logger.warning("Unable to send message to report chat.")
await http_session.close() await http_session.close()
await self.sender_session.close() await self.sender_session.close()
await super().stop() await super().stop()
logger.warning("Bot stopped with PID %s.", getpid())
async def collect_commands(self) -> Union[List[CommandSet], None]:
"""Gather list of the bot's commands
### Returns:
* `List[CommandSet]`: List of the commands' sets
"""
command_sets = None
# If config get bot.scoped_commands is true - more complicated
# scopes system will be used instead of simple global commands
if self.scoped_commands:
scopes = {}
command_sets = []
# Iterate through all commands in config
for command, contents in self.config["commands"].items():
# Iterate through all scopes of a command
for scope in contents["scopes"]:
if dumps(scope) not in scopes:
scopes[dumps(scope)] = {"_": []}
# Add command to the scope's flattened key in scopes dict
scopes[dumps(scope)]["_"].append(
BotCommand(command, _(command, "commands"))
)
for locale, string in (
self.in_every_locale(command, "commands")
).items():
if locale not in scopes[dumps(scope)]:
scopes[dumps(scope)][locale] = []
scopes[dumps(scope)][locale].append(BotCommand(command, string))
# Iterate through all scopes and its commands
for scope, locales in scopes.items():
# Make flat key a dict again
scope_dict = loads(scope)
# Replace "owner" in the bot scope with owner's id
if "chat_id" in scope_dict and scope_dict["chat_id"] == "owner":
scope_dict["chat_id"] = self.owner
# Create object with the same name and args from the dict
try:
scope_obj = globals()[scope_dict["name"]](
**{
key: value
for key, value in scope_dict.items()
if key != "name"
}
)
except NameError:
logger.error(
"Could not register commands of the scope '%s' due to an invalid scope class provided!",
scope_dict["name"],
)
continue
except TypeError:
logger.error(
"Could not register commands of the scope '%s' due to an invalid class arguments provided!",
scope_dict["name"],
)
continue
# Add set of commands to the list of the command sets
for locale, commands in locales.items():
if locale == "_":
command_sets.append(
CommandSet(commands, scope=scope_obj, language_code="")
)
continue
command_sets.append(
CommandSet(commands, scope=scope_obj, language_code=locale)
)
logger.info("Registering the following command sets: %s", command_sets)
else:
# This part here looks into the handlers and looks for commands
# in it, if there are any. Then adds them to self.commands
for handler in self.dispatcher.groups[0]:
if isinstance(handler, MessageHandler):
for entry in [handler.filters.base, handler.filters.other]:
if hasattr(entry, "commands"):
for command in entry.commands:
logger.info("I see a command %s in my filters", command)
self.add_command(command)
return command_sets
def add_command(
self,
command: str,
):
"""Add command to the bot's internal commands list
### Args:
* command (`str`)
"""
self.commands.append(
PyroCommand(
command,
_(command, "commands"),
)
)
logger.info(
"Added command '%s' to the bot's internal commands list",
command,
)
async def register_commands(
self, command_sets: Union[List[CommandSet], None] = None
):
"""Register commands stored in bot's 'commands' attribute"""
if command_sets is None:
commands = [
BotCommand(command=command.command, description=command.description)
for command in self.commands
]
logger.info(
"Registering commands %s with a default scope 'BotCommandScopeDefault'"
)
await self.set_bot_commands(commands)
return
for command_set in command_sets:
logger.info(
"Registering command set with commands %s and scope '%s' (%s)",
command_set.commands,
command_set.scope,
command_set.language_code,
)
await self.set_bot_commands(
command_set.commands,
command_set.scope,
language_code=command_set.language_code,
)
async def remove_commands(self, command_sets: Union[List[CommandSet], None] = None):
"""Remove commands stored in bot's 'commands' attribute"""
if command_sets is None:
logger.info(
"Removing commands with a default scope 'BotCommandScopeDefault'"
)
await self.delete_bot_commands(BotCommandScopeDefault())
return
for command_set in command_sets:
logger.info(
"Removing command set with scope '%s' (%s)",
command_set.scope,
command_set.language_code,
)
await self.delete_bot_commands(
command_set.scope,
language_code=command_set.language_code,
)
async def submit_media( async def submit_media(
self, id: str self, id: str
@ -422,7 +160,7 @@ class PyroClient(Client):
submission, file_name=self.config["locations"]["tmp"] + sep submission, file_name=self.config["locations"]["tmp"] + sep
) )
except Exception as exp: except Exception as exp:
raise SubmissionUnavailableError() raise SubmissionUnavailableError() from exp
elif not Path( elif not Path(
f"{self.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}/{db_entry['temp']['file']}", f"{self.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}/{db_entry['temp']['file']}",
@ -470,8 +208,8 @@ class PyroClient(Client):
), ),
caption="queue", caption="queue",
) )
except UnexpectedStatus: except UnexpectedStatus as exp:
raise SubmissionUnsupportedError(str(filepath)) raise SubmissionUnsupportedError(str(filepath)) from exp
response_dict = ( response_dict = (
{} {}

View File

@ -1,9 +0,0 @@
from dataclasses import dataclass
@dataclass
class PyroCommand:
"""Command stored in PyroClient's 'commands' attribute"""
command: str
description: str

View File

@ -2,6 +2,7 @@
"commands": { "commands": {
"start": "Start using the bot", "start": "Start using the bot",
"rules": "Photos submission rules", "rules": "Photos submission rules",
"report": "Report this post",
"forwards": "Check post forwards", "forwards": "Check post forwards",
"import": "Submit .zip archive with photos", "import": "Submit .zip archive with photos",
"export": "Get .zip archive with all photos", "export": "Get .zip archive with all photos",

View File

@ -2,6 +2,7 @@
"commands": { "commands": {
"start": "Почати користуватись ботом", "start": "Почати користуватись ботом",
"rules": "Правила пропонування фото", "rules": "Правила пропонування фото",
"report": "Поскаржитись на цей пост",
"forwards": "Переглянути репости", "forwards": "Переглянути репости",
"import": "Надати боту .zip архів з фотографіями", "import": "Надати боту .zip архів з фотографіями",
"export": "Отримати .zip архів з усіма фотографіями", "export": "Отримати .zip архів з усіма фотографіями",

View File

@ -22,7 +22,7 @@ with contextlib.suppress(ImportError):
def main(): def main():
client = PyroClient() client = PyroClient(scheduler=scheduler)
Conversation(client) Conversation(client)
try: try:
@ -30,7 +30,8 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
logger.warning("Forcefully shutting down with PID %s...", getpid()) logger.warning("Forcefully shutting down with PID %s...", getpid())
finally: finally:
scheduler.shutdown() if client.scheduler is not None:
client.scheduler.shutdown()
exit() exit()

View File

@ -1,17 +1,13 @@
aiofiles~=23.1.0
aiohttp~=3.8.4 aiohttp~=3.8.4
apscheduler~=3.10.1
black~=23.3.0 black~=23.3.0
convopyro==0.5 convopyro==0.5
pillow~=9.4.0 pillow~=9.4.0
psutil~=5.9.4 psutil~=5.9.4
pymongo~=4.3.3 pymongo~=4.4.0
pyrogram==2.0.106
python_dateutil==2.8.2 python_dateutil==2.8.2
pytimeparse~=1.1.8 pytimeparse~=1.1.8
tgcrypto==1.2.5 tgcrypto==1.2.5
ujson==5.8.0
uvloop==0.17.0 uvloop==0.17.0
--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple --extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
libbot[speed,pyrogram]==1.0 libbot[speed,pyrogram]==1.5
photosapi_client==0.4.0 photosapi_client==0.4.0