botapi-py 1.1.1__tar.gz → 1.3.0__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.
- {botapi_py-1.1.1 → botapi_py-1.3.0}/PKG-INFO +3 -1
- {botapi_py-1.1.1 → botapi_py-1.3.0}/pyproject.toml +6 -1
- {botapi_py-1.1.1 → botapi_py-1.3.0}/src/botapi/__init__.py +2 -2
- {botapi_py-1.1.1 → botapi_py-1.3.0}/src/botapi/__init__.pyi +2 -2
- {botapi_py-1.1.1 → botapi_py-1.3.0}/src/botapi/api.py +45 -43
- botapi_py-1.3.0/src/botapi/boilerplate.py +20 -0
- botapi_py-1.3.0/src/botapi/dispatchers/__init__.py +7 -0
- botapi_py-1.3.0/src/botapi/dispatchers/get_updates_handler.py +188 -0
- botapi_py-1.3.0/src/botapi/dispatchers/redis_pubsub_handler.py +200 -0
- botapi_py-1.3.0/src/botapi/enums.py +164 -0
- {botapi_py-1.1.1 → botapi_py-1.3.0}/src/botapi/types.py +2938 -2927
- botapi_py-1.3.0/src/botapi/utils.py +1 -0
- botapi_py-1.1.1/src/botapi/enums.py +0 -30
- botapi_py-1.1.1/src/botapi/utils.py +0 -66
- {botapi_py-1.1.1 → botapi_py-1.3.0}/src/botapi/core.py +0 -0
- {botapi_py-1.1.1 → botapi_py-1.3.0}/src/botapi/errors.py +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: botapi-py
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
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.
|
|
3
|
+
version = "1.3.0"
|
|
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']
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# WARNING!
|
|
2
|
+
# This is an autogenerated file.
|
|
3
|
+
# Do NOT edit this file directly, your changes will be overwritten.
|
|
2
4
|
|
|
3
5
|
from __future__ import annotations
|
|
4
6
|
|
|
@@ -15,10 +17,14 @@ from .types import (
|
|
|
15
17
|
Message,
|
|
16
18
|
MessageId,
|
|
17
19
|
InaccessibleMessage,
|
|
20
|
+
MaybeInaccessibleMessage,
|
|
21
|
+
MaybeInaccessibleMessageAdapter,
|
|
18
22
|
MessageEntity,
|
|
19
23
|
TextQuote,
|
|
20
24
|
ExternalReplyInfo,
|
|
21
25
|
ReplyParameters,
|
|
26
|
+
MessageOrigin,
|
|
27
|
+
MessageOriginAdapter,
|
|
22
28
|
MessageOriginUser,
|
|
23
29
|
MessageOriginHiddenUser,
|
|
24
30
|
MessageOriginChat,
|
|
@@ -32,6 +38,8 @@ from .types import (
|
|
|
32
38
|
VideoNote,
|
|
33
39
|
Voice,
|
|
34
40
|
PaidMediaInfo,
|
|
41
|
+
PaidMedia,
|
|
42
|
+
PaidMediaAdapter,
|
|
35
43
|
PaidMediaPreview,
|
|
36
44
|
PaidMediaPhoto,
|
|
37
45
|
PaidMediaVideo,
|
|
@@ -53,9 +61,13 @@ from .types import (
|
|
|
53
61
|
ProximityAlertTriggered,
|
|
54
62
|
MessageAutoDeleteTimerChanged,
|
|
55
63
|
ChatBoostAdded,
|
|
64
|
+
BackgroundFill,
|
|
65
|
+
BackgroundFillAdapter,
|
|
56
66
|
BackgroundFillSolid,
|
|
57
67
|
BackgroundFillGradient,
|
|
58
68
|
BackgroundFillFreeformGradient,
|
|
69
|
+
BackgroundType,
|
|
70
|
+
BackgroundTypeAdapter,
|
|
59
71
|
BackgroundTypeFill,
|
|
60
72
|
BackgroundTypeWallpaper,
|
|
61
73
|
BackgroundTypePattern,
|
|
@@ -111,6 +123,8 @@ from .types import (
|
|
|
111
123
|
ChatInviteLink,
|
|
112
124
|
ChatAdministratorRights,
|
|
113
125
|
ChatMemberUpdated,
|
|
126
|
+
ChatMember,
|
|
127
|
+
ChatMemberAdapter,
|
|
114
128
|
ChatMemberOwner,
|
|
115
129
|
ChatMemberAdministrator,
|
|
116
130
|
ChatMemberMember,
|
|
@@ -126,6 +140,8 @@ from .types import (
|
|
|
126
140
|
BusinessOpeningHours,
|
|
127
141
|
StoryAreaPosition,
|
|
128
142
|
LocationAddress,
|
|
143
|
+
StoryAreaType,
|
|
144
|
+
StoryAreaTypeAdapter,
|
|
129
145
|
StoryAreaTypeLocation,
|
|
130
146
|
StoryAreaTypeSuggestedReaction,
|
|
131
147
|
StoryAreaTypeLink,
|
|
@@ -133,6 +149,8 @@ from .types import (
|
|
|
133
149
|
StoryAreaTypeUniqueGift,
|
|
134
150
|
StoryArea,
|
|
135
151
|
ChatLocation,
|
|
152
|
+
ReactionType,
|
|
153
|
+
ReactionTypeAdapter,
|
|
136
154
|
ReactionTypeEmoji,
|
|
137
155
|
ReactionTypeCustomEmoji,
|
|
138
156
|
ReactionTypePaid,
|
|
@@ -149,12 +167,16 @@ from .types import (
|
|
|
149
167
|
UniqueGift,
|
|
150
168
|
GiftInfo,
|
|
151
169
|
UniqueGiftInfo,
|
|
170
|
+
OwnedGift,
|
|
171
|
+
OwnedGiftAdapter,
|
|
152
172
|
OwnedGiftRegular,
|
|
153
173
|
OwnedGiftUnique,
|
|
154
174
|
OwnedGifts,
|
|
155
175
|
AcceptedGiftTypes,
|
|
156
176
|
StarAmount,
|
|
157
177
|
BotCommand,
|
|
178
|
+
BotCommandScope,
|
|
179
|
+
BotCommandScopeAdapter,
|
|
158
180
|
BotCommandScopeDefault,
|
|
159
181
|
BotCommandScopeAllPrivateChats,
|
|
160
182
|
BotCommandScopeAllGroupChats,
|
|
@@ -165,9 +187,13 @@ from .types import (
|
|
|
165
187
|
BotName,
|
|
166
188
|
BotDescription,
|
|
167
189
|
BotShortDescription,
|
|
190
|
+
MenuButton,
|
|
191
|
+
MenuButtonAdapter,
|
|
168
192
|
MenuButtonCommands,
|
|
169
193
|
MenuButtonWebApp,
|
|
170
194
|
MenuButtonDefault,
|
|
195
|
+
ChatBoostSource,
|
|
196
|
+
ChatBoostSourceAdapter,
|
|
171
197
|
ChatBoostSourcePremium,
|
|
172
198
|
ChatBoostSourceGiftCode,
|
|
173
199
|
ChatBoostSourceGiveaway,
|
|
@@ -179,16 +205,24 @@ from .types import (
|
|
|
179
205
|
BusinessConnection,
|
|
180
206
|
BusinessMessagesDeleted,
|
|
181
207
|
ResponseParameters,
|
|
208
|
+
InputMedia,
|
|
209
|
+
InputMediaAdapter,
|
|
182
210
|
InputMediaPhoto,
|
|
183
211
|
InputMediaVideo,
|
|
184
212
|
InputMediaAnimation,
|
|
185
213
|
InputMediaAudio,
|
|
186
214
|
InputMediaDocument,
|
|
187
215
|
InputFile,
|
|
216
|
+
InputPaidMedia,
|
|
217
|
+
InputPaidMediaAdapter,
|
|
188
218
|
InputPaidMediaPhoto,
|
|
189
219
|
InputPaidMediaVideo,
|
|
220
|
+
InputProfilePhoto,
|
|
221
|
+
InputProfilePhotoAdapter,
|
|
190
222
|
InputProfilePhotoStatic,
|
|
191
223
|
InputProfilePhotoAnimated,
|
|
224
|
+
InputStoryContent,
|
|
225
|
+
InputStoryContentAdapter,
|
|
192
226
|
InputStoryContentPhoto,
|
|
193
227
|
InputStoryContentVideo,
|
|
194
228
|
Sticker,
|
|
@@ -197,6 +231,8 @@ from .types import (
|
|
|
197
231
|
InputSticker,
|
|
198
232
|
InlineQuery,
|
|
199
233
|
InlineQueryResultsButton,
|
|
234
|
+
InlineQueryResult,
|
|
235
|
+
InlineQueryResultAdapter,
|
|
200
236
|
InlineQueryResultArticle,
|
|
201
237
|
InlineQueryResultPhoto,
|
|
202
238
|
InlineQueryResultGif,
|
|
@@ -217,6 +253,8 @@ from .types import (
|
|
|
217
253
|
InlineQueryResultCachedVideo,
|
|
218
254
|
InlineQueryResultCachedVoice,
|
|
219
255
|
InlineQueryResultCachedAudio,
|
|
256
|
+
InputMessageContent,
|
|
257
|
+
InputMessageContentAdapter,
|
|
220
258
|
InputTextMessageContent,
|
|
221
259
|
InputLocationMessageContent,
|
|
222
260
|
InputVenueMessageContent,
|
|
@@ -235,10 +273,14 @@ from .types import (
|
|
|
235
273
|
ShippingQuery,
|
|
236
274
|
PreCheckoutQuery,
|
|
237
275
|
PaidMediaPurchased,
|
|
276
|
+
RevenueWithdrawalState,
|
|
277
|
+
RevenueWithdrawalStateAdapter,
|
|
238
278
|
RevenueWithdrawalStatePending,
|
|
239
279
|
RevenueWithdrawalStateSucceeded,
|
|
240
280
|
RevenueWithdrawalStateFailed,
|
|
241
281
|
AffiliateInfo,
|
|
282
|
+
TransactionPartner,
|
|
283
|
+
TransactionPartnerAdapter,
|
|
242
284
|
TransactionPartnerUser,
|
|
243
285
|
TransactionPartnerChat,
|
|
244
286
|
TransactionPartnerAffiliateProgram,
|
|
@@ -252,6 +294,8 @@ from .types import (
|
|
|
252
294
|
PassportFile,
|
|
253
295
|
EncryptedPassportElement,
|
|
254
296
|
EncryptedCredentials,
|
|
297
|
+
PassportElementError,
|
|
298
|
+
PassportElementErrorAdapter,
|
|
255
299
|
PassportElementErrorDataField,
|
|
256
300
|
PassportElementErrorFrontSide,
|
|
257
301
|
PassportElementErrorReverseSide,
|
|
@@ -264,48 +308,6 @@ from .types import (
|
|
|
264
308
|
Game,
|
|
265
309
|
CallbackGame,
|
|
266
310
|
GameHighScore,
|
|
267
|
-
MaybeInaccessibleMessage,
|
|
268
|
-
MaybeInaccessibleMessageAdapter,
|
|
269
|
-
MessageOrigin,
|
|
270
|
-
MessageOriginAdapter,
|
|
271
|
-
PaidMedia,
|
|
272
|
-
PaidMediaAdapter,
|
|
273
|
-
BackgroundFill,
|
|
274
|
-
BackgroundFillAdapter,
|
|
275
|
-
BackgroundType,
|
|
276
|
-
BackgroundTypeAdapter,
|
|
277
|
-
ChatMember,
|
|
278
|
-
ChatMemberAdapter,
|
|
279
|
-
StoryAreaType,
|
|
280
|
-
StoryAreaTypeAdapter,
|
|
281
|
-
ReactionType,
|
|
282
|
-
ReactionTypeAdapter,
|
|
283
|
-
OwnedGift,
|
|
284
|
-
OwnedGiftAdapter,
|
|
285
|
-
BotCommandScope,
|
|
286
|
-
BotCommandScopeAdapter,
|
|
287
|
-
MenuButton,
|
|
288
|
-
MenuButtonAdapter,
|
|
289
|
-
ChatBoostSource,
|
|
290
|
-
ChatBoostSourceAdapter,
|
|
291
|
-
InputMedia,
|
|
292
|
-
InputMediaAdapter,
|
|
293
|
-
InputPaidMedia,
|
|
294
|
-
InputPaidMediaAdapter,
|
|
295
|
-
InputProfilePhoto,
|
|
296
|
-
InputProfilePhotoAdapter,
|
|
297
|
-
InputStoryContent,
|
|
298
|
-
InputStoryContentAdapter,
|
|
299
|
-
InlineQueryResult,
|
|
300
|
-
InlineQueryResultAdapter,
|
|
301
|
-
InputMessageContent,
|
|
302
|
-
InputMessageContentAdapter,
|
|
303
|
-
RevenueWithdrawalState,
|
|
304
|
-
RevenueWithdrawalStateAdapter,
|
|
305
|
-
TransactionPartner,
|
|
306
|
-
TransactionPartnerAdapter,
|
|
307
|
-
PassportElementError,
|
|
308
|
-
PassportElementErrorAdapter,
|
|
309
311
|
IntAdapter,
|
|
310
312
|
StrAdapter,
|
|
311
313
|
FloatAdapter,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar, Callable
|
|
4
|
+
|
|
5
|
+
# Enums
|
|
6
|
+
E = TypeVar('E', bound=enum.Enum)
|
|
7
|
+
|
|
8
|
+
def extend_enum(*sources: type[E]) -> Callable[..., type[E]]:
|
|
9
|
+
'''
|
|
10
|
+
Decorator that extends an Enum class with members
|
|
11
|
+
from one or more source Enum classes.
|
|
12
|
+
'''
|
|
13
|
+
def decorator(target: type[E]) -> type[E]:
|
|
14
|
+
for source in sources:
|
|
15
|
+
for member in source:
|
|
16
|
+
if member.name not in target.__members__:
|
|
17
|
+
target._member_map_[member.name] = member
|
|
18
|
+
return target
|
|
19
|
+
return decorator
|
|
20
|
+
|
|
@@ -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,200 @@
|
|
|
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
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import redis.asyncio as redis
|
|
14
|
+
from redis.asyncio.client import PubSub
|
|
15
|
+
except ImportError as e:
|
|
16
|
+
logger.error(
|
|
17
|
+
'The Redis python library is not installed. Please install it to use this feature.'
|
|
18
|
+
)
|
|
19
|
+
raise e
|
|
20
|
+
|
|
21
|
+
UpdateHandler: TypeAlias = Callable[[Update], Coroutine[Any, Any, None]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RedisPubSubAsyncDispatcher:
|
|
25
|
+
update_handler: UpdateHandler
|
|
26
|
+
tasks_shutdown_timeout: float
|
|
27
|
+
tasks: set[asyncio.Task] # prevent gc from collecting tasks before they finish
|
|
28
|
+
redis_client: redis.Redis
|
|
29
|
+
redis_reconnect_delay: float
|
|
30
|
+
pubsub: PubSub
|
|
31
|
+
pubsub_channel: str
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
update_handler: UpdateHandler,
|
|
36
|
+
pubsub_channel: str,
|
|
37
|
+
redis_client: redis.Redis,
|
|
38
|
+
redis_reconnect_delay: float = 5,
|
|
39
|
+
shutdown_wait_timeout: float = 3
|
|
40
|
+
) -> None:
|
|
41
|
+
'''
|
|
42
|
+
Dispatcher that receives updates from a Redis PUB/SUB
|
|
43
|
+
and dispatches them to a registered async handler.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
update_handler (UpdateHandler):
|
|
47
|
+
Async function to handle received messages.
|
|
48
|
+
redis_client (redis.Redis):
|
|
49
|
+
Redis client instance.
|
|
50
|
+
pubsub_channel (str):
|
|
51
|
+
Name of the Redis PUB/SUB channel to subscribe to.
|
|
52
|
+
redis_reconnect_delay (float, optional):
|
|
53
|
+
Delay in seconds before retrying connection on failure.
|
|
54
|
+
shutdown_wait_timeout (float, optional):
|
|
55
|
+
Timeout in seconds to wait for pending tasks to complete on shutdown.
|
|
56
|
+
'''
|
|
57
|
+
self.redis_client = redis_client
|
|
58
|
+
self.update_handler = update_handler
|
|
59
|
+
self.pubsub_channel = pubsub_channel
|
|
60
|
+
self.redis_reconnect_delay = redis_reconnect_delay
|
|
61
|
+
self.shutdown_wait_timeout = shutdown_wait_timeout
|
|
62
|
+
self.tasks = set()
|
|
63
|
+
self.pubsub = self.redis_client.pubsub(ignore_subscribe_messages=True)
|
|
64
|
+
|
|
65
|
+
def _task_done_callback(self, task: asyncio.Task) -> None:
|
|
66
|
+
'''
|
|
67
|
+
Callback that is called by the event loop when an update_handler task is done.
|
|
68
|
+
|
|
69
|
+
This catches and logs unhandled exceptions, so that they don't pass silently.
|
|
70
|
+
Also, it removes the completed task from the `tasks` set.
|
|
71
|
+
'''
|
|
72
|
+
try:
|
|
73
|
+
task.result()
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.exception('Exception in handler')
|
|
76
|
+
|
|
77
|
+
finally:
|
|
78
|
+
self.tasks.discard(task)
|
|
79
|
+
|
|
80
|
+
async def _message_to_update(self, message: str) -> Update:
|
|
81
|
+
'''
|
|
82
|
+
Converts raw message (containing a JSON) into a Python dict (with json),
|
|
83
|
+
then validated it and loads it into an Update object with Pydantic.
|
|
84
|
+
'''
|
|
85
|
+
data = json.loads(message)
|
|
86
|
+
return Update.de_json(data)
|
|
87
|
+
|
|
88
|
+
async def _dispatch(self, message: str) -> None:
|
|
89
|
+
'''
|
|
90
|
+
Converts raw message (containing a JSON) into a Python dict,
|
|
91
|
+
then dispatch it, to the registered handler, in a new task.
|
|
92
|
+
'''
|
|
93
|
+
try:
|
|
94
|
+
update = await self._message_to_update(message)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
logger.exception('Failed to decode JSON:')
|
|
97
|
+
except pydantic.ValidationError:
|
|
98
|
+
logger.exception('Failed pydantic validation:')
|
|
99
|
+
else:
|
|
100
|
+
task = asyncio.create_task(self.update_handler(update))
|
|
101
|
+
self.tasks.add(task)
|
|
102
|
+
task.add_done_callback(self._task_done_callback)
|
|
103
|
+
|
|
104
|
+
async def _shutdown(self) -> None:
|
|
105
|
+
'''
|
|
106
|
+
Wait for pending/running tasks to complete (with a timeout),
|
|
107
|
+
then close the Redis connection.
|
|
108
|
+
'''
|
|
109
|
+
if self.tasks:
|
|
110
|
+
logger.info('Waiting for %d tasks to complete...', len(self.tasks))
|
|
111
|
+
try:
|
|
112
|
+
await asyncio.wait_for(
|
|
113
|
+
asyncio.gather(*self.tasks, return_exceptions=True),
|
|
114
|
+
timeout=self.shutdown_wait_timeout
|
|
115
|
+
)
|
|
116
|
+
except asyncio.TimeoutError:
|
|
117
|
+
logger.warning('Some tasks were still running after timeout, CancelledError was raised.')
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
await self.redis_client.aclose()
|
|
121
|
+
except Exception:
|
|
122
|
+
logger.exception('Exception while closing Redis connection:')
|
|
123
|
+
|
|
124
|
+
async def _listen_forever(self) -> None:
|
|
125
|
+
'''
|
|
126
|
+
Listen for messages coming from the PUBSUB channel forever.
|
|
127
|
+
In case of connection errors, it will retry after a delay.
|
|
128
|
+
'''
|
|
129
|
+
while True:
|
|
130
|
+
try:
|
|
131
|
+
logger.info('Subscribing to channel %s...', self.pubsub_channel)
|
|
132
|
+
await self.pubsub.subscribe(self.pubsub_channel)
|
|
133
|
+
|
|
134
|
+
logger.info('Subscribed to %s. Waiting for messages...', self.pubsub_channel)
|
|
135
|
+
|
|
136
|
+
async for message in self.pubsub.listen():
|
|
137
|
+
if message['type'] == 'message':
|
|
138
|
+
try:
|
|
139
|
+
await self._dispatch(message['data'])
|
|
140
|
+
except Exception:
|
|
141
|
+
logger.exception('Exception raised in the dispatcher:')
|
|
142
|
+
|
|
143
|
+
except redis.ConnectionError as e:
|
|
144
|
+
# logger.exception('Connection error, attempting to reconnect:')
|
|
145
|
+
logger.error('Connection error, attempting to reconnect: %s', e)
|
|
146
|
+
await asyncio.sleep(delay=self.redis_reconnect_delay)
|
|
147
|
+
|
|
148
|
+
# await self.pubsub.unsubscribe()
|
|
149
|
+
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
logger.info('Received termination signal')
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
except Exception: # catch-all for unexpected exceptions; NEVER let the bot crash!
|
|
155
|
+
logger.exception('Unexpected exception in listen_forever:')
|
|
156
|
+
await asyncio.sleep(delay=self.redis_reconnect_delay)
|
|
157
|
+
|
|
158
|
+
async def _register_signal_handlers(self, task_to_cancel: asyncio.Task) -> None:
|
|
159
|
+
'''
|
|
160
|
+
Register signal handlers for graceful shutdown on:
|
|
161
|
+
- SIGTERM
|
|
162
|
+
- SIGINT
|
|
163
|
+
'''
|
|
164
|
+
loop = asyncio.get_running_loop()
|
|
165
|
+
|
|
166
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
167
|
+
loop.add_signal_handler(
|
|
168
|
+
sig,
|
|
169
|
+
task_to_cancel.cancel
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def run_forever(self) -> None:
|
|
173
|
+
'''
|
|
174
|
+
Wait for incoming updates forever. This method **is blocking**.
|
|
175
|
+
Retries automatically after Redis connection errors, terminates on SIGINT/SIGTERM.
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
```python
|
|
179
|
+
redis_client = redis.Redis(
|
|
180
|
+
host='localhost',
|
|
181
|
+
port=6379
|
|
182
|
+
)
|
|
183
|
+
dispatcher = RedisPubSubAsyncDispatcher(
|
|
184
|
+
update_handler=handler,
|
|
185
|
+
pubsub_channel='my_channel',
|
|
186
|
+
redis_client=redis_client
|
|
187
|
+
)
|
|
188
|
+
await dispatcher.run_forever()
|
|
189
|
+
```
|
|
190
|
+
'''
|
|
191
|
+
task = asyncio.create_task(self._listen_forever())
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
await task
|
|
195
|
+
except asyncio.CancelledError:
|
|
196
|
+
logger.info('Received termination signal')
|
|
197
|
+
finally:
|
|
198
|
+
logger.warning('Shutting down...')
|
|
199
|
+
await self._shutdown()
|
|
200
|
+
|