@@ -56,6 +56,243 @@ Multi-user Telegram bot that manages per-user Letta agents through an identity-b
5656- Each ` .edgeql ` file generates a corresponding Python module with type-safe async functions
5757- Examples: ` register_user ` , ` create_auth_request ` , ` get_identity ` , ` set_selected_agent `
5858
59+ ### Middleware System
60+
61+ The bot uses ** Aiogram's middleware system** for dependency injection and access control. Middlewares are called before handlers and can inject data, perform checks, or block handler execution.
62+
63+ ** DBMiddleware** (` middlewares.py ` ):
64+
65+ - Injects ` gel_client ` (Gel database client) into handler data for all Message and CallbackQuery events
66+ - Registered as outer middleware for both message and callback query routers
67+ - Provides database access to all handlers without manual client passing
68+
69+ ** IdentityMiddleware** (` middlewares.py ` ):
70+
71+ - Checks if handler requires identity verification by inspecting ` require_identity ` flag
72+ - Only processes handlers marked with ` flags={'require_identity': True} `
73+ - Performs identity authorization check using ` get_allowed_identity_query() `
74+ - If authorization succeeds:
75+ - Fetches user's identity using ` get_identity_query() `
76+ - Injects ` identity ` object into handler data
77+ - Allows handler execution
78+ - If authorization fails:
79+ - Sends error message to user: "You need to request bot access first using /botaccess"
80+ - Blocks handler execution (returns None)
81+
82+ ** Marking Handlers that Require Identity** :
83+
84+ Handlers that need identity access should use ` flags={'require_identity': True} ` parameter:
85+
86+ ``` python
87+ # Command handler requiring identity
88+ @router.message (Command(' switch' ), flags = {' require_identity' : True })
89+ async def switch (message : Message, identity : GetIdentityResult) -> None :
90+ # Handler receives identity as injected parameter
91+ pass
92+
93+ # Callback query handler requiring identity
94+ @router.callback_query (
95+ SwitchAssistantCallback.filter(),
96+ flags = {' require_identity' : True }
97+ )
98+ async def handle_switch (
99+ callback : CallbackQuery,
100+ callback_data : SwitchAssistantCallback,
101+ identity : GetIdentityResult,
102+ ) -> None :
103+ # Handler receives identity as injected parameter
104+ pass
105+
106+ # Message handler (catch-all) requiring identity
107+ @router.message (flags = {' require_identity' : True })
108+ async def message_handler (
109+ message : Message,
110+ identity : GetIdentityResult
111+ ) -> None :
112+ # Handler receives identity as injected parameter
113+ pass
114+ ```
115+
116+ ** Key Points** :
117+
118+ - Handlers with ` require_identity ` flag automatically receive ` identity: GetIdentityResult ` parameter
119+ - No need to manually check identity or pass ` gel_client ` - middleware handles this
120+ - Middleware setup via ` setup_middlewares(dp, gel_client) ` in ` main.py `
121+ - Replaces legacy ` @require_identity(gel_client) ` decorator pattern
122+
123+ ** Creating Custom Middlewares** :
124+
125+ Aiogram middlewares inherit from ` BaseMiddleware ` and implement ` __call__ ` method:
126+
127+ ``` python
128+ from aiogram import BaseMiddleware
129+ from aiogram.types import TelegramObject
130+ from collections.abc import Awaitable, Callable
131+
132+ class CustomMiddleware (BaseMiddleware ):
133+ async def __call__ (
134+ self ,
135+ handler : Callable[[TelegramObject, dict[str , object ]], Awaitable[object ]],
136+ event : TelegramObject,
137+ data : dict[str , object ],
138+ ) -> object | None :
139+ # Pre-processing: inject data, perform checks
140+ data[' custom_key' ] = ' custom_value'
141+
142+ # Optional: check conditions and block handler
143+ if some_condition:
144+ await event.answer(' Access denied' )
145+ return None # Block handler execution
146+
147+ # Call handler
148+ result = await handler(event, data)
149+
150+ # Post-processing (optional)
151+ # ... do something after handler
152+
153+ return result
154+ ```
155+
156+ ** Middleware Types** :
157+
158+ - ** Outer middleware** : Runs before inner middleware and filters
159+ - Use: ` dp.message.outer_middleware.register(CustomMiddleware()) `
160+ - Example: ` DBMiddleware ` (injects database client)
161+ - ** Inner middleware** : Runs after outer middleware but before handlers
162+ - Use: ` dp.message.middleware(CustomMiddleware()) `
163+ - Example: ` IdentityMiddleware ` (checks identity access)
164+
165+ ** Registration Order Matters** :
166+
167+ ``` python
168+ # DBMiddleware must run first (outer) to inject gel_client
169+ dp.message.outer_middleware.register(DBMiddleware(gel_client))
170+ dp.callback_query.outer_middleware.register(DBMiddleware(gel_client))
171+
172+ # IdentityMiddleware runs second (inner) and uses gel_client
173+ dp.message.middleware(IdentityMiddleware())
174+ dp.callback_query.middleware(IdentityMiddleware())
175+ ```
176+
177+ ### Filters
178+
179+ The bot uses ** Aiogram's filter system** for access control. Filters determine whether a handler should execute based on specific conditions. Unlike middleware, filters are declarative and specific to individual handlers.
180+
181+ ** AdminOnlyFilter** (` filters.py ` ):
182+
183+ - Magic filter that restricts handler execution to admin users only
184+ - Implementation: ` MagicData(F.event_from_user.id.in_(CONFIG.admin_ids)) `
185+ - Checks if ` event.from_user.id ` is in the list of configured admin IDs
186+ - If ` CONFIG.admin_ids ` is ` None ` , filter always fails (no admins configured)
187+
188+ ** Usage Pattern** :
189+
190+ ``` python
191+ from letta_bot.filters import AdminOnlyFilter
192+
193+ # Apply filter to command handler
194+ @router.message (Command(' pending' ), AdminOnlyFilter)
195+ async def pending (message : Message, gel_client : AsyncIOExecutor) -> None :
196+ # Only admins can access this handler
197+ pass
198+ ```
199+
200+ ** Admin Commands Using This Filter** :
201+
202+ - ` /pending ` - View pending authorization requests
203+ - ` /allow <request_uuid> ` - Approve authorization request
204+ - ` /deny <request_uuid> [reason] ` - Deny authorization request with optional reason
205+ - ` /revoke <telegram_id> ` - Revoke user's identity access
206+ - ` /users ` - List all users with their allowed resources
207+
208+ ** Key Points** :
209+
210+ - Filter automatically blocks non-admin users from accessing admin commands
211+ - No error message sent to non-admin users (handler simply doesn't execute)
212+ - Admin IDs must be configured via ` ADMIN_IDS ` environment variable (comma-separated list)
213+ - If no admin IDs configured, admin commands are inaccessible to everyone
214+
215+ ** Creating Custom Filters** :
216+
217+ Aiogram supports multiple filter types:
218+
219+ ** 1. Magic Filters (Recommended for simple conditions)** :
220+
221+ Using Aiogram's ` F ` (magic filter) for concise, readable conditions:
222+
223+ ``` python
224+ from aiogram import F
225+ from aiogram.filters.magic_data import MagicData
226+
227+ # Check user ID
228+ UserIsJohn = MagicData(F.event_from_user.id == 12345 )
229+
230+ # Check multiple IDs
231+ PremiumUsersFilter = MagicData(F.event_from_user.id.in_([123 , 456 , 789 ]))
232+
233+ # Check message text pattern
234+ HasKeywordFilter = MagicData(F.message.text.contains(' keyword' ))
235+
236+ # Combine conditions with & (AND) and | (OR)
237+ SpecialFilter = MagicData(
238+ (F.event_from_user.id == 123 ) & (F.message.text.startswith(' /' ))
239+ )
240+ ```
241+
242+ ** 2. Custom Filter Classes (For complex logic)** :
243+
244+ ``` python
245+ from aiogram.filters import Filter
246+ from aiogram.types import Message
247+
248+ class HasAttachmentFilter (Filter ):
249+ async def __call__ (self , message : Message) -> bool :
250+ # Return True if handler should execute
251+ return bool (
252+ message.photo
253+ or message.document
254+ or message.video
255+ or message.audio
256+ )
257+
258+ # Usage
259+ @router.message (HasAttachmentFilter())
260+ async def handle_attachment (message : Message) -> None :
261+ pass
262+ ```
263+
264+ ** 3. Filter with Data Injection** :
265+
266+ Filters can inject data into handlers by returning a dict:
267+
268+ ``` python
269+ from aiogram.filters import Filter
270+ from aiogram.types import Message
271+
272+ class ExtractMentionFilter (Filter ):
273+ async def __call__ (self , message : Message) -> bool | dict[str , object ]:
274+ if not message.entities:
275+ return False
276+
277+ for entity in message.entities:
278+ if entity.type == ' mention' :
279+ mention = message.text[entity.offset:entity.offset + entity.length]
280+ return {' mentioned_user' : mention}
281+
282+ return False
283+
284+ # Handler receives injected data
285+ @router.message (ExtractMentionFilter())
286+ async def handle_mention (message : Message, mentioned_user : str ) -> None :
287+ await message.answer(f ' You mentioned: { mentioned_user} ' )
288+ ```
289+
290+ ** Filter vs Middleware Decision** :
291+
292+ - ** Use Filter when** : Condition is specific to a handler (e.g., admin-only command)
293+ - ** Use Middleware when** : Logic applies to many handlers (e.g., database injection, identity checks)
294+ - ** Combine both** : Middleware for common setup, filters for specific conditions
295+
59296### Authorization Flow
60297
61298** Phase 1: User Registration**
@@ -257,6 +494,8 @@ Current module organization:
257494letta_bot/
258495 main.py # Bot entry point with webhook/polling modes, /start handler
259496 config.py # Configuration management (Pydantic settings)
497+ middlewares.py # Middleware for database client injection and identity checks
498+ filters.py # Filters for admin access control
260499 auth.py # Admin authorization handlers (/pending, /allow, /deny, /users, /revoke)
261500 agent.py # Agent request handlers, message routing, and Letta API integration
262501 client.py # Shared Letta client instance and Letta API operations
0 commit comments