upd project structure
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
__pycache__
|
||||
/backend/.venv
|
||||
.env
|
||||
.pytest_cache
|
||||
/backend/logs
|
||||
.DS_Store
|
||||
.venv
|
||||
dist
|
||||
*.egg-info
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
5
README.md
Normal 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
28
pyproject.toml
Normal 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",
|
||||
]
|
||||
@@ -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,
|
||||
@@ -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
139
tests/bot.py
Normal 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
0
tests/test_bot.py
Normal file
Reference in New Issue
Block a user