vitrine-tg 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vitrine_tg-0.1.0/LICENSE +21 -0
- vitrine_tg-0.1.0/PKG-INFO +192 -0
- vitrine_tg-0.1.0/README.md +162 -0
- vitrine_tg-0.1.0/pyproject.toml +64 -0
- vitrine_tg-0.1.0/src/vitrine/__init__.py +106 -0
- vitrine_tg-0.1.0/src/vitrine/app.py +443 -0
- vitrine_tg-0.1.0/src/vitrine/args.py +145 -0
- vitrine_tg-0.1.0/src/vitrine/auth.py +150 -0
- vitrine_tg-0.1.0/src/vitrine/callbacks.py +226 -0
- vitrine_tg-0.1.0/src/vitrine/commands.py +85 -0
- vitrine_tg-0.1.0/src/vitrine/conversations.py +369 -0
- vitrine_tg-0.1.0/src/vitrine/dispatch.py +218 -0
- vitrine_tg-0.1.0/src/vitrine/errors.py +145 -0
- vitrine_tg-0.1.0/src/vitrine/exceptions.py +84 -0
- vitrine_tg-0.1.0/src/vitrine/injection.py +227 -0
- vitrine_tg-0.1.0/src/vitrine/logging.py +70 -0
- vitrine_tg-0.1.0/src/vitrine/markdown.py +252 -0
- vitrine_tg-0.1.0/src/vitrine/media.py +85 -0
- vitrine_tg-0.1.0/src/vitrine/middleware.py +54 -0
- vitrine_tg-0.1.0/src/vitrine/pagination.py +114 -0
- vitrine_tg-0.1.0/src/vitrine/py.typed +0 -0
- vitrine_tg-0.1.0/src/vitrine/ratelimit.py +128 -0
- vitrine_tg-0.1.0/src/vitrine/routing.py +211 -0
- vitrine_tg-0.1.0/src/vitrine/screens.py +597 -0
- vitrine_tg-0.1.0/src/vitrine/workers.py +139 -0
vitrine_tg-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 target111
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vitrine-tg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A batteries-included framework on top of python-telegram-bot: typed callbacks, screens, DI, auth, conversations, and supervised workers.
|
|
5
|
+
Keywords: telegram,bot,framework,python-telegram-bot,asyncio
|
|
6
|
+
Author: target111
|
|
7
|
+
Author-email: target111 <target111@protonmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Communications :: Chat
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Dist: pydantic>=2.13.4
|
|
23
|
+
Requires-Dist: python-telegram-bot[job-queue]>=22.8
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Project-URL: Homepage, https://github.com/target111/vitrine
|
|
26
|
+
Project-URL: Repository, https://github.com/target111/vitrine
|
|
27
|
+
Project-URL: Issues, https://github.com/target111/vitrine/issues
|
|
28
|
+
Project-URL: Changelog, https://github.com/target111/vitrine/blob/main/CHANGELOG.md
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# vitrine
|
|
32
|
+
|
|
33
|
+
[](https://github.com/target111/vitrine/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/vitrine-tg/)
|
|
35
|
+
|
|
36
|
+
A framework for building Telegram bots with [python-telegram-bot](https://python-telegram-bot.org/) that doesn't get in your way.
|
|
37
|
+
|
|
38
|
+
Handlers are just async functions. You get dependency injection, auth, robust message delivery, background workers, typed callbacks, rate limiting, and decent error handling -- the stuff every real bot needs. Everything from PTB stays accessible.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from vitrine import Bot, Button, CallbackData, Screen
|
|
42
|
+
|
|
43
|
+
bot = Bot(token="...")
|
|
44
|
+
|
|
45
|
+
class MenuCB(CallbackData, prefix="menu"):
|
|
46
|
+
section: str
|
|
47
|
+
|
|
48
|
+
@bot.command("start", description="Open the menu")
|
|
49
|
+
async def start(update):
|
|
50
|
+
return Screen(text="Welcome!", keyboard=[[Button("Shop", callback=MenuCB(section="shop"))]])
|
|
51
|
+
|
|
52
|
+
@bot.callback(MenuCB)
|
|
53
|
+
async def menu(data: MenuCB): # decoded, validated payload injected
|
|
54
|
+
return Screen(text=f"Section: {data.section}")
|
|
55
|
+
|
|
56
|
+
bot.run()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv add vitrine-tg
|
|
63
|
+
# or
|
|
64
|
+
pip install vitrine-tg
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The distribution is named `vitrine-tg`; the import name is `vitrine`.
|
|
68
|
+
|
|
69
|
+
## Local development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv sync # install deps (PTB 22, pydantic 2)
|
|
73
|
+
uv run pytest # run tests
|
|
74
|
+
BOT_TOKEN=... uv run python examples/small_bot.py
|
|
75
|
+
BOT_TOKEN=... uv run python examples/launcher_bot.py
|
|
76
|
+
BOT_TOKEN=... ADMIN_IDS=123 uv run python examples/shop/main.py
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Core features
|
|
80
|
+
|
|
81
|
+
### Dependency injection (`vitrine.injection`)
|
|
82
|
+
|
|
83
|
+
Handlers declare what they need by parameter name; the framework supplies it. Framework values (`update`, `context`, `bot`, `data`, `state`, `event`, `delivery`), your registered providers, command args, and middleware extras all live in one namespace:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
bot.provide_value("orders", OrderService(...)) # constants / singletons
|
|
87
|
+
|
|
88
|
+
@bot.provide("session") # factories; may be async
|
|
89
|
+
async def session(db): # ...and depend on each other
|
|
90
|
+
async with db.begin() as s:
|
|
91
|
+
yield s # cleanup after handler
|
|
92
|
+
|
|
93
|
+
@bot.callback(OrderCB)
|
|
94
|
+
async def view_order(data: OrderCB, user: User, orders: OrderService, session):
|
|
95
|
+
...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Dependencies are resolved once per handler call. Bad parameter names fail at startup, not production. `Depends(fn)` is available for explicit one-offs.
|
|
99
|
+
|
|
100
|
+
### Identity & auth (`vitrine.auth`)
|
|
101
|
+
|
|
102
|
+
You define the principal type. The framework handles resolve-once-per-update, caching, injection, and guards:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
auth = Auth(resolve_user, name="user",
|
|
106
|
+
roles=lambda u: u.roles, is_banned=lambda u: u.banned)
|
|
107
|
+
bot = Bot(token, auth=auth)
|
|
108
|
+
|
|
109
|
+
@bot.command("refund", scope="admin")
|
|
110
|
+
@requires("support") # or @admin_only
|
|
111
|
+
async def refund(user: User, order_id: int): ...
|
|
112
|
+
|
|
113
|
+
@bot.command("profile")
|
|
114
|
+
@requires_principal # resolver returned None? -> "not registered" UX
|
|
115
|
+
async def profile(user: User): ...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Bans are enforced bot-wide. Guard failures turn into friendly error messages: a caller with no resolvable principal gets `NotRegisteredError` (point them at /start), one missing a role gets `NotAuthorizedError`. Any handler asking for `user` gets the same instance -- no re-fetching during an update.
|
|
119
|
+
|
|
120
|
+
### Screens & delivery (`vitrine.screens`)
|
|
121
|
+
|
|
122
|
+
A `Screen` is a value object (text + keyboard + media + options) that doesn't need an `Update` -- unit-test your views by just calling them. `Delivery` sends it three ways: reply, edit, or proactively to any chat. It handles the annoying stuff:
|
|
123
|
+
|
|
124
|
+
- text↔media transitions send the new message first, then delete the old one
|
|
125
|
+
- all media types are detected (photo/video/animation/document/audio/voice/video-note/sticker)
|
|
126
|
+
- uploads are cached by content hash and re-sent as `file_id`; rejected IDs trigger exactly one retry
|
|
127
|
+
- "message is not modified" errors are silently skipped; `fresh=True` forces a new message anyway
|
|
128
|
+
|
|
129
|
+
Return a `Screen` from a handler and it renders automatically (edit for buttons, reply for commands). Or call `screen.render(update, context)` and `delivery.send(chat_id, screen)` manually.
|
|
130
|
+
|
|
131
|
+
Screens can also carry a **persistent reply keyboard** — the launcher pattern:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
LAUNCHER = ReplyKeyboard([["🛍 Shop", "ℹ️ Help"]]) # persistent by default
|
|
135
|
+
|
|
136
|
+
@bot.command("start")
|
|
137
|
+
async def start():
|
|
138
|
+
return Screen(text="Welcome!", reply_keyboard=LAUNCHER) # set once, sticks around
|
|
139
|
+
|
|
140
|
+
@bot.reply_button("🛍 Shop") # presses route like messages
|
|
141
|
+
async def shop():
|
|
142
|
+
return shop_screen() # jump here from anywhere
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A message carries an inline *or* a reply keyboard, never both, and Telegram can't attach reply keyboards to edits — `Delivery` turns such edits into replaces automatically. `Screen(reply_keyboard=REMOVE_REPLY_KEYBOARD)` takes the keyboard away. See `examples/launcher_bot.py`.
|
|
146
|
+
|
|
147
|
+
### Lifecycle & workers (`vitrine.workers`)
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
@bot.on_startup
|
|
151
|
+
async def warmup(delivery): ...
|
|
152
|
+
|
|
153
|
+
@bot.worker(every=30)
|
|
154
|
+
async def reconcile(orders, delivery):
|
|
155
|
+
for o in await orders.confirmed():
|
|
156
|
+
await delivery.send(o.chat_id, receipt_screen(o))
|
|
157
|
+
|
|
158
|
+
@bot.worker()
|
|
159
|
+
async def chain_watcher(feed): ...
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Workers get DI, start after init, and shut down gracefully. Crashes restart automatically with exponential backoff -- no manual task supervision needed.
|
|
163
|
+
|
|
164
|
+
## Features
|
|
165
|
+
|
|
166
|
+
| Feature | Module | Details |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| Typed callbacks | `callbacks` | Pydantic models with a prefix. Stale/corrupt data returns "button expired" instead of crashing. Keyed encoding (`keyed=True`) uses query strings and tolerates schema changes; `unpack()` auto-detects either format so live buttons survive upgrades. |
|
|
169
|
+
| Reply keyboards | `screens` | `ReplyKeyboard` value object (persistent + resized by default), `@bot.reply_button("label")` routes presses through the full pipeline, `REMOVE_REPLY_KEYBOARD` clears it. |
|
|
170
|
+
| Markdown builder | `markdown` | Composable/nestable nodes, safe escaping for V1+V2, `raw()` escape hatch. |
|
|
171
|
+
| Routers | `routing` | `@router.command/callback/message`, sub-routers, `router.raw()` for plain PTB handlers. |
|
|
172
|
+
| Command args | `args` | Typed params from the signature; required/optional/`Greedy`; auto usage messages. |
|
|
173
|
+
| Pagination | `pagination` | Implement `count()`/`fetch(offset, limit)`, use `Paginator` and `nav_row()` buttons. |
|
|
174
|
+
| Conversations | `conversations` | Dataclass state per run, string transitions, timeout, `on_exit(reason)` hooks. Full DI/middleware/principal interop. |
|
|
175
|
+
| Files/media | `media` | `download()` with timeout and cleanup; content-hash `file_id` cache shared with Screen rendering. |
|
|
176
|
+
| Rate limiting | `ratelimit` | `@throttle(3, per=60)`, custom keys and custom behavior on limits. |
|
|
177
|
+
| Logging | `logging` | Key=value format, one line per update, `audit()` convention. |
|
|
178
|
+
| Errors | `errors` | `@bot.on_error(Type)` registry dispatched by MRO. Handlers can return a `Screen` to render into the current message. |
|
|
179
|
+
| Command discovery | `commands` | Auto `/help` filtered by caller's scopes. `set_my_commands()` per scope. `hidden=True` for internal handlers. |
|
|
180
|
+
| Middleware | `middleware` | `async def mw(event, call_next)` at bot or router scope. `event.extras` values become injectable. |
|
|
181
|
+
|
|
182
|
+
## Example: scaled mode
|
|
183
|
+
|
|
184
|
+
`examples/shop/` is a full app: a storefront with a **domain layer that never touches the bot layer** (`domain/`), views as pure functions (`domain -> Screen`), services injected into handlers, a `User` principal for guards and menus, a purchase conversation, admin commands, and a reconciler worker that messages buyers. Only `main.py` knows all the layers.
|
|
185
|
+
|
|
186
|
+
## Escape hatches
|
|
187
|
+
|
|
188
|
+
Not a fork or parallel dispatcher. `bot.build()` returns the PTB `Application` for webhooks. `router.raw(handler, group=...)` registers plain PTB handlers. `Screen.extra` passes kwargs to PTB's send methods. Everything from PTB stays accessible.
|
|
189
|
+
|
|
190
|
+
## Testing
|
|
191
|
+
|
|
192
|
+
Views are pure functions -- test them without a live bot. `Screen.content()`/`markup()` show what would be sent. `Delivery` accepts any object with `send`/`edit` methods (see `tests/conftest.py` for a mock). This repo has 102 tests that exercise dispatch and conversations the same way you can.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# vitrine
|
|
2
|
+
|
|
3
|
+
[](https://github.com/target111/vitrine/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/vitrine-tg/)
|
|
5
|
+
|
|
6
|
+
A framework for building Telegram bots with [python-telegram-bot](https://python-telegram-bot.org/) that doesn't get in your way.
|
|
7
|
+
|
|
8
|
+
Handlers are just async functions. You get dependency injection, auth, robust message delivery, background workers, typed callbacks, rate limiting, and decent error handling -- the stuff every real bot needs. Everything from PTB stays accessible.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from vitrine import Bot, Button, CallbackData, Screen
|
|
12
|
+
|
|
13
|
+
bot = Bot(token="...")
|
|
14
|
+
|
|
15
|
+
class MenuCB(CallbackData, prefix="menu"):
|
|
16
|
+
section: str
|
|
17
|
+
|
|
18
|
+
@bot.command("start", description="Open the menu")
|
|
19
|
+
async def start(update):
|
|
20
|
+
return Screen(text="Welcome!", keyboard=[[Button("Shop", callback=MenuCB(section="shop"))]])
|
|
21
|
+
|
|
22
|
+
@bot.callback(MenuCB)
|
|
23
|
+
async def menu(data: MenuCB): # decoded, validated payload injected
|
|
24
|
+
return Screen(text=f"Section: {data.section}")
|
|
25
|
+
|
|
26
|
+
bot.run()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add vitrine-tg
|
|
33
|
+
# or
|
|
34
|
+
pip install vitrine-tg
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The distribution is named `vitrine-tg`; the import name is `vitrine`.
|
|
38
|
+
|
|
39
|
+
## Local development
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv sync # install deps (PTB 22, pydantic 2)
|
|
43
|
+
uv run pytest # run tests
|
|
44
|
+
BOT_TOKEN=... uv run python examples/small_bot.py
|
|
45
|
+
BOT_TOKEN=... uv run python examples/launcher_bot.py
|
|
46
|
+
BOT_TOKEN=... ADMIN_IDS=123 uv run python examples/shop/main.py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Core features
|
|
50
|
+
|
|
51
|
+
### Dependency injection (`vitrine.injection`)
|
|
52
|
+
|
|
53
|
+
Handlers declare what they need by parameter name; the framework supplies it. Framework values (`update`, `context`, `bot`, `data`, `state`, `event`, `delivery`), your registered providers, command args, and middleware extras all live in one namespace:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
bot.provide_value("orders", OrderService(...)) # constants / singletons
|
|
57
|
+
|
|
58
|
+
@bot.provide("session") # factories; may be async
|
|
59
|
+
async def session(db): # ...and depend on each other
|
|
60
|
+
async with db.begin() as s:
|
|
61
|
+
yield s # cleanup after handler
|
|
62
|
+
|
|
63
|
+
@bot.callback(OrderCB)
|
|
64
|
+
async def view_order(data: OrderCB, user: User, orders: OrderService, session):
|
|
65
|
+
...
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Dependencies are resolved once per handler call. Bad parameter names fail at startup, not production. `Depends(fn)` is available for explicit one-offs.
|
|
69
|
+
|
|
70
|
+
### Identity & auth (`vitrine.auth`)
|
|
71
|
+
|
|
72
|
+
You define the principal type. The framework handles resolve-once-per-update, caching, injection, and guards:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
auth = Auth(resolve_user, name="user",
|
|
76
|
+
roles=lambda u: u.roles, is_banned=lambda u: u.banned)
|
|
77
|
+
bot = Bot(token, auth=auth)
|
|
78
|
+
|
|
79
|
+
@bot.command("refund", scope="admin")
|
|
80
|
+
@requires("support") # or @admin_only
|
|
81
|
+
async def refund(user: User, order_id: int): ...
|
|
82
|
+
|
|
83
|
+
@bot.command("profile")
|
|
84
|
+
@requires_principal # resolver returned None? -> "not registered" UX
|
|
85
|
+
async def profile(user: User): ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Bans are enforced bot-wide. Guard failures turn into friendly error messages: a caller with no resolvable principal gets `NotRegisteredError` (point them at /start), one missing a role gets `NotAuthorizedError`. Any handler asking for `user` gets the same instance -- no re-fetching during an update.
|
|
89
|
+
|
|
90
|
+
### Screens & delivery (`vitrine.screens`)
|
|
91
|
+
|
|
92
|
+
A `Screen` is a value object (text + keyboard + media + options) that doesn't need an `Update` -- unit-test your views by just calling them. `Delivery` sends it three ways: reply, edit, or proactively to any chat. It handles the annoying stuff:
|
|
93
|
+
|
|
94
|
+
- text↔media transitions send the new message first, then delete the old one
|
|
95
|
+
- all media types are detected (photo/video/animation/document/audio/voice/video-note/sticker)
|
|
96
|
+
- uploads are cached by content hash and re-sent as `file_id`; rejected IDs trigger exactly one retry
|
|
97
|
+
- "message is not modified" errors are silently skipped; `fresh=True` forces a new message anyway
|
|
98
|
+
|
|
99
|
+
Return a `Screen` from a handler and it renders automatically (edit for buttons, reply for commands). Or call `screen.render(update, context)` and `delivery.send(chat_id, screen)` manually.
|
|
100
|
+
|
|
101
|
+
Screens can also carry a **persistent reply keyboard** — the launcher pattern:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
LAUNCHER = ReplyKeyboard([["🛍 Shop", "ℹ️ Help"]]) # persistent by default
|
|
105
|
+
|
|
106
|
+
@bot.command("start")
|
|
107
|
+
async def start():
|
|
108
|
+
return Screen(text="Welcome!", reply_keyboard=LAUNCHER) # set once, sticks around
|
|
109
|
+
|
|
110
|
+
@bot.reply_button("🛍 Shop") # presses route like messages
|
|
111
|
+
async def shop():
|
|
112
|
+
return shop_screen() # jump here from anywhere
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A message carries an inline *or* a reply keyboard, never both, and Telegram can't attach reply keyboards to edits — `Delivery` turns such edits into replaces automatically. `Screen(reply_keyboard=REMOVE_REPLY_KEYBOARD)` takes the keyboard away. See `examples/launcher_bot.py`.
|
|
116
|
+
|
|
117
|
+
### Lifecycle & workers (`vitrine.workers`)
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
@bot.on_startup
|
|
121
|
+
async def warmup(delivery): ...
|
|
122
|
+
|
|
123
|
+
@bot.worker(every=30)
|
|
124
|
+
async def reconcile(orders, delivery):
|
|
125
|
+
for o in await orders.confirmed():
|
|
126
|
+
await delivery.send(o.chat_id, receipt_screen(o))
|
|
127
|
+
|
|
128
|
+
@bot.worker()
|
|
129
|
+
async def chain_watcher(feed): ...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Workers get DI, start after init, and shut down gracefully. Crashes restart automatically with exponential backoff -- no manual task supervision needed.
|
|
133
|
+
|
|
134
|
+
## Features
|
|
135
|
+
|
|
136
|
+
| Feature | Module | Details |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| Typed callbacks | `callbacks` | Pydantic models with a prefix. Stale/corrupt data returns "button expired" instead of crashing. Keyed encoding (`keyed=True`) uses query strings and tolerates schema changes; `unpack()` auto-detects either format so live buttons survive upgrades. |
|
|
139
|
+
| Reply keyboards | `screens` | `ReplyKeyboard` value object (persistent + resized by default), `@bot.reply_button("label")` routes presses through the full pipeline, `REMOVE_REPLY_KEYBOARD` clears it. |
|
|
140
|
+
| Markdown builder | `markdown` | Composable/nestable nodes, safe escaping for V1+V2, `raw()` escape hatch. |
|
|
141
|
+
| Routers | `routing` | `@router.command/callback/message`, sub-routers, `router.raw()` for plain PTB handlers. |
|
|
142
|
+
| Command args | `args` | Typed params from the signature; required/optional/`Greedy`; auto usage messages. |
|
|
143
|
+
| Pagination | `pagination` | Implement `count()`/`fetch(offset, limit)`, use `Paginator` and `nav_row()` buttons. |
|
|
144
|
+
| Conversations | `conversations` | Dataclass state per run, string transitions, timeout, `on_exit(reason)` hooks. Full DI/middleware/principal interop. |
|
|
145
|
+
| Files/media | `media` | `download()` with timeout and cleanup; content-hash `file_id` cache shared with Screen rendering. |
|
|
146
|
+
| Rate limiting | `ratelimit` | `@throttle(3, per=60)`, custom keys and custom behavior on limits. |
|
|
147
|
+
| Logging | `logging` | Key=value format, one line per update, `audit()` convention. |
|
|
148
|
+
| Errors | `errors` | `@bot.on_error(Type)` registry dispatched by MRO. Handlers can return a `Screen` to render into the current message. |
|
|
149
|
+
| Command discovery | `commands` | Auto `/help` filtered by caller's scopes. `set_my_commands()` per scope. `hidden=True` for internal handlers. |
|
|
150
|
+
| Middleware | `middleware` | `async def mw(event, call_next)` at bot or router scope. `event.extras` values become injectable. |
|
|
151
|
+
|
|
152
|
+
## Example: scaled mode
|
|
153
|
+
|
|
154
|
+
`examples/shop/` is a full app: a storefront with a **domain layer that never touches the bot layer** (`domain/`), views as pure functions (`domain -> Screen`), services injected into handlers, a `User` principal for guards and menus, a purchase conversation, admin commands, and a reconciler worker that messages buyers. Only `main.py` knows all the layers.
|
|
155
|
+
|
|
156
|
+
## Escape hatches
|
|
157
|
+
|
|
158
|
+
Not a fork or parallel dispatcher. `bot.build()` returns the PTB `Application` for webhooks. `router.raw(handler, group=...)` registers plain PTB handlers. `Screen.extra` passes kwargs to PTB's send methods. Everything from PTB stays accessible.
|
|
159
|
+
|
|
160
|
+
## Testing
|
|
161
|
+
|
|
162
|
+
Views are pure functions -- test them without a live bot. `Screen.content()`/`markup()` show what would be sent. `Delivery` accepts any object with `send`/`edit` methods (see `tests/conftest.py` for a mock). This repo has 102 tests that exercise dispatch and conversations the same way you can.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vitrine-tg"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A batteries-included framework on top of python-telegram-bot: typed callbacks, screens, DI, auth, conversations, and supervised workers."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "target111", email = "target111@protonmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["telegram", "bot", "framework", "python-telegram-bot", "asyncio"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Communications :: Chat",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"pydantic>=2.13.4",
|
|
29
|
+
"python-telegram-bot[job-queue]>=22.8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/target111/vitrine"
|
|
34
|
+
Repository = "https://github.com/target111/vitrine"
|
|
35
|
+
Issues = "https://github.com/target111/vitrine/issues"
|
|
36
|
+
Changelog = "https://github.com/target111/vitrine/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.11.28,<0.12.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[tool.uv.build-backend]
|
|
43
|
+
module-name = "vitrine"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=9.1.1",
|
|
48
|
+
"pytest-asyncio>=1.4.0",
|
|
49
|
+
"ruff>=0.14.4",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 92
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
asyncio_mode = "auto"
|
|
60
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
61
|
+
testpaths = ["tests"]
|
|
62
|
+
filterwarnings = [
|
|
63
|
+
"ignore::telegram.warnings.PTBUserWarning",
|
|
64
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""vitrine — a batteries-included foundation on top of python-telegram-bot.
|
|
2
|
+
|
|
3
|
+
Thin in ceremony, deep in capability: typed callback routing, screens with
|
|
4
|
+
robust delivery, dependency injection, an app-defined principal, guided
|
|
5
|
+
conversations, supervised workers, rate limiting, structured logging, and
|
|
6
|
+
typed error UX — while PTB's dispatcher, handlers, and filters stay fully
|
|
7
|
+
reachable underneath.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .app import Bot, VitrineContext
|
|
11
|
+
from .args import Greedy
|
|
12
|
+
from .auth import Auth, admin_only, requires, requires_principal
|
|
13
|
+
from .callbacks import CallbackData
|
|
14
|
+
from .conversations import END, Conversation, ExitReason
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
AuthError,
|
|
17
|
+
BannedError,
|
|
18
|
+
CallbackDataError,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
FrameworkError,
|
|
21
|
+
InjectionError,
|
|
22
|
+
NotAuthorizedError,
|
|
23
|
+
NotRegisteredError,
|
|
24
|
+
RateLimitedError,
|
|
25
|
+
UsageError,
|
|
26
|
+
UserFacingError,
|
|
27
|
+
)
|
|
28
|
+
from .injection import Depends, Invocation, Providers
|
|
29
|
+
from .logging import audit, log_event, setup_logging
|
|
30
|
+
from .media import FileIdCache, InMemoryFileIdCache, download
|
|
31
|
+
from .middleware import Event
|
|
32
|
+
from .pagination import ListSource, Page, PageSource, Paginator, nav_row
|
|
33
|
+
from .ratelimit import throttle
|
|
34
|
+
from .routing import Router
|
|
35
|
+
from .screens import (
|
|
36
|
+
REMOVE_REPLY_KEYBOARD,
|
|
37
|
+
Animation,
|
|
38
|
+
Audio,
|
|
39
|
+
Button,
|
|
40
|
+
ButtonStyle,
|
|
41
|
+
Delivery,
|
|
42
|
+
Document,
|
|
43
|
+
Media,
|
|
44
|
+
Photo,
|
|
45
|
+
ReplyButton,
|
|
46
|
+
ReplyKeyboard,
|
|
47
|
+
Screen,
|
|
48
|
+
Video,
|
|
49
|
+
Voice,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"Animation",
|
|
54
|
+
"Audio",
|
|
55
|
+
"Auth",
|
|
56
|
+
"AuthError",
|
|
57
|
+
"BannedError",
|
|
58
|
+
"Bot",
|
|
59
|
+
"Button",
|
|
60
|
+
"ButtonStyle",
|
|
61
|
+
"CallbackData",
|
|
62
|
+
"CallbackDataError",
|
|
63
|
+
"ConfigurationError",
|
|
64
|
+
"Conversation",
|
|
65
|
+
"Delivery",
|
|
66
|
+
"Depends",
|
|
67
|
+
"Document",
|
|
68
|
+
"END",
|
|
69
|
+
"Event",
|
|
70
|
+
"ExitReason",
|
|
71
|
+
"VitrineContext",
|
|
72
|
+
"FileIdCache",
|
|
73
|
+
"FrameworkError",
|
|
74
|
+
"Greedy",
|
|
75
|
+
"InMemoryFileIdCache",
|
|
76
|
+
"InjectionError",
|
|
77
|
+
"Invocation",
|
|
78
|
+
"ListSource",
|
|
79
|
+
"Media",
|
|
80
|
+
"NotAuthorizedError",
|
|
81
|
+
"NotRegisteredError",
|
|
82
|
+
"Page",
|
|
83
|
+
"PageSource",
|
|
84
|
+
"Paginator",
|
|
85
|
+
"Photo",
|
|
86
|
+
"Providers",
|
|
87
|
+
"REMOVE_REPLY_KEYBOARD",
|
|
88
|
+
"RateLimitedError",
|
|
89
|
+
"ReplyButton",
|
|
90
|
+
"ReplyKeyboard",
|
|
91
|
+
"Router",
|
|
92
|
+
"Screen",
|
|
93
|
+
"UsageError",
|
|
94
|
+
"UserFacingError",
|
|
95
|
+
"Video",
|
|
96
|
+
"Voice",
|
|
97
|
+
"admin_only",
|
|
98
|
+
"audit",
|
|
99
|
+
"download",
|
|
100
|
+
"log_event",
|
|
101
|
+
"nav_row",
|
|
102
|
+
"requires",
|
|
103
|
+
"requires_principal",
|
|
104
|
+
"setup_logging",
|
|
105
|
+
"throttle",
|
|
106
|
+
]
|