botapi-py 1.2.1__tar.gz → 1.3.1__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.
@@ -1,8 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: botapi-py
3
- Version: 1.2.1
3
+ Version: 1.3.1
4
4
  Summary: A simple and flexible Python library to conveniently build Telegram bots using the Bot API.
5
5
  Requires-Dist: httpx>=0.28.1
6
6
  Requires-Dist: pydantic>=2.12.0
7
+ Requires-Dist: redis>=6.4.0 ; extra == 'redis'
7
8
  Requires-Python: >=3.12
8
9
  Project-URL: Telegram BotAPI Docs, https://core.telegram.org/bots/api
10
+ Provides-Extra: redis
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "botapi-py"
3
- version = "1.2.1"
3
+ version = "1.3.1"
4
4
  description = "A simple and flexible Python library to conveniently build Telegram bots using the Bot API."
5
5
  # readme = "README.md"
6
6
  # authors = [
@@ -15,6 +15,11 @@ dependencies = [
15
15
  [project.urls]
16
16
  "Telegram BotAPI Docs" = "https://core.telegram.org/bots/api"
17
17
 
18
+ [project.optional-dependencies]
19
+ redis = [
20
+ "redis>=6.4.0",
21
+ ]
22
+
18
23
  [build-system]
19
24
  requires = ["uv_build>=0.9.0,<0.10.0"]
20
25
  build-backend = "uv_build"
@@ -1,4 +1,4 @@
1
- from . import core, enums, errors, types, utils
1
+ from . import core, enums, errors, types, utils, dispatchers
2
2
  from .api import BotAPI
3
3
 
4
- __all__ = ['BotAPI', 'types', 'enums', 'utils', 'errors', 'core']
4
+ __all__ = ['BotAPI', 'types', 'enums', 'utils', 'errors', 'core', 'dispatchers']
@@ -1,4 +1,4 @@
1
- from . import core, enums, errors, types, utils
1
+ from . import core, enums, errors, types, utils, dispatchers
2
2
  from .api import BotAPI
3
3
 
4
- __all__ = ['BotAPI', 'types', 'enums', 'utils', 'errors', 'core']
4
+ __all__ = ['BotAPI', 'types', 'enums', 'utils', 'errors', 'core', 'dispatchers']
@@ -0,0 +1,7 @@
1
+ from .get_updates_handler import GetUpdatesAsyncDispatcher
2
+ from .redis_pubsub_handler import RedisPubSubAsyncDispatcher
3
+
4
+ __all__ = [
5
+ 'GetUpdatesAsyncDispatcher',
6
+ 'RedisPubSubAsyncDispatcher',
7
+ ]
@@ -0,0 +1,188 @@
1
+ import asyncio
2
+ import logging
3
+ import signal
4
+ from typing import Any, Callable, Coroutine, TypeAlias
5
+
6
+ import httpx
7
+
8
+ import botapi
9
+ from botapi.types import Update
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ UpdateHandler: TypeAlias = Callable[[Update], Coroutine[Any, Any, None]]
14
+
15
+
16
+ class GetUpdatesAsyncDispatcher:
17
+ update_handler: UpdateHandler
18
+ tasks_shutdown_timeout: float
19
+ tasks: set[asyncio.Task] # prevent gc from collecting tasks before they finish
20
+ bot: botapi.api.BotAPI
21
+ reconnect_delay: float
22
+ allowed_updates: list[str]
23
+ offset: int
24
+
25
+ def __init__(
26
+ self,
27
+ update_handler: UpdateHandler,
28
+ bot: botapi.api.BotAPI,
29
+ allowed_updates: list[str], # botapi.enums.UpdateType
30
+ reconnect_delay: float = 5,
31
+ shutdown_wait_timeout: float = 3
32
+ ) -> None:
33
+ '''
34
+ Dispatcher that receives updates with long polling (`getUpdates`)
35
+ and dispatches them to a registered async handler.
36
+
37
+ Args:
38
+ update_handler (UpdateHandler):
39
+ Async function to handle received messages.
40
+ bot (botapi.api.BotAPI):
41
+ A `Bot` instance.
42
+ allowed_updates (list[botapi.enums.UpdateType]):
43
+ List of allowed update types.
44
+ reconnect_delay (float, optional):
45
+ Delay in seconds before retrying after a failed request.
46
+ shutdown_wait_timeout (float, optional):
47
+ Timeout in seconds to wait for pending tasks to complete on shutdown.
48
+ '''
49
+ self.bot = bot
50
+ self.update_handler = update_handler
51
+ self.allowed_updates = allowed_updates
52
+ self.reconnect_delay = reconnect_delay
53
+ self.shutdown_wait_timeout = shutdown_wait_timeout
54
+ self.offset = -1
55
+ self.tasks = set()
56
+
57
+ def _task_done_callback(self, task: asyncio.Task) -> None:
58
+ '''
59
+ Callback that is called by the event loop when an update_handler task is done.
60
+
61
+ This catches and logs unhandled exceptions, so that they don't pass silently.
62
+ Also, it removes the completed task from the `tasks` set.
63
+ '''
64
+ try:
65
+ task.result()
66
+ except Exception:
67
+ logger.exception('Exception in handler')
68
+
69
+ finally:
70
+ self.tasks.discard(task)
71
+
72
+ async def _dispatch(self, update: Update) -> None:
73
+ '''
74
+ Dispatches the update to the registered handler, in a new task.
75
+ '''
76
+ task = asyncio.create_task(self.update_handler(update))
77
+ self.tasks.add(task)
78
+ task.add_done_callback(self._task_done_callback)
79
+
80
+ async def _shutdown(self) -> None:
81
+ '''
82
+ Wait for pending/running tasks to complete (with a timeout),
83
+ then exits.
84
+ '''
85
+ if self.tasks:
86
+ logger.info('Waiting for %d tasks to complete...', len(self.tasks))
87
+ try:
88
+ await asyncio.wait_for(
89
+ asyncio.gather(*self.tasks, return_exceptions=True),
90
+ timeout=self.shutdown_wait_timeout
91
+ )
92
+ except asyncio.TimeoutError:
93
+ logger.warning('Some tasks were still running after timeout, CancelledError was raised.')
94
+
95
+ async def _listen_forever(self) -> None:
96
+ '''
97
+ Listen for incoming updates forever, with long polling.
98
+ In case of connection errors, it will retry after a delay.
99
+ '''
100
+ GET_UPDATES_TIMEOUT = int(self.bot.session.timeout.read or 60) - 10 # 10 seconds less than the read timeout
101
+
102
+ self.offset = -1
103
+
104
+ while True:
105
+ try:
106
+ result = await self.bot.get_updates(
107
+ offset=self.offset,
108
+ limit=100,
109
+ timeout=GET_UPDATES_TIMEOUT,
110
+ allowed_updates=self.allowed_updates,
111
+ )
112
+
113
+ if result:
114
+ self.offset = result[-1].update_id + 1 # next update offset
115
+
116
+ for update in result:
117
+ try:
118
+ await self._dispatch(update)
119
+ except Exception:
120
+ logger.exception('Exception raised in the dispatcher:')
121
+
122
+
123
+ except botapi.errors.BotAPIError as e:
124
+ # logger.exception('Bot API error:')
125
+ logger.error('Bot API error, retrying soon: %s', e)
126
+ await asyncio.sleep(delay=self.reconnect_delay)
127
+ #TODO manage specific error codes (like 409 Conflict or 401 Unauthorized)
128
+
129
+ except httpx.RequestError as e:
130
+ # logger.exception('Request error:')
131
+ logger.error('Request error, retrying soon: %s', e)
132
+ await asyncio.sleep(delay=self.reconnect_delay)
133
+
134
+ except asyncio.CancelledError:
135
+ logger.info('Received termination signal')
136
+ break
137
+
138
+ except Exception: # catch-all for unexpected exceptions; NEVER let the bot crash!
139
+ logger.exception('Unexpected exception in listen_forever:')
140
+ await asyncio.sleep(delay=self.reconnect_delay)
141
+
142
+ async def _register_signal_handlers(self, task_to_cancel: asyncio.Task) -> None:
143
+ '''
144
+ Register signal handlers for graceful shutdown on:
145
+ - SIGTERM
146
+ - SIGINT
147
+ '''
148
+ loop = asyncio.get_running_loop()
149
+
150
+ for sig in (signal.SIGTERM, signal.SIGINT):
151
+ loop.add_signal_handler(
152
+ sig,
153
+ task_to_cancel.cancel
154
+ )
155
+
156
+ async def run_forever(self) -> None:
157
+ '''
158
+ Waits for incoming updates forever, and dispatches them to the registered handler.
159
+ This method **is blocking**, it terminates on SIGINT/SIGTERM signals.
160
+
161
+ This method automatically retries failed `getUpdates` requests after a short delay.
162
+
163
+ Example:
164
+ ```python
165
+ dispatcher = GetUpdatesAsyncDispatcher(
166
+ update_handler=handler,
167
+ bot=bot,
168
+ allowed_updates=[
169
+ botapi.enums.UpdateType.MESSAGE,
170
+ botapi.enums.UpdateType.EDITED_MESSAGE,
171
+ botapi.enums.UpdateType.CALLBACK_QUERY,
172
+ botapi.enums.UpdateType.CHAT_MEMBER_UPDATED,
173
+ botapi.enums.UpdateType.CHAT_JOIN_REQUEST,
174
+ ]
175
+ )
176
+ await dispatcher.run_forever()
177
+ ```
178
+ '''
179
+ task = asyncio.create_task(self._listen_forever())
180
+
181
+ try:
182
+ await task
183
+ except asyncio.CancelledError:
184
+ logger.info('Received termination signal')
185
+ finally:
186
+ logger.warning('Shutting down...')
187
+ await self._shutdown()
188
+
@@ -0,0 +1,201 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import signal
5
+ from typing import Any, Callable, Coroutine, TypeAlias
6
+
7
+ import pydantic
8
+ from botapi.types import Update
9
+
10
+ try:
11
+ import redis.asyncio as redis
12
+ from redis.asyncio.client import PubSub
13
+ _has_redis = True
14
+ except ImportError:
15
+ _has_redis = False
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ UpdateHandler: TypeAlias = Callable[[Update], Coroutine[Any, Any, None]]
20
+
21
+
22
+ class RedisPubSubAsyncDispatcher:
23
+ update_handler: UpdateHandler
24
+ tasks_shutdown_timeout: float
25
+ tasks: set[asyncio.Task] # prevent gc from collecting tasks before they finish
26
+ redis_client: redis.Redis
27
+ redis_reconnect_delay: float
28
+ pubsub: PubSub
29
+ pubsub_channel: str
30
+
31
+ def __init__(
32
+ self,
33
+ update_handler: UpdateHandler,
34
+ pubsub_channel: str,
35
+ redis_client: redis.Redis,
36
+ redis_reconnect_delay: float = 5,
37
+ shutdown_wait_timeout: float = 3
38
+ ) -> None:
39
+ '''
40
+ Dispatcher that receives updates from a Redis PUB/SUB
41
+ and dispatches them to a registered async handler.
42
+
43
+ Args:
44
+ update_handler (UpdateHandler):
45
+ Async function to handle received messages.
46
+ redis_client (redis.Redis):
47
+ Redis client instance.
48
+ pubsub_channel (str):
49
+ Name of the Redis PUB/SUB channel to subscribe to.
50
+ redis_reconnect_delay (float, optional):
51
+ Delay in seconds before retrying connection on failure.
52
+ shutdown_wait_timeout (float, optional):
53
+ Timeout in seconds to wait for pending tasks to complete on shutdown.
54
+ '''
55
+ if not _has_redis:
56
+ raise ImportError('redis-py is not installed. Install `botapi-py[redis]` to use this dispatcher.')
57
+
58
+ self.redis_client = redis_client
59
+ self.update_handler = update_handler
60
+ self.pubsub_channel = pubsub_channel
61
+ self.redis_reconnect_delay = redis_reconnect_delay
62
+ self.shutdown_wait_timeout = shutdown_wait_timeout
63
+ self.tasks = set()
64
+ self.pubsub = self.redis_client.pubsub(ignore_subscribe_messages=True)
65
+
66
+ def _task_done_callback(self, task: asyncio.Task) -> None:
67
+ '''
68
+ Callback that is called by the event loop when an update_handler task is done.
69
+
70
+ This catches and logs unhandled exceptions, so that they don't pass silently.
71
+ Also, it removes the completed task from the `tasks` set.
72
+ '''
73
+ try:
74
+ task.result()
75
+ except Exception:
76
+ logger.exception('Exception in handler')
77
+
78
+ finally:
79
+ self.tasks.discard(task)
80
+
81
+ async def _message_to_update(self, message: str) -> Update:
82
+ '''
83
+ Converts raw message (containing a JSON) into a Python dict (with json),
84
+ then validated it and loads it into an Update object with Pydantic.
85
+ '''
86
+ data = json.loads(message)
87
+ return Update.de_json(data)
88
+
89
+ async def _dispatch(self, message: str) -> None:
90
+ '''
91
+ Converts raw message (containing a JSON) into a Python dict,
92
+ then dispatch it, to the registered handler, in a new task.
93
+ '''
94
+ try:
95
+ update = await self._message_to_update(message)
96
+ except json.JSONDecodeError:
97
+ logger.exception('Failed to decode JSON:')
98
+ except pydantic.ValidationError:
99
+ logger.exception('Failed pydantic validation:')
100
+ else:
101
+ task = asyncio.create_task(self.update_handler(update))
102
+ self.tasks.add(task)
103
+ task.add_done_callback(self._task_done_callback)
104
+
105
+ async def _shutdown(self) -> None:
106
+ '''
107
+ Wait for pending/running tasks to complete (with a timeout),
108
+ then close the Redis connection.
109
+ '''
110
+ if self.tasks:
111
+ logger.info('Waiting for %d tasks to complete...', len(self.tasks))
112
+ try:
113
+ await asyncio.wait_for(
114
+ asyncio.gather(*self.tasks, return_exceptions=True),
115
+ timeout=self.shutdown_wait_timeout
116
+ )
117
+ except asyncio.TimeoutError:
118
+ logger.warning('Some tasks were still running after timeout, CancelledError was raised.')
119
+
120
+ try:
121
+ await self.redis_client.aclose()
122
+ except Exception:
123
+ logger.exception('Exception while closing Redis connection:')
124
+
125
+ async def _listen_forever(self) -> None:
126
+ '''
127
+ Listen for messages coming from the PUBSUB channel forever.
128
+ In case of connection errors, it will retry after a delay.
129
+ '''
130
+ while True:
131
+ try:
132
+ logger.info('Subscribing to channel %s...', self.pubsub_channel)
133
+ await self.pubsub.subscribe(self.pubsub_channel)
134
+
135
+ logger.info('Subscribed to %s. Waiting for messages...', self.pubsub_channel)
136
+
137
+ async for message in self.pubsub.listen():
138
+ if message['type'] == 'message':
139
+ try:
140
+ await self._dispatch(message['data'])
141
+ except Exception:
142
+ logger.exception('Exception raised in the dispatcher:')
143
+
144
+ except redis.ConnectionError as e:
145
+ # logger.exception('Connection error, attempting to reconnect:')
146
+ logger.error('Connection error, attempting to reconnect: %s', e)
147
+ await asyncio.sleep(delay=self.redis_reconnect_delay)
148
+
149
+ # await self.pubsub.unsubscribe()
150
+
151
+ except asyncio.CancelledError:
152
+ logger.info('Received termination signal')
153
+ break
154
+
155
+ except Exception: # catch-all for unexpected exceptions; NEVER let the bot crash!
156
+ logger.exception('Unexpected exception in listen_forever:')
157
+ await asyncio.sleep(delay=self.redis_reconnect_delay)
158
+
159
+ async def _register_signal_handlers(self, task_to_cancel: asyncio.Task) -> None:
160
+ '''
161
+ Register signal handlers for graceful shutdown on:
162
+ - SIGTERM
163
+ - SIGINT
164
+ '''
165
+ loop = asyncio.get_running_loop()
166
+
167
+ for sig in (signal.SIGTERM, signal.SIGINT):
168
+ loop.add_signal_handler(
169
+ sig,
170
+ task_to_cancel.cancel
171
+ )
172
+
173
+ async def run_forever(self) -> None:
174
+ '''
175
+ Wait for incoming updates forever. This method **is blocking**.
176
+ Retries automatically after Redis connection errors, terminates on SIGINT/SIGTERM.
177
+
178
+ Example:
179
+ ```python
180
+ redis_client = redis.Redis(
181
+ host='localhost',
182
+ port=6379
183
+ )
184
+ dispatcher = RedisPubSubAsyncDispatcher(
185
+ update_handler=handler,
186
+ pubsub_channel='my_channel',
187
+ redis_client=redis_client
188
+ )
189
+ await dispatcher.run_forever()
190
+ ```
191
+ '''
192
+ task = asyncio.create_task(self._listen_forever())
193
+
194
+ try:
195
+ await task
196
+ except asyncio.CancelledError:
197
+ logger.info('Received termination signal')
198
+ finally:
199
+ logger.warning('Shutting down...')
200
+ await self._shutdown()
201
+
File without changes
File without changes
File without changes
File without changes
File without changes