Compare commits

..

1 Commits

Author SHA1 Message Date
06d006a298 Update dependency aiofiles to ~=23.2.0 2023-08-09 14:43:02 +03:00
29 changed files with 141 additions and 485 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 +1,40 @@
from typing import Union
from libbot.pyrogram.classes import PyroClient as LibPyroClient
from libbot.pyrogram.classes import PyroClient
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
from classes.pyrouser import PyroUser
from modules.database import col_users
class PyroClient(LibPyroClient):
class PyroClient(PyroClient):
async def find_user(self, user: Union[int, User]) -> PyroUser:
"""Find User by its ID or User object.
"""Find User by it's ID or User object
### Args:
* user (`Union[int, User]`): ID or User object to extract ID from.
* user (`Union[int, User]`): ID or User object to extract ID from
### Returns:
* `PyroUser`: User in database representation.
* `PyroUser`: PyroUser object
"""
if (
col_users.find_one({"id": user.id if isinstance(user, User) else user})
is None
):
col_users.insert_one(
{
"id": user.id if isinstance(user, User) else user,
"locale": user.language_code if isinstance(user, User) else None,
}
)
return (
await PyroUser.find(user)
if isinstance(user, int)
else await PyroUser.find(user.id, locale=user.language_code)
db_record = col_users.find_one(
{"id": user.id if isinstance(user, User) else user}
)
if db_record is None:
raise TypeError(
f"User with ID {user.id if isinstance(user, User) else user} was not found in the database"
)
return PyroUser(**db_record)

23
classes/pyrouser.py Normal file
View File

@@ -0,0 +1,23 @@
from typing import Union
from attrs import define
from bson import ObjectId
from modules.database import col_users
@define
class PyroUser:
"""Dataclass of DB entry of a user"""
_id: ObjectId
id: int
locale: Union[str, None]
async def update_locale(self, locale: str) -> None:
"""Change user's locale stored in the database
### Args:
* locale (`str`): New locale to be set
"""
col_users.update_one({"_id": self._id}, {"$set": {"locale": locale}})

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,5 +1,4 @@
{
"debug": false,
"locale": "en",
"bot": {
"owner": 0,
@@ -20,5 +19,15 @@
"reports": {
"chat_id": "owner"
},
"disabled_plugins": []
"disabled_plugins": [],
"commands": {
"start": {
"scopes": [
{
"name": "BotCommandScopeChat",
"chat_id": "owner"
}
]
}
}
}

View File

@@ -1,24 +1,5 @@
{
"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}"
"start": "Start using the bot"
}
}

View File

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

46
main.py
View File

@@ -1,20 +1,9 @@
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 libbot.pyrogram.classes import PyroClient
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
# Uncomment this and the line below client declaration
@@ -24,21 +13,13 @@ from modules.scheduler import scheduler
# 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,22 +28,13 @@ 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(scheduler=scheduler)
# 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()

View File

32
modules/database.py Normal file
View File

@@ -0,0 +1,32 @@
"""Module that provides all database columns"""
from pymongo import MongoClient
from ujson import loads
with open("config.json", "r", encoding="utf-8") as f:
db_config = loads(f.read())["database"]
f.close()
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 = MongoClient(con_string)
db = db_client.get_database(name=db_config["name"])
collections = db.list_collection_names()
for collection in ["users"]:
if collection not in collections:
db.create_collection(collection)
col_users = db.get_collection("users")

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,9 +1,10 @@
from pyrogram import filters
from pyrogram.client import Client
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))
@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,11 +1,12 @@
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.types import Message
from classes.pyroclient import PyroClient
@PyroClient.on_message(
@Client.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))
async def command_start(app: PyroClient, msg: Message):
await msg.reply_text("Welcome! I'm your bot!")

View File

@@ -1,12 +1,13 @@
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.types import Message
from classes.pyroclient import PyroClient
@PyroClient.on_message(
@Client.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.")
async def command_remove_commands(app: PyroClient, msg: Message):
await msg.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
@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,3 +1,4 @@
from pyrogram.client import Client
from pyrogram.types import (
InlineQuery,
InlineQueryResultArticle,
@@ -7,7 +8,7 @@ from pyrogram.types import (
from classes.pyroclient 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,24 +1,23 @@
from typing import List
from pykeyboard import InlineButton, InlineKeyboard
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.types import CallbackQuery, Message
from classes.callbacks import CallbackLanguage
from classes.pyroclient import PyroClient
@PyroClient.on_message(
@Client.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] = []
buttons = []
for locale, data in app.in_every_locale("metadata").items():
buttons.append(InlineButton(f"{data['flag']} {data['name']}", f"language:{locale}"))
buttons.append(
InlineButton(f"{data['flag']} {data['name']}", f"language:{locale}")
)
keyboard.add(*buttons)
@@ -28,16 +27,16 @@ async def command_language(app: PyroClient, message: Message):
)
@PyroClient.on_callback_query(filters.regex(r"language:[\s\S]*")) # type: ignore
@Client.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)
language = str(callback.data).split(":")[1]
await user.update_locale(parsed.language)
await user.update_locale(language)
await callback.answer(
app._("locale_set", "callbacks", locale=parsed.language).format(
locale=app._("name", "metadata", locale=parsed.language)
app._("locale_set", "callbacks", locale=language).format(
locale=app._("name", "metadata", locale=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,13 @@
apscheduler~=3.11.0
async_pymongo==0.1.9
aiofiles~=23.2.0
apscheduler~=3.10.1
attrs~=23.1.0
black~=23.7.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
pykeyboard==0.1.5
pymongo==4.4.1
pyrogram==2.0.106
tgcrypto==1.2.5
ujson==5.8.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]==2.0.0

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"
}
}
}
}