Skip to content

Commit c915638

Browse files
dsammarugabeanrepo
authored andcommitted
simplify API
1 parent bf52f73 commit c915638

File tree

3 files changed

+311
-169
lines changed

3 files changed

+311
-169
lines changed

src/arduino/app_bricks/telegram_bot/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
#
33
# SPDX-License-Identifier: MPL-2.0
44

5-
from .telegram_bot import TelegramBot
6-
from telegram import Update
7-
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
5+
from .telegram_bot import TelegramBot, Message
86

9-
10-
__all__ = ["TelegramBot", "Update", "Application", "CommandHandler", "MessageHandler", "filters", "ContextTypes"]
7+
__all__ = ["TelegramBot", "Message"]

src/arduino/app_bricks/telegram_bot/telegram_bot.py

Lines changed: 145 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import os
66
import asyncio
77
import threading
8-
import inspect
98
import time
109
from typing import Callable, Optional
10+
from dataclasses import dataclass
1111
from arduino.app_utils import brick, Logger
1212
from telegram import Update, BotCommand
1313
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
@@ -16,6 +16,30 @@
1616
logger = 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
2044
class 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

Comments
 (0)