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__ __pycache__
/backend/.venv
.env .env
.pytest_cache .pytest_cache
/backend/logs
.DS_Store .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 .router import Router as Router
from .model.bot_entity import BotEntity as BotEntity from .model.bot_entity import BotEntity as BotEntity
from .model.bot_enum import BotEnum as BotEnum, EnumMember as EnumMember from .model.bot_enum import BotEnum as BotEnum, EnumMember as EnumMember
@@ -10,6 +11,7 @@ from .bot.handlers.context import (
from .model.descriptors import ( from .model.descriptors import (
Entity as Entity, Entity as Entity,
EntityField as EntityField, EntityField as EntityField,
FormField as FormField,
EntityForm as EntityForm, EntityForm as EntityForm,
EntityList as EntityList, EntityList as EntityList,
EntityPermission as EntityPermission, EntityPermission as EntityPermission,

View File

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