botapi-py 1.4.0__tar.gz → 1.4.2__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,9 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: botapi-py
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: A simple and flexible Python library to conveniently build fast 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: orjson>=3.11.3 ; extra == 'redis'
7
8
  Requires-Dist: redis>=6.4.0 ; extra == 'redis'
8
9
  Requires-Python: >=3.12
9
10
  Project-URL: Telegram BotAPI Docs, https://core.telegram.org/bots/api
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "botapi-py"
3
- version = "1.4.0"
3
+ version = "1.4.2"
4
4
  description = "A simple and flexible Python library to conveniently build fast Telegram bots using the Bot API."
5
5
  # readme = "README.md"
6
6
  # authors = [
@@ -17,6 +17,7 @@ dependencies = [
17
17
 
18
18
  [project.optional-dependencies]
19
19
  redis = [
20
+ "orjson>=3.11.3",
20
21
  "redis>=6.4.0",
21
22
  ]
22
23
 
@@ -1,10 +1,14 @@
1
+ from .common import UpdateHandler
2
+
1
3
  from .get_updates_handler import GetUpdatesAsyncDispatcher
2
4
  try:
5
+ # Fails at runtime if redis-py is not installed
3
6
  from .redis_pubsub_handler import RedisPubSubAsyncDispatcher
4
7
  except ImportError:
5
8
  pass
6
9
 
7
10
  __all__ = [
11
+ 'UpdateHandler',
8
12
  'GetUpdatesAsyncDispatcher',
9
13
  'RedisPubSubAsyncDispatcher',
10
14
  ]
@@ -0,0 +1,10 @@
1
+ from .common import UpdateHandler
2
+
3
+ from .get_updates_handler import GetUpdatesAsyncDispatcher
4
+ from .redis_pubsub_handler import RedisPubSubAsyncDispatcher
5
+
6
+ __all__ = [
7
+ 'UpdateHandler',
8
+ 'GetUpdatesAsyncDispatcher',
9
+ 'RedisPubSubAsyncDispatcher',
10
+ ]
@@ -0,0 +1,62 @@
1
+ from abc import ABC
2
+ from typing import Any, Callable, Coroutine, TypeAlias, Union
3
+
4
+ from botapi.types import Update
5
+
6
+ UpdateHandler1: TypeAlias = Callable[
7
+ [Update],
8
+ Coroutine[Any, Any, None]
9
+ ]
10
+ '''
11
+ Represents an async update handler function.
12
+ It has the following signature:
13
+ - `async def handler(update: Update) -> None: ...`
14
+ '''
15
+
16
+ UpdateHandler2: TypeAlias = Callable[
17
+ [Update, str],
18
+ Coroutine[Any, Any, None]
19
+ ]
20
+ '''
21
+ Represents an async update handler function.
22
+ It has the following signature:
23
+ - `async def handler(update: Update, bot_token: str) -> None: ...`
24
+ '''
25
+
26
+ UpdateHandler: TypeAlias = Union[
27
+ UpdateHandler1,
28
+ UpdateHandler2
29
+ ]
30
+ '''
31
+ Represents an async update handler function.
32
+
33
+ It can have one of the following signatures:
34
+ - `async def handler(update: Update) -> None: ...`
35
+ - `async def handler(update: Update, bot_token: str) -> None: ...`
36
+
37
+ The second signature is especially useful when the dispatcher
38
+ is used to handle incoming updates from multiple bots.
39
+ '''
40
+
41
+ class BaseAsyncDispatcher(ABC):
42
+ '''
43
+ Abstract base class for all async update dispatchers.
44
+ '''
45
+
46
+ async def run_forever(self) -> None:
47
+ '''
48
+ Waits for incoming updates forever, and dispatches them to the registered handler.
49
+ This method **is blocking**, it terminates on SIGINT/SIGTERM signals.
50
+
51
+ This method automatically retries failed `getUpdates` requests after a short delay.
52
+
53
+ Example:
54
+ ```python
55
+ dispatcher = Dispatcher(
56
+ ...
57
+ )
58
+ await dispatcher.run_forever()
59
+ ```
60
+ '''
61
+ raise NotImplementedError()
62
+
@@ -1,22 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import inspect
4
5
  import logging
5
6
  import signal
6
- from typing import Any, Callable, Coroutine, TypeAlias
7
+ from typing import override
7
8
 
8
9
  import httpx
9
10
 
10
11
  import botapi
11
12
  from botapi.types import Update
12
13
 
13
- logger = logging.getLogger(__name__)
14
-
15
- UpdateHandler: TypeAlias = Callable[[Update], Coroutine[Any, Any, None]]
14
+ from .common import BaseAsyncDispatcher, UpdateHandler
16
15
 
16
+ logger = logging.getLogger(__name__)
17
17
 
18
- class GetUpdatesAsyncDispatcher:
18
+ class GetUpdatesAsyncDispatcher(BaseAsyncDispatcher):
19
19
  update_handler: UpdateHandler
20
+ update_handler_argcount: int
20
21
  tasks_shutdown_timeout: float
21
22
  tasks: set[asyncio.Task] # prevent gc from collecting tasks before they finish
22
23
  bot: botapi.methods.Bot
@@ -55,6 +56,7 @@ class GetUpdatesAsyncDispatcher:
55
56
  self.shutdown_wait_timeout = shutdown_wait_timeout
56
57
  self.offset = -1
57
58
  self.tasks = set()
59
+ self.update_handler_argcount = len(inspect.getfullargspec(update_handler).args)
58
60
 
59
61
  def _task_done_callback(self, task: asyncio.Task) -> None:
60
62
  '''
@@ -75,7 +77,13 @@ class GetUpdatesAsyncDispatcher:
75
77
  '''
76
78
  Dispatches the update to the registered handler, in a new task.
77
79
  '''
78
- task = asyncio.create_task(self.update_handler(update))
80
+ if self.update_handler_argcount == 1:
81
+ coro = self.update_handler(update) # pyright: ignore[reportCallIssue]
82
+ else:
83
+ coro = self.update_handler(update, self.bot.token) # pyright: ignore[reportCallIssue]
84
+
85
+ task = asyncio.create_task(coro)
86
+
79
87
  self.tasks.add(task)
80
88
  task.add_done_callback(self._task_done_callback)
81
89
 
@@ -155,6 +163,7 @@ class GetUpdatesAsyncDispatcher:
155
163
  task_to_cancel.cancel
156
164
  )
157
165
 
166
+ @override
158
167
  async def run_forever(self) -> None:
159
168
  '''
160
169
  Waits for incoming updates forever, and dispatches them to the registered handler.
@@ -1,12 +1,19 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
4
+ import inspect
2
5
  import json
3
6
  import logging
4
7
  import signal
5
- from typing import Any, Callable, Coroutine, TypeAlias
8
+ from typing import override
6
9
 
10
+ import orjson
7
11
  import pydantic
12
+
8
13
  from botapi.types import Update
9
14
 
15
+ from .common import BaseAsyncDispatcher, UpdateHandler
16
+
10
17
  try:
11
18
  import redis.asyncio as redis
12
19
  from redis.asyncio.client import PubSub
@@ -17,21 +24,22 @@ except ImportError as e:
17
24
 
18
25
  logger = logging.getLogger(__name__)
19
26
 
20
- UpdateHandler: TypeAlias = Callable[[Update], Coroutine[Any, Any, None]]
21
-
22
27
 
23
- class RedisPubSubAsyncDispatcher:
28
+ class RedisPubSubAsyncDispatcher(BaseAsyncDispatcher):
24
29
  update_handler: UpdateHandler
30
+ update_handler_argcount: int
25
31
  tasks_shutdown_timeout: float
26
32
  tasks: set[asyncio.Task] # prevent gc from collecting tasks before they finish
27
33
  redis_client: redis.Redis
28
34
  redis_reconnect_delay: float
29
35
  pubsub: PubSub
30
36
  pubsub_channel: str
37
+ bot_token_json_key: str
31
38
 
32
39
  def __init__(
33
40
  self,
34
41
  update_handler: UpdateHandler,
42
+ bot_token_json_key: str,
35
43
  pubsub_channel: str,
36
44
  redis_client: redis.Redis,
37
45
  redis_reconnect_delay: float = 5,
@@ -41,9 +49,20 @@ class RedisPubSubAsyncDispatcher:
41
49
  Dispatcher that receives updates from a Redis PUB/SUB
42
50
  and dispatches them to a registered async handler.
43
51
 
52
+ This dispatcher is atypical but very useful, especially when you need to handle
53
+ updates coming from a lot of different bots AND from many different projects.
54
+
55
+ **NOTE:**
56
+ This dispatcher assumes that the bot token is part of the update JSON payload itself
57
+ inside the `bot_token_json_key` key (see constructor), so either:
58
+ - the software handling the `PUBLISH` must manipulate the JSON accordingly, or
59
+ - a custom Bot API Server must do this.
60
+
44
61
  Args:
45
62
  update_handler (UpdateHandler):
46
63
  Async function to handle received messages.
64
+ bot_token_json_key (str):
65
+ Name of the JSON key in the update payload that contains the bot token.
47
66
  redis_client (redis.Redis):
48
67
  Redis client instance.
49
68
  pubsub_channel (str):
@@ -56,13 +75,15 @@ class RedisPubSubAsyncDispatcher:
56
75
  if not _has_redis:
57
76
  raise ImportError('redis-py is not installed. Install `botapi-py[redis]` to use this dispatcher.')
58
77
 
59
- self.redis_client = redis_client
60
78
  self.update_handler = update_handler
79
+ self.bot_token_json_key = bot_token_json_key
80
+ self.redis_client = redis_client
61
81
  self.pubsub_channel = pubsub_channel
62
82
  self.redis_reconnect_delay = redis_reconnect_delay
63
83
  self.shutdown_wait_timeout = shutdown_wait_timeout
64
84
  self.tasks = set()
65
85
  self.pubsub = self.redis_client.pubsub(ignore_subscribe_messages=True)
86
+ self.update_handler_argcount = len(inspect.getfullargspec(update_handler).args)
66
87
 
67
88
  def _task_done_callback(self, task: asyncio.Task) -> None:
68
89
  '''
@@ -79,13 +100,21 @@ class RedisPubSubAsyncDispatcher:
79
100
  finally:
80
101
  self.tasks.discard(task)
81
102
 
82
- async def _message_to_update(self, message: str) -> Update:
103
+ async def _message_to_update_and_token(self, message: str) -> tuple[Update, str]:
83
104
  '''
84
- Converts raw message (containing a JSON) into a Python dict (with json),
85
- then validated it and loads it into an Update object with Pydantic.
105
+ Converts raw message (containing a JSON) into a Python dict, then:
106
+ - extracts the bot token from the specified `self.bot_token_json_key` key
107
+ - validates the update into an `Update` object.
86
108
  '''
87
- data = json.loads(message)
88
- return Update.from_dict(data)
109
+ data: dict = orjson.loads(message)
110
+
111
+ if self.bot_token_json_key not in data:
112
+ err = f'Missing bot token key "{self.bot_token_json_key}" in the update JSON payload'
113
+ raise KeyError(err)
114
+
115
+ bot_token = data.pop(self.bot_token_json_key)
116
+
117
+ return Update.from_dict(data), bot_token
89
118
 
90
119
  async def _dispatch(self, message: str) -> None:
91
120
  '''
@@ -93,13 +122,19 @@ class RedisPubSubAsyncDispatcher:
93
122
  then dispatch it, to the registered handler, in a new task.
94
123
  '''
95
124
  try:
96
- update = await self._message_to_update(message)
125
+ update, bot_token = await self._message_to_update_and_token(message)
97
126
  except json.JSONDecodeError:
98
127
  logger.exception('Failed to decode JSON:')
99
128
  except pydantic.ValidationError:
100
129
  logger.exception('Failed pydantic validation:')
101
130
  else:
102
- task = asyncio.create_task(self.update_handler(update))
131
+ if self.update_handler_argcount == 1:
132
+ coro = self.update_handler(update) # pyright: ignore[reportCallIssue]
133
+ else:
134
+ coro = self.update_handler(update, bot_token) # pyright: ignore[reportCallIssue]
135
+
136
+ task = asyncio.create_task(coro)
137
+
103
138
  self.tasks.add(task)
104
139
  task.add_done_callback(self._task_done_callback)
105
140
 
@@ -171,6 +206,7 @@ class RedisPubSubAsyncDispatcher:
171
206
  task_to_cancel.cancel
172
207
  )
173
208
 
209
+ @override
174
210
  async def run_forever(self) -> None:
175
211
  '''
176
212
  Wait for incoming updates forever. This method **is blocking**.
@@ -14,6 +14,8 @@ class UpdateType(enum.StrEnum):
14
14
 
15
15
  You can use these to filter updates, using the `allowed_updates`
16
16
  parameter in `getUpdates` and `setWebhook` methods.
17
+
18
+ Source: https://github.com/tdlib/telegram-bot-api/blob/3b6a0b769c4a7fbe064087a4ad9fe6b1dbda498f/telegram-bot-api/Client.cpp#L15491
17
19
  '''
18
20
  MESSAGE = 'message'
19
21
  EDITED_MESSAGE = 'edited_message'
@@ -22,12 +24,24 @@ class UpdateType(enum.StrEnum):
22
24
  INLINE_QUERY = 'inline_query'
23
25
  CHOSEN_INLINE_RESULT = 'chosen_inline_result'
24
26
  CALLBACK_QUERY = 'callback_query'
27
+ CUSTOM_EVENT = 'custom_event'
28
+ CUSTOM_QUERY = 'custom_query'
25
29
  SHIPPING_QUERY = 'shipping_query'
26
30
  PRE_CHECKOUT_QUERY = 'pre_checkout_query'
27
31
  POLL = 'poll'
28
32
  POLL_ANSWER = 'poll_answer'
29
- CHAT_MEMBER_UPDATED = 'chat_member_updated'
33
+ MY_CHAT_MEMBER = 'my_chat_member'
34
+ CHAT_MEMBER = 'chat_member'
30
35
  CHAT_JOIN_REQUEST = 'chat_join_request'
36
+ CHAT_BOOST = 'chat_boost'
37
+ REMOVED_CHAT_BOOST = 'removed_chat_boost'
38
+ MESSAGE_REACTION = 'message_reaction'
39
+ MESSAGE_REACTION_COUNT = 'message_reaction_count'
40
+ BUSINESS_CONNECTION = 'business_connection'
41
+ BUSINESS_MESSAGE = 'business_message'
42
+ EDITED_BUSINESS_MESSAGE = 'edited_business_message'
43
+ DELETED_BUSINESS_MESSAGES = 'deleted_business_messages'
44
+ PURCHASED_PAID_MEDIA = 'purchased_paid_media'
31
45
 
32
46
  class MediaType(enum.StrEnum):
33
47
  '''
File without changes
File without changes
File without changes