Compare commits

..

1 Commits

Author SHA1 Message Date
b9cbd53062 Update dependency libbot to v0.6 2023-05-26 15:11:42 +03:00
31 changed files with 109 additions and 586 deletions

2
.gitignore vendored
View File

@@ -155,8 +155,6 @@ cython_debug/
# Custom
config.json
*.session
*.session-wal
*.session-shm
*.session-journal
venv

View File

@@ -2,16 +2,5 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
],
"packageRules": [
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": true
}
]
}

View File

@@ -1,28 +0,0 @@
from dataclasses import dataclass
from pyrogram.types import CallbackQuery
@dataclass
class CallbackLanguage:
language: str
@classmethod
def from_callback(cls, callback: CallbackQuery):
"""Parse callback query and extract language data from it.
### Args:
* callback (`CallbackQuery`): Callback query got from user interaction.
### Raises:
* `ValueError`: Raised when callback provided is not a language one.
### Returns:
* `CallbackLanguage`: Parsed callback query.
"""
action, language = str(callback.data).split(":")
if action.lower() != "language":
raise ValueError("Callback provided is not a language callback")
return cls(language)

View File

@@ -1,27 +0,0 @@
from typing import Union
from libbot.pyrogram.classes import PyroClient as LibPyroClient
from pyrogram.types import User
# PyroClient uses MongoDB implementation of PyroUser
# but you can also select SQLite one below
# from classes.pyrouser_sqlite import PyroUser
from classes.pyrouser_mongo import PyroUser
class PyroClient(LibPyroClient):
async def find_user(self, user: Union[int, User]) -> PyroUser:
"""Find User by its ID or User object.
### Args:
* user (`Union[int, User]`): ID or User object to extract ID from.
### Returns:
* `PyroUser`: User in database representation.
"""
return (
await PyroUser.find(user)
if isinstance(user, int)
else await PyroUser.find(user.id, locale=user.language_code)
)

View File

@@ -1,55 +0,0 @@
from dataclasses import dataclass
from logging import Logger
from typing import Union
from bson import ObjectId
from modules.database_mongo import col_users
from modules.logging_utils import get_logger
logger: Logger = get_logger(__name__)
@dataclass
class PyroUser:
"""Dataclass of DB entry of a user"""
__slots__ = ("_id", "id", "locale")
_id: ObjectId
id: int
locale: Union[str, None]
@classmethod
async def find(cls, id: int, locale: Union[str, None] = None):
"""Find user in database and create new record if user does not exist.
### Args:
* id (`int`): User's Telegram ID
* locale (`Union[str, None]`, *optional*): User's locale. Defaults to `None`.
### Raises:
* `RuntimeError`: Raised when user entry after insertion could not be found.
### Returns:
* `PyroUser`: User with its database data.
"""
db_entry = await col_users.find_one({"id": id})
if db_entry is None:
inserted = await col_users.insert_one({"id": id, "locale": locale})
db_entry = await col_users.find_one({"_id": inserted.inserted_id})
if db_entry is None:
raise RuntimeError("Could not find inserted user entry.")
return cls(**db_entry)
async def update_locale(self, locale: Union[str, None]) -> None:
"""Change user's locale stored in the database.
### Args:
* locale (`Union[str, None]`): New locale to be set.
"""
logger.debug("%s's locale has been set to %s", self.id, locale)
await col_users.update_one({"_id": self._id}, {"$set": {"locale": locale}})

View File

@@ -1,57 +0,0 @@
import logging
from dataclasses import dataclass
from typing import Union
from modules.database_sqlite import cursor
from modules.logging_utils import get_logger
logger: logging.Logger = get_logger(__name__)
@dataclass
class PyroUser:
"""Dataclass of DB entry of a user"""
__slots__ = ("id", "locale")
id: int
locale: Union[str, None]
@classmethod
async def find(cls, id: int, locale: Union[str, None] = None):
"""Find user in database and create new record if user does not exist.
### Args:
* id (`int`): User's Telegram ID
* locale (`Union[str, None]`, *optional*): User's locale. Defaults to `None`.
### Raises:
* `RuntimeError`: Raised when user entry after insertion could not be found.
### Returns:
* `PyroUser`: User with its database data.
"""
db_entry = cursor.execute("SELECT id, locale FROM users WHERE id = ?", (id,)).fetchone()
if db_entry is None:
cursor.execute("INSERT INTO users VALUES (?, ?)", (id, locale))
cursor.connection.commit()
db_entry = cursor.execute("SELECT id, locale FROM users WHERE id = ?", (id,)).fetchone()
if db_entry is None:
raise RuntimeError("Could not find inserted user entry.")
return cls(*db_entry)
async def update_locale(self, locale: Union[str, None]) -> None:
"""Change user's locale stored in the database.
### Args:
* locale (`Union[str, None]`): New locale to be set.
"""
logger.debug("%s's locale has been set to %s", self.id, locale)
cursor.execute(
"UPDATE users SET locale = ? WHERE id = ?",
(locale, self.id),
)
cursor.connection.commit()

View File

@@ -1,29 +0,0 @@
{
"start": {
"scopes": [
{
"name": "BotCommandScopeDefault"
},
{
"name": "BotCommandScopeChat",
"chat_id": "owner"
}
]
},
"shutdown": {
"scopes": [
{
"name": "BotCommandScopeChat",
"chat_id": "owner"
}
]
},
"remove_commands": {
"scopes": [
{
"name": "BotCommandScopeChat",
"chat_id": "owner"
}
]
}
}

View File

@@ -1,24 +1,12 @@
{
"debug": false,
"locale": "en",
"bot": {
"owner": 0,
"api_id": 0,
"api_hash": "",
"bot_token": "",
"workers": 1,
"max_concurrent_transmissions": 1,
"scoped_commands": true
},
"database": {
"user": null,
"password": null,
"host": "127.0.0.1",
"port": 27017,
"name": "pyrobot"
"workers": 1
},
"disabled_plugins": [],
"reports": {
"chat_id": "owner"
},
"disabled_plugins": []
"chat_id": 0
}
}

View File

@@ -1,24 +0,0 @@
{
"metadata": {
"flag": "🇬🇧",
"name": "English",
"codes": [
"en",
"en-US",
"en-GB"
]
},
"commands": {
"start": "Start using the bot",
"shutdown": "Turn off the bot",
"language": "Change bot's language",
"remove_commands": "Unregister all commands"
},
"messages": {
"start": "Welcome! I'm your bot!",
"locale_choice": "Alright. Please choose the language using keyboard below."
},
"callbacks": {
"locale_set": "Your language now is: {locale}"
}
}

View File

@@ -1,23 +0,0 @@
{
"metadata": {
"flag": "🇺🇦",
"name": "Українська",
"codes": [
"uk",
"uk-UA"
]
},
"commands": {
"start": "Почати користуватись ботом",
"shutdown": "Вимкнути бота",
"language": "Змінити мову бота",
"remove_commands": "Видалити всі команди"
},
"messages": {
"start": "Привіт! Я твій бот!",
"locale_choice": "Гаразд. Будь ласка, оберіть мову за допомогою клавіатури нижче."
},
"callbacks": {
"locale_set": "Встановлено мову: {locale}"
}
}

52
main.py
View File

@@ -1,44 +1,21 @@
import contextlib
import logging.config
from argparse import ArgumentParser
from logging import Logger
from os import getpid, makedirs
from pathlib import Path
from sys import exit
import logging
from os import getpid
from libbot.utils import json_read
from classes.pyroclient import PyroClient
from modules.logging_utils import get_logger, get_logging_config
# Main uses MongoDB implementation of DB,
# but you can also select SQLite one below
# from modules.migrator_sqlite import migrate_database
from modules.migrator_mongo import migrate_database
from modules.scheduler import scheduler
from modules.app import PyroClient
# Uncomment this and the line below client declaration
# in order to use context manager in your commands.
# Don't forget to disable PyroClient workers if you want
# to use convopyro, because workers create more event loops.
# from convopyro import Conversation
makedirs(Path("logs/"), exist_ok=True)
logging.config.dictConfig(get_logging_config())
logger: Logger = get_logger(__name__)
parser = ArgumentParser(
prog="__name__",
description="__description__",
logging.basicConfig(
level=logging.INFO,
format="%(name)s.%(funcName)s | %(levelname)s | %(message)s",
datefmt="[%X]",
)
# Remove if no database is being used
parser.add_argument("--migrate", action="store_true")
args = parser.parse_args()
logger = logging.getLogger(__name__)
with contextlib.suppress(ImportError):
import uvloop
@@ -47,25 +24,14 @@ with contextlib.suppress(ImportError):
def main():
# Remove if no database is being used
if args.migrate:
migrate_database()
logger.info("Migration finished. Exiting...")
exit()
client = PyroClient(scheduler=scheduler, commands_source=json_read(Path("commands.json")))
client = PyroClient()
# Conversation(client)
try:
client.run()
except KeyboardInterrupt:
logger.warning("Forcefully shutting down with PID %s...", getpid())
except Exception as exc:
logger.error("An unexpected exception has occurred: %s", exc, exc_info=exc)
exit(1)
finally:
if client.scheduler is not None:
client.scheduler.shutdown()
exit()

View File

60
modules/app.py Normal file
View File

@@ -0,0 +1,60 @@
import logging
from os import getpid
from time import time
import pyrogram
from libbot import config_get
from pyrogram.client import Client
from pyrogram.errors import BadRequest
from pyrogram.raw.all import layer
from ujson import loads
logger = logging.getLogger(__name__)
class PyroClient(Client):
def __init__(self):
with open("config.json", "r", encoding="utf-8") as f:
config = loads(f.read())
super().__init__(
name="bot_client",
api_id=config["bot"]["api_id"],
api_hash=config["bot"]["api_hash"],
bot_token=config["bot"]["bot_token"],
workers=config["bot"]["workers"],
plugins=dict(root="plugins", exclude=config["disabled_plugins"]),
sleep_threshold=120,
)
self.start_time = 0
async def start(self):
await super().start()
self.start_time = time()
logger.info(
"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:
await self.send_message(
chat_id=await config_get("chat_id", "reports"),
text=f"Bot started PID `{getpid()}`",
)
except BadRequest:
logger.warning("Unable to send message to report chat.")
async def stop(self):
try:
await self.send_message(
chat_id=await config_get("chat_id", "reports"),
text=f"Bot stopped with PID `{getpid()}`",
)
except BadRequest:
logger.warning("Unable to send message to report chat.")
await super().stop()
logger.warning("Bot stopped with PID %s.", getpid())

View File

@@ -1,24 +0,0 @@
"""Module that provides all database collections"""
from typing import Any, Mapping
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
from libbot.utils import config_get
db_config: Mapping[str, Any] = config_get("database")
if db_config["user"] is not None and db_config["password"] is not None:
con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format(
db_config["user"],
db_config["password"],
db_config["host"],
db_config["port"],
db_config["name"],
)
else:
con_string = "mongodb://{0}:{1}/{2}".format(db_config["host"], db_config["port"], db_config["name"])
db_client = AsyncClient(con_string)
db: AsyncDatabase = db_client.get_database(name=db_config["name"])
col_users: AsyncCollection = db.get_collection("users")

View File

@@ -1,11 +0,0 @@
"""Module that provides database access"""
import sqlite3
from pathlib import Path
from libbot.utils import config_get
db: sqlite3.Connection = sqlite3.connect(Path(config_get("database")))
cursor: sqlite3.Cursor = db.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER, card TEXT, locale TEXT)")

View File

@@ -1,35 +0,0 @@
import logging
from logging import Logger
from pathlib import Path
from typing import Any, Dict
from libbot.utils import config_get
def get_logging_config() -> Dict[str, Any]:
return {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(Path("logs/latest.log")),
"maxBytes": 500000,
"backupCount": 10,
"formatter": "simple",
},
"console": {"class": "logging.StreamHandler", "formatter": "systemd"},
},
"formatters": {
"simple": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"},
"systemd": {"format": "%(name)s - %(levelname)s - %(message)s"},
},
"root": {
"level": "DEBUG" if config_get("debug") else "INFO",
"handlers": ["file", "console"],
},
}
def get_logger(name: str) -> Logger:
return logging.getLogger(name)

View File

@@ -1,22 +0,0 @@
from typing import Any, Mapping
from libbot.utils import config_get
from mongodb_migrations.cli import MigrationManager
from mongodb_migrations.config import Configuration
def migrate_database() -> None:
"""Apply migrations from folder `migrations/` to the database"""
db_config: Mapping[str, Any] = config_get("database")
manager_config = Configuration(
{
"mongo_host": db_config["host"],
"mongo_port": db_config["port"],
"mongo_database": db_config["name"],
"mongo_username": db_config["user"],
"mongo_password": db_config["password"],
}
)
manager = MigrationManager(manager_config)
manager.run()

View File

@@ -1,24 +0,0 @@
from os import rename
from pathlib import Path
from typing import Mapping
from libbot.utils import json_read
from modules.database_sqlite import cursor
def migrate_database() -> None:
"""Apply migrations from old JSON database to SQLite"""
if not Path("data/database.json").exists():
return
db_old: Mapping[str, Mapping[str, str]] = json_read(Path("data/database.json"))
for user, keys in db_old.items():
user_locale = None if "locale" not in keys else keys["locale"]
user_card = None if "card" not in keys else keys["card"]
cursor.execute("INSERT INTO users VALUES (?, ?)", (int(user), user_card, user_locale))
cursor.connection.commit()
rename(Path("data/database.json"), Path("data/database.migrated.json"))

View File

@@ -1,3 +0,0 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()

10
plugins/callback.py Normal file
View File

@@ -0,0 +1,10 @@
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.types import CallbackQuery
from modules.app import PyroClient
@Client.on_callback_query(filters.regex("nothing")) # type: ignore
async def callback_nothing(app: PyroClient, clb: CallbackQuery):
await clb.answer(text="Nothing here...")

View File

@@ -1,9 +0,0 @@
from pyrogram import filters
from pyrogram.types import CallbackQuery
from classes.pyroclient import PyroClient
@PyroClient.on_callback_query(filters.regex("nothing")) # type: ignore
async def callback_nothing(app: PyroClient, callback: CallbackQuery):
await callback.answer(text=app._("nothing", "callbacks", locale=callback.from_user.language_code))

12
plugins/command.py Normal file
View File

@@ -0,0 +1,12 @@
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.types import Message
from modules.app import PyroClient
@Client.on_message(
~filters.scheduled & filters.private & filters.command(["start"], prefixes=["/"]) # type: ignore
)
async def command_start(app: PyroClient, msg: Message):
await msg.reply_text("Welcome! I'm your bot!")

View File

@@ -1,11 +0,0 @@
from pyrogram import filters
from pyrogram.types import Message
from classes.pyroclient import PyroClient
@PyroClient.on_message(
~filters.scheduled & filters.private & filters.command(["start"], prefixes=["/"]) # type: ignore
)
async def command_start(app: PyroClient, message: Message):
await message.reply_text(app._("start", "messages", locale=message.from_user.language_code))

View File

@@ -1,12 +0,0 @@
from pyrogram import filters
from pyrogram.types import Message
from classes.pyroclient import PyroClient
@PyroClient.on_message(
~filters.scheduled & filters.private & filters.command(["remove_commands"], prefixes=["/"]) # type: ignore
)
async def command_remove_commands(app: PyroClient, message: Message):
await message.reply_text("Okay.")
await app.remove_commands(command_sets=await app.collect_commands())

View File

@@ -1,16 +0,0 @@
import asyncio
from pyrogram import filters
from pyrogram.types import Message
from classes.pyroclient import PyroClient
@PyroClient.on_message(
~filters.scheduled
& filters.private
& filters.command(["shutdown", "reboot", "restart"], prefixes=["/"]) # type: ignore
)
async def command_shutdown(app: PyroClient, msg: Message):
if msg.from_user.id == app.owner:
asyncio.get_event_loop().create_task(app.stop())

View File

@@ -1,9 +1,10 @@
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.types import Message
from classes.pyroclient import PyroClient
from modules.app import PyroClient
@PyroClient.on_message(filters.text & filters.private) # type: ignore
@Client.on_message(filters.text & filters.private) # type: ignore
async def handler_echo(app: PyroClient, message: Message):
await message.reply(message.text[::-1])

View File

@@ -1,13 +1,14 @@
from pyrogram.client import Client
from pyrogram.types import (
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
)
from classes.pyroclient import PyroClient
from modules.app import PyroClient
@PyroClient.on_inline_query() # type: ignore
@Client.on_inline_query() # type: ignore
async def inline(app: PyroClient, inline_query: InlineQuery):
await inline_query.answer(
results=[

View File

@@ -1,43 +0,0 @@
from typing import List
from pykeyboard import InlineButton, InlineKeyboard
from pyrogram import filters
from pyrogram.types import CallbackQuery, Message
from classes.callbacks import CallbackLanguage
from classes.pyroclient import PyroClient
@PyroClient.on_message(
~filters.scheduled & filters.private & filters.command(["language"], prefixes=["/"]) # type: ignore
)
async def command_language(app: PyroClient, message: Message):
user = await app.find_user(message.from_user)
keyboard = InlineKeyboard(row_width=2)
buttons: List[InlineButton] = []
for locale, data in app.in_every_locale("metadata").items():
buttons.append(InlineButton(f"{data['flag']} {data['name']}", f"language:{locale}"))
keyboard.add(*buttons)
await message.reply_text(
app._("locale_choice", "messages", locale=user.locale),
reply_markup=keyboard,
)
@PyroClient.on_callback_query(filters.regex(r"language:[\s\S]*")) # type: ignore
async def callback_language(app: PyroClient, callback: CallbackQuery):
user = await app.find_user(callback.from_user)
parsed = CallbackLanguage.from_callback(callback)
await user.update_locale(parsed.language)
await callback.answer(
app._("locale_set", "callbacks", locale=parsed.language).format(
locale=app._("name", "metadata", locale=parsed.language)
),
show_alert=True,
)

View File

@@ -1,25 +0,0 @@
[project]
name = "PyrogramBotBase"
authors = [{ name = "Profitroll" }]
readme = "README.md"
requires-python = ">=3.11"
[tool.black]
line-length = 108
target-version = ["py311", "py312", "py313"]
[tool.isort]
profile = "black"
[tool.mypy]
namespace_packages = true
install_types = true
strict = true
show_error_codes = true
[tool.pylint]
disable = ["line-too-long"]
[tool.pylint.main]
extension-pkg-whitelist = ["ujson"]
py-version = 3.11

View File

@@ -1,10 +1,10 @@
apscheduler~=3.11.0
async_pymongo==0.1.9
aiofiles~=23.1.0
apscheduler~=3.10.1
black~=23.3.0
convopyro==0.5
libbot[speed,pyrogram]==4.1.0
tgcrypto-pyrofork==1.2.7
uvloop==0.21.0
# If uses MongoDB:
mongodb-migrations==1.3.1
pyrogram==2.0.106
tgcrypto==1.2.5
ujson==5.7.0
uvloop==0.17.0
--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
pykeyboard==0.1.7
libbot[speed,pyrogram]==0.6

View File

@@ -1,24 +0,0 @@
{
"$jsonSchema": {
"required": [
"id",
"locale"
],
"properties": {
"user": {
"bsonType": [
"int",
"long"
],
"description": "Telegram ID of user"
},
"locale": {
"bsonType": [
"string",
"null"
],
"description": "Preferred messages language according to user's preference"
}
}
}
}