upd project structure

This commit is contained in:
Alexander Kalinovsky
2025-02-18 20:57:52 +01:00
parent baa55d28d6
commit 1d162677a8
62 changed files with 1305 additions and 48 deletions

5
.gitignore vendored
View File

@@ -1,6 +1,7 @@
__pycache__
/backend/.venv
.env
.pytest_cache
/backend/logs
.DS_Store
.venv
dist
*.egg-info

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Alexander Kalinovsky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# QBot - Telegram Bots Rapid Application Development (RAD) Framework
QBot is designed to quickly create complex and feature rich Telegram bots using declarative programming.
##

28
pyproject.toml Normal file
View File

@@ -0,0 +1,28 @@
[project]
name = "quickbot"
version = "0.1.0"
description = "QBot - Rapid Application Development Framework for Telegram Bots"
readme = "README.md"
requires-python = ">=3.12"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
"Development Status :: 3 - Alpha",
]
authors = [
{ name = "Alexander Kalinovsky", email = "ak@botforge.biz" },
]
license = { file = "LICENSE" }
dependencies = [
"aiogram>=3.17.0",
"babel>=2.17.0",
"fastapi[standard]>=0.115.8",
"greenlet>=3.1.1",
"pydantic-settings>=2.7.1",
"pyngrok>=7.2.3",
"pytest>=8.3.4",
"ruff>=0.9.6",
"sqlmodel>=0.0.22",
"ujson>=5.10.0",
]

View File

@@ -1,4 +1,5 @@
from .main import QBotApp as QBotApp, Config as Config
from .main import QBotApp as QBotApp
from .config import Config as Config
from .router import Router as Router
from .model.bot_entity import BotEntity as BotEntity
from .model.bot_enum import BotEnum as BotEnum, EnumMember as EnumMember
@@ -10,6 +11,7 @@ from .bot.handlers.context import (
from .model.descriptors import (
Entity as Entity,
EntityField as EntityField,
FormField as FormField,
EntityForm as EntityForm,
EntityList as EntityList,
EntityPermission as EntityPermission,

View File

@@ -15,19 +15,18 @@ from .config import Config
from .fsm.db_storage import DbStorage
from .middleware.telegram import AuthMiddleware, I18nMiddleware
from .model.user import UserBase
from .model.entity_metadata import EntityMetadata
from .model.descriptors import BotCommand
from .router import Router
logger = getLogger(__name__)
@asynccontextmanager
async def default_lifespan(app: "QBotApp"):
logger.debug("starting qbot app")
if app.lifespan_bot_init:
if app.config.USE_NGROK:
app.ngrok_init()
@@ -52,37 +51,40 @@ async def default_lifespan(app: "QBotApp"):
logger.info("qbot app stopped")
class QBotApp(FastAPI):
class QBotApp[UserType: UserBase](FastAPI):
"""
Main class for the QBot application
`QBotApp` app class, the main entrypoint to use QBot.
Derives from FastAPI.
## Example
```python
from qbot import QbotApp
app = QBotApp()
```
"""
def __init__[UserType: UserBase](
def __init__(
self,
user_class: (
Annotated[
type[UserType], Doc("User class that will be used in the application")
]
| None
) = None,
user_class: Annotated[
type[UserType],
Doc(
"""
User entity class, derived from :class:`UserBase`.
If not provided, default :class:`DefaultUser` will be used.
"""
),
] = None,
config: Config | None = None,
bot_start: (
Annotated[
Callable[
[
Annotated[
Callable[[Message, Any], None],
Doc("Default handler for the start command"),
],
Message,
Any,
],
None,
],
Doc("Handler for the start command"),
]
| None
) = None,
bot_start: Callable[
[
Callable[[Message, Any], tuple[UserType, bool]],
Message,
Any,
],
None,
] = None,
lifespan: Lifespan[AppType] | None = None,
lifespan_bot_init: bool = True,
allowed_updates: list[str] | None = None,
@@ -92,15 +94,10 @@ class QBotApp(FastAPI):
if config is None:
config = Config()
if user_class is None:
from .model.default_user import DefaultUser
user_class = DefaultUser
self.allowed_updates = allowed_updates or ["message", "callback_query"]
self.user_class = user_class
self.entity_metadata: EntityMetadata = user_class.entity_metadata
self.entity_metadata = user_class.entity_metadata
self.config = config
self.lifespan = lifespan
self.bot = Bot(
@@ -144,13 +141,11 @@ class QBotApp(FastAPI):
self.root_router._commands = self.bot_commands
self.command = self.root_router.command
def register_routers(self, *routers: Router):
for router in routers:
for command_name, command in router._commands.items():
self.bot_commands[command_name] = command
def ngrok_init(self):
try:
from pyngrok import ngrok
@@ -166,7 +161,6 @@ class QBotApp(FastAPI):
)
self.config.NGROK_URL = tunnel.public_url
def ngrok_stop(self):
try:
from pyngrok import ngrok
@@ -178,10 +172,7 @@ class QBotApp(FastAPI):
ngrok.disconnect(self.config.NGROK_URL)
ngrok.kill()
async def bot_init(self):
commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in self.bot_commands.items():
@@ -214,7 +205,5 @@ class QBotApp(FastAPI):
secret_token=self.bot_auth_token,
)
async def bot_close(self):
await self.bot.delete_webhook()

139
tests/bot.py Normal file
View File

@@ -0,0 +1,139 @@
from qbot import (
QBotApp,
BotEntity,
Entity,
EntityForm,
EntityList,
FieldEditButton,
EntityField,
CommandCallbackContext,
ContextData,
CallbackCommand,
)
from qbot.model.user import UserBase
from qbot.model.descriptors import Filter
from qbot.model.role import RoleBase
from aiogram.types import InlineKeyboardButton
from datetime import datetime
from sqlmodel import BigInteger, Field, Relationship
from typing import Optional
class User(UserBase):
bot_entity_descriptor = Entity(
icon="👤",
full_name="User",
full_name_plural="Users",
item_repr=lambda d, e: e.name,
default_list=EntityList(
show_add_new_button=False,
static_filters=[Filter("roles", "contains", value=RoleBase.SUPER_USER)],
filtering=True,
filtering_fields=["name"],
),
default_form=EntityForm(
show_delete_button=False,
show_edit_button=False,
form_buttons=[
[
FieldEditButton("name"),
FieldEditButton("is_active"),
],
[
FieldEditButton("lang"),
FieldEditButton("roles"),
],
],
item_repr=lambda d, e: f"{e.name}\n{
'is active' if e.is_active else 'is not active'
}\nlang: {e.lang.localized()}\nroles: [{
', '.join([r.localized() for r in e.roles]) if e.roles else 'none'
}]",
),
)
class Entity(BotEntity):
bot_entity_descriptor = Entity(
icon="📦",
full_name="Entity",
full_name_plural="Entities",
item_repr=lambda d, e: e.name,
default_list=EntityList(
filtering=True,
filtering_fields=["name"],
order_by="name",
),
default_form=EntityForm(
form_buttons=[
[
FieldEditButton("name"),
FieldEditButton("user"),
],
[
FieldEditButton("creation_dt"),
],
],
),
)
id: int = EntityField(
caption="ID",
sm_descriptor=Field(
primary_key=True,
),
is_visible=False,
)
name: str = EntityField(
caption="Name",
)
creation_dt: datetime = EntityField(
caption="Creation date",
dt_type="datetime",
)
user_id: int | None = EntityField(
sm_descriptor=Field(
sa_type=BigInteger,
foreign_key="user.id",
ondelete="RESTRICT",
nullable=True,
),
is_visible=False,
)
user: Optional[User] = EntityField(
sm_descriptor=Relationship(
sa_relationship_kwargs={
"lazy": "selectin",
"foreign_keys": "Entity.user_id",
}
),
caption="User",
)
app = QBotApp(
user_class=User,
)
@app.command(
name="menu",
caption="Main menu",
show_in_bot_commands=True,
clear_navigation=True,
)
async def menu(context: CommandCallbackContext):
context.message_text = "Main menu"
context.keyboard_builder.row(
InlineKeyboardButton(
text="Entities",
callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_ENTITIES,
).pack(),
)
)

0
tests/test_bot.py Normal file
View File

1071
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff