55import os
66import asyncio
77import threading
8- import inspect
98import time
109from typing import Callable , Optional
10+ from dataclasses import dataclass
1111from arduino .app_utils import brick , Logger
1212from telegram import Update , BotCommand
1313from telegram .ext import Application , CommandHandler , MessageHandler , filters , ContextTypes
1616logger = Logger ("TelegramBot" )
1717
1818
19+ @dataclass
20+ class Message :
21+ """Simplified message object with only essential attributes.
22+
23+ This object is passed to user callbacks, containing all the information
24+ needed to process a message and respond to it.
25+
26+ Attributes:
27+ chat_id: Telegram chat ID (used to send responses).
28+ text: Text content of the message (None for photos).
29+ user_id: ID of the user who sent the message.
30+ user_name: First name of the user.
31+ username: Username of the user (None if not set).
32+ photo_bytes: Photo data as bytes (None for text messages).
33+ """
34+
35+ chat_id : int
36+ text : Optional [str ] = None
37+ user_id : Optional [int ] = None
38+ user_name : Optional [str ] = None
39+ username : Optional [str ] = None
40+ photo_bytes : Optional [bytearray ] = None
41+
42+
1943@brick
2044class TelegramBot :
2145 """A brick to manage Telegram Bot interactions with synchronous API.
@@ -66,73 +90,121 @@ def __init__(
6690 self ._scheduled_tasks : dict [str , threading .Timer ] = {}
6791 self ._commands_registry : dict [str , str ] = {}
6892
69- def _make_async_handler (self , callback : Callable ) -> Callable :
70- """Convert a synchronous callback to an async handler if needed.
93+ def _create_message_handler (self , callback : Callable ) -> Callable :
94+ """Create a Telegram handler from user's simple callback.
95+
96+ This method extracts essential information from Telegram's Update object,
97+ creates a simplified Message object, and handles async-to-sync conversion
98+ automatically. Photos are downloaded automatically if present.
99+
100+ User's callback receives only a simplified Message object, not Update/Context.
71101
72102 Args:
73- callback: User-defined callback function (sync or async).
103+ callback: User's synchronous callback(message: Message) -> None
74104
75105 Returns:
76- Async- compatible callback function.
106+ Async handler compatible with python-telegram-bot
77107 """
78- if inspect .iscoroutinefunction (callback ):
79- # Already async, use as-is
80- return callback
81108
82- # Sync callback, wrap it
83- async def async_wrapper (update : Update , context : ContextTypes .DEFAULT_TYPE ) -> None :
109+ async def wrapper (update : Update , context : ContextTypes .DEFAULT_TYPE ):
110+ # Extract essential info from Update into simplified Message object
111+ message = Message (
112+ chat_id = update .message .chat_id ,
113+ text = update .message .text if update .message .text else None ,
114+ user_id = update .effective_user .id ,
115+ user_name = update .effective_user .first_name ,
116+ username = update .effective_user .username ,
117+ )
118+
119+ # Download photo if present
120+ if update .message .photo :
121+ try :
122+ photo_file = await update .message .photo [- 1 ].get_file ()
123+ message .photo_bytes = await photo_file .download_as_bytearray ()
124+ except Exception as e :
125+ logger .error (f"Failed to download photo: { e } " )
126+
127+ # Run user's callback in executor (sync)
84128 loop = asyncio .get_event_loop ()
85- await loop .run_in_executor (None , callback , update , context )
129+ await loop .run_in_executor (None , callback , message )
86130
87- return async_wrapper
131+ return wrapper
88132
89- def add_command (self , command : str , callback : Callable , description : str = "" ) -> None :
90- """Register a slash command handler with optional description .
133+ def add_command (self , command : str , callback : Callable [[ Message ], None ] , description : str = "" ) -> None :
134+ """Register a command handler (e.g., /start) .
91135
92- The callback can be either synchronous or asynchronous. If synchronous,
93- it will be automatically converted to async. If a description is provided
94- and auto_set_commands is enabled, the command will appear in Telegram's
95- command menu when users type '/'.
136+ The callback function receives a simplified Message object containing
137+ all essential information about the message and user.
96138
97139 Args:
98- command: Command name (without the leading slash, e.g., "start" for /start).
99- callback: Handler function (can be sync or async). Receives Update and ContextTypes.
100- description: Optional description shown in Telegram's command menu. If empty,
101- the command will still work but won't appear in the '/' menu.
140+ command: Command name without '/' (e.g., "start", "hello").
141+ callback: Function that receives a Message object.
142+ description: Optional description shown in Telegram's command menu.
102143
103144 Example:
104- >>> bot.add_command("start", start_handler, "Start the bot")
105- >>> bot.add_command("help", help_handler, "Show available commands")
145+ >>> def greet(msg: Message):
146+ ... bot.send(msg.chat_id, f"Hello {msg.user_name}!")
147+ >>> bot.add_command("hello", greet, "Greet the user")
106148 """
107- async_callback = self ._make_async_handler (callback )
108- self .application .add_handler (CommandHandler (command , async_callback ))
109-
110- # Track command for auto-sync with Telegram
149+ handler = self ._create_message_handler (callback )
150+ self .application .add_handler (CommandHandler (command , handler ))
151+
111152 if description :
112153 self ._commands_registry [command ] = description
113- logger .info (f"Registered command /{ command } : { description } " )
114- else :
115- logger .info (f"Registered command /{ command } " )
116154
117- def on_text (self , callback : Callable ) -> None :
155+ logger .info (f"Registered command: /{ command } " + (f" - { description } " if description else "" ))
156+
157+ def on_text (self , callback : Callable [[Message ], None ]) -> None :
118158 """Register a handler for text messages.
119159
160+ The callback function receives a simplified Message object containing
161+ the text and user information.
162+
120163 Args:
121- callback: Handler function (can be sync or async). Receives Update and ContextTypes.
122- Called for all text messages except commands.
164+ callback: Function that receives a Message object.
165+
166+ Example:
167+ >>> def echo(msg: Message):
168+ ... bot.send(msg.chat_id, f"You said: {msg.text}")
169+ >>> bot.on_text(echo)
123170 """
124- async_callback = self ._make_async_handler (callback )
125- self .application .add_handler (MessageHandler (filters .TEXT & ~ filters .COMMAND , async_callback ))
171+ handler = self ._create_message_handler (callback )
172+ self .application .add_handler (MessageHandler (filters .TEXT & ~ filters .COMMAND , handler ))
173+ logger .info ("Registered text message handler" )
126174
127- def on_photo (self , callback : Callable ) -> None :
175+ def on_photo (self , callback : Callable [[ Message ], None ] ) -> None :
128176 """Register a handler for photo messages.
129177
178+ The callback function receives a simplified Message object with the
179+ photo already downloaded as photo_bytes.
180+
130181 Args:
131- callback: Handler function (can be sync or async). Receives Update and ContextTypes.
132- Called when user sends a photo.
182+ callback: Function that receives a Message object with photo_bytes.
183+
184+ Example:
185+ >>> def handle_photo(msg: Message):
186+ ... if msg.photo_bytes:
187+ ... bot.send(msg.chat_id, "Got your photo!")
188+ >>> bot.on_photo(handle_photo)
189+ """
190+ handler = self ._create_message_handler (callback )
191+ self .application .add_handler (MessageHandler (filters .PHOTO , handler ))
192+ logger .info ("Registered photo message handler" )
193+
194+ def send (self , chat_id : int , text : str ) -> bool :
195+ """Send a text message to a chat (simplified method).
196+
197+ Args:
198+ chat_id: Telegram chat ID.
199+ text: Message text.
200+
201+ Returns:
202+ True if message was sent successfully, False otherwise.
203+
204+ Example:
205+ >>> bot.send(123456, "Hello from Arduino!")
133206 """
134- async_callback = self ._make_async_handler (callback )
135- self .application .add_handler (MessageHandler (filters .PHOTO & ~ filters .COMMAND , async_callback ))
207+ return self .send_message (chat_id , text )
136208
137209 def send_message (self , chat_id : int , message_text : str ) -> bool :
138210 """Send a text message to a specific chat (synchronous with automatic retry).
@@ -193,24 +265,28 @@ async def _send_message_async(self, chat_id: int, message_text: str) -> None:
193265 logger .error (f"An error occurred: { e } " )
194266 raise
195267
196- def send_photo (self , chat_id : int , photo , caption : Optional [ str ] = None ) -> bool :
197- """Send a photo to a specific chat (synchronous with automatic retry) .
268+ def send_photo (self , chat_id : int , photo_bytes : bytes , caption : str = "" ) -> bool :
269+ """Send a photo to a chat.
198270
199271 Args:
200- chat_id: Telegram chat ID to send the photo to .
201- photo : Photo to send (file path, URL, or file-like object) .
202- caption: Optional caption for the photo .
272+ chat_id: Telegram chat ID.
273+ photo_bytes : Photo as bytes .
274+ caption: Optional caption text .
203275
204276 Returns:
205- True if photo was sent successfully, False otherwise.
277+ True if successful, False otherwise.
278+
279+ Example:
280+ >>> with open("image.jpg", "rb") as f:
281+ ... bot.send_photo(123456, f.read(), "Check this out!")
206282 """
207283 if not self ._running or not self ._loop or not self ._initialized :
208284 logger .error ("Bot not properly initialized, cannot send photo" )
209285 return False
210286
211287 for attempt in range (self .max_retries ):
212288 try :
213- future = asyncio .run_coroutine_threadsafe (self ._send_photo_async (chat_id , photo , caption ), self ._loop )
289+ future = asyncio .run_coroutine_threadsafe (self ._send_photo_async (chat_id , photo_bytes , caption ), self ._loop )
214290 future .result (timeout = self .photo_timeout )
215291 return True
216292 except TimeoutError :
@@ -225,13 +301,13 @@ def send_photo(self, chat_id: int, photo, caption: Optional[str] = None) -> bool
225301 logger .error (f"Failed to send photo after { self .max_retries } attempts" )
226302 return False
227303
228- async def _send_photo_async (self , chat_id : int , photo , caption : Optional [ str ] = None ) -> None :
304+ async def _send_photo_async (self , chat_id : int , photo_bytes : bytes , caption : str ) -> None :
229305 """Internal async method to send a photo with network error handling.
230306
231307 Args:
232308 chat_id: Telegram chat ID.
233- photo : Photo to send.
234- caption: Optional caption.
309+ photo_bytes : Photo bytes to send.
310+ caption: Photo caption.
235311
236312 Raises:
237313 NetworkError: If network issues occur.
@@ -242,7 +318,7 @@ async def _send_photo_async(self, chat_id: int, photo, caption: Optional[str] =
242318 try :
243319 await self .application .bot .send_photo (
244320 chat_id = chat_id ,
245- photo = photo ,
321+ photo = photo_bytes ,
246322 caption = caption ,
247323 read_timeout = self .photo_timeout ,
248324 write_timeout = self .photo_timeout ,
@@ -255,63 +331,33 @@ async def _send_photo_async(self, chat_id: int, photo, caption: Optional[str] =
255331 logger .error (f"An error occurred: { e } " )
256332 raise
257333
258- def get_photo (self , update : Update , context : ContextTypes . DEFAULT_TYPE ) -> Optional [ bytearray ] :
259- """Download photo from an update (synchronous with automatic retry) .
334+ def schedule (self , chat_id : int , text : str , interval_seconds : int ) -> str :
335+ """Schedule recurring messages to a chat .
260336
261337 Args:
262- update: Telegram update object containing the photo.
263- context: Telegram context object.
338+ chat_id: Telegram chat ID.
339+ text: Message text to send.
340+ interval_seconds: Interval between messages in seconds.
264341
265342 Returns:
266- Photo bytes as bytearray, or None if download fails after all retries.
267- """
268- if not self ._running or not self ._loop or not self ._initialized :
269- logger .error ("Bot not properly initialized, cannot get photo" )
270- return None
271-
272- for attempt in range (self .max_retries ):
273- try :
274- future = asyncio .run_coroutine_threadsafe (self ._get_photo_async (update , context ), self ._loop )
275- return future .result (timeout = self .photo_timeout )
276- except TimeoutError :
277- logger .warning (f"Photo download timeout (attempt { attempt + 1 } /{ self .max_retries } )" )
278- if attempt < self .max_retries - 1 :
279- time .sleep (2 * (attempt + 1 )) # Backoff for downloads
280- continue
281- except Exception as e :
282- logger .error (f"Failed to get photo: { e } " )
283- return None
343+ Task ID that can be used to cancel the scheduled message.
284344
285- logger .error (f"Failed to download photo after { self .max_retries } attempts" )
286- return None
345+ Example:
346+ >>> task_id = bot.schedule(123456, "Reminder!", 60) # Every minute
347+ >>> # Later: bot.cancel_schedule(task_id)
348+ """
349+ return self .schedule_message (chat_id , text , interval_seconds )
287350
288- async def _get_photo_async (self , update : Update , context : ContextTypes . DEFAULT_TYPE ) -> bytearray :
289- """Internal async method to download a photo with network error handling .
351+ def cancel_schedule (self , task_id : str ) -> bool :
352+ """Cancel a scheduled message .
290353
291354 Args:
292- update: Telegram update object.
293- context: Telegram context object.
355+ task_id: Task ID returned by schedule().
294356
295357 Returns:
296- Photo bytes as bytearray.
297-
298- Raises:
299- NetworkError: If network issues occur.
300- TimedOut: If request times out.
301- Exception: If photo download fails for other reasons.
358+ True if task was cancelled, False if task_id not found.
302359 """
303- logger .info ("Downloading photo from Telegram..." )
304- try :
305- photo_file = await update .message .photo [- 1 ].get_file ()
306- photo_bytes = await photo_file .download_as_bytearray ()
307- logger .info ("Photo downloaded successfully!" )
308- return photo_bytes
309- except (NetworkError , TimedOut ) as e :
310- logger .warning (f"Network issue while downloading photo: { e } " )
311- raise
312- except Exception as e :
313- logger .error (f"An error occurred while downloading photo: { e } " )
314- raise
360+ return self .cancel_scheduled_message (task_id )
315361
316362 async def _set_bot_commands (self ) -> None :
317363 """Internal method to sync registered commands with Telegram.
@@ -327,10 +373,7 @@ async def _set_bot_commands(self) -> None:
327373 return
328374
329375 try :
330- bot_commands = [
331- BotCommand (command = cmd , description = desc )
332- for cmd , desc in self ._commands_registry .items ()
333- ]
376+ bot_commands = [BotCommand (command = cmd , description = desc ) for cmd , desc in self ._commands_registry .items ()]
334377 await self .application .bot .set_my_commands (bot_commands )
335378 logger .info (f"Successfully registered { len (bot_commands )} command(s) with Telegram's menu" )
336379 except Exception as e :
0 commit comments