chibi-bot 1.6.0b0__py3-none-any.whl
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.
- chibi/__init__.py +0 -0
- chibi/__main__.py +343 -0
- chibi/cli.py +90 -0
- chibi/config/__init__.py +6 -0
- chibi/config/app.py +123 -0
- chibi/config/gpt.py +108 -0
- chibi/config/logging.py +15 -0
- chibi/config/telegram.py +43 -0
- chibi/config_generator.py +233 -0
- chibi/constants.py +362 -0
- chibi/exceptions.py +58 -0
- chibi/models.py +496 -0
- chibi/schemas/__init__.py +0 -0
- chibi/schemas/anthropic.py +20 -0
- chibi/schemas/app.py +54 -0
- chibi/schemas/cloudflare.py +65 -0
- chibi/schemas/mistralai.py +56 -0
- chibi/schemas/suno.py +83 -0
- chibi/service.py +135 -0
- chibi/services/bot.py +276 -0
- chibi/services/lock_manager.py +20 -0
- chibi/services/mcp/manager.py +242 -0
- chibi/services/metrics.py +54 -0
- chibi/services/providers/__init__.py +16 -0
- chibi/services/providers/alibaba.py +79 -0
- chibi/services/providers/anthropic.py +40 -0
- chibi/services/providers/cloudflare.py +98 -0
- chibi/services/providers/constants/suno.py +2 -0
- chibi/services/providers/customopenai.py +11 -0
- chibi/services/providers/deepseek.py +15 -0
- chibi/services/providers/eleven_labs.py +85 -0
- chibi/services/providers/gemini_native.py +489 -0
- chibi/services/providers/grok.py +40 -0
- chibi/services/providers/minimax.py +96 -0
- chibi/services/providers/mistralai_native.py +312 -0
- chibi/services/providers/moonshotai.py +20 -0
- chibi/services/providers/openai.py +74 -0
- chibi/services/providers/provider.py +892 -0
- chibi/services/providers/suno.py +130 -0
- chibi/services/providers/tools/__init__.py +23 -0
- chibi/services/providers/tools/cmd.py +132 -0
- chibi/services/providers/tools/common.py +127 -0
- chibi/services/providers/tools/constants.py +78 -0
- chibi/services/providers/tools/exceptions.py +1 -0
- chibi/services/providers/tools/file_editor.py +875 -0
- chibi/services/providers/tools/mcp_management.py +274 -0
- chibi/services/providers/tools/mcp_simple.py +72 -0
- chibi/services/providers/tools/media.py +451 -0
- chibi/services/providers/tools/memory.py +252 -0
- chibi/services/providers/tools/schemas.py +10 -0
- chibi/services/providers/tools/send.py +435 -0
- chibi/services/providers/tools/tool.py +163 -0
- chibi/services/providers/tools/utils.py +146 -0
- chibi/services/providers/tools/web.py +261 -0
- chibi/services/providers/utils.py +182 -0
- chibi/services/task_manager.py +93 -0
- chibi/services/user.py +269 -0
- chibi/storage/abstract.py +54 -0
- chibi/storage/database.py +86 -0
- chibi/storage/dynamodb.py +257 -0
- chibi/storage/local.py +70 -0
- chibi/storage/redis.py +91 -0
- chibi/utils/__init__.py +0 -0
- chibi/utils/app.py +249 -0
- chibi/utils/telegram.py +521 -0
- chibi_bot-1.6.0b0.dist-info/LICENSE +21 -0
- chibi_bot-1.6.0b0.dist-info/METADATA +340 -0
- chibi_bot-1.6.0b0.dist-info/RECORD +70 -0
- chibi_bot-1.6.0b0.dist-info/WHEEL +4 -0
- chibi_bot-1.6.0b0.dist-info/entry_points.txt +3 -0
chibi/utils/telegram.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from io import BytesIO
|
|
3
|
+
from typing import Any, Callable, Coroutine, ParamSpec, Type, TypeVar, cast
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import telegramify_markdown
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from telegram import (
|
|
10
|
+
Chat as TelegramChat,
|
|
11
|
+
)
|
|
12
|
+
from telegram import (
|
|
13
|
+
InputMediaDocument,
|
|
14
|
+
InputMediaPhoto,
|
|
15
|
+
Update,
|
|
16
|
+
constants,
|
|
17
|
+
)
|
|
18
|
+
from telegram import (
|
|
19
|
+
Message as TelegramMessage,
|
|
20
|
+
)
|
|
21
|
+
from telegram import (
|
|
22
|
+
User as TelegramUser,
|
|
23
|
+
)
|
|
24
|
+
from telegram.constants import FileSizeLimit
|
|
25
|
+
from telegram.error import BadRequest
|
|
26
|
+
from telegram.ext import ContextTypes
|
|
27
|
+
|
|
28
|
+
from chibi.config import telegram_settings
|
|
29
|
+
from chibi.constants import (
|
|
30
|
+
FILE_UPLOAD_TIMEOUT,
|
|
31
|
+
GROUP_CHAT_TYPES,
|
|
32
|
+
IMAGE_UPLOAD_TIMEOUT,
|
|
33
|
+
MARKDOWN_TOKENS,
|
|
34
|
+
PERSONAL_CHAT_TYPES,
|
|
35
|
+
UserAction,
|
|
36
|
+
UserContext,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
R = TypeVar("R")
|
|
40
|
+
P = ParamSpec("P")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_telegram_user(update: Update) -> TelegramUser:
|
|
44
|
+
if user := update.effective_user:
|
|
45
|
+
return user
|
|
46
|
+
raise ValueError(f"Telegram incoming update does not contain valid user data. Update ID: {update.update_id}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_telegram_chat(update: Update) -> TelegramChat:
|
|
50
|
+
if chat := update.effective_chat:
|
|
51
|
+
return chat
|
|
52
|
+
raise ValueError(f"Telegram incoming update does not contain valid chat data. Update ID: {update.update_id}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def user_data(update: Update) -> str:
|
|
56
|
+
user = get_telegram_user(update=update)
|
|
57
|
+
return f"{user.name} ({user.id})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def chat_data(update: Update) -> str:
|
|
61
|
+
chat = get_telegram_chat(update=update)
|
|
62
|
+
return f"{chat.type.upper()} chat ({chat.id})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_telegram_message(update: Update) -> TelegramMessage:
|
|
66
|
+
if message := update.effective_message:
|
|
67
|
+
return message
|
|
68
|
+
raise ValueError(f"Telegram incoming update does not contain valid message data. Update ID: {update.update_id}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_next_token(text: str, pos: int, escaped: bool) -> tuple[str | None, int]:
|
|
72
|
+
"""Find the next Markdown token at the given position.
|
|
73
|
+
|
|
74
|
+
Checks if the text starting from `pos` begins with any known Markdown token,
|
|
75
|
+
unless the preceding character was an escape character '\'.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
text: The string to search within.
|
|
79
|
+
pos: The starting position in the text to check for a token.
|
|
80
|
+
escaped: True if the character immediately preceding `pos` was an
|
|
81
|
+
escape character ('\'), False otherwise.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A tuple containing:
|
|
85
|
+
- The found Markdown token (str) or None if no token is found
|
|
86
|
+
at the position or if it's escaped.
|
|
87
|
+
- The length of the found token (int), or 0 if no token is found.
|
|
88
|
+
"""
|
|
89
|
+
if escaped:
|
|
90
|
+
return None, 0
|
|
91
|
+
for token in MARKDOWN_TOKENS:
|
|
92
|
+
if text.startswith(token, pos):
|
|
93
|
+
return token, len(token)
|
|
94
|
+
return None, 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_markdown_v2(
|
|
98
|
+
text: str,
|
|
99
|
+
limit: int = constants.MessageLimit.MAX_TEXT_LENGTH,
|
|
100
|
+
recommended_margin: int = 400,
|
|
101
|
+
safety_margin: int = 50,
|
|
102
|
+
) -> list[str]:
|
|
103
|
+
"""Split a Markdown text into chunks while trying to preserve formatting.
|
|
104
|
+
|
|
105
|
+
Attempts to split the text into chunks smaller than `limit`. It tracks
|
|
106
|
+
opening and closing Markdown tokens using a stack. When a chunk needs to be
|
|
107
|
+
split, it appends the necessary closing tokens to the end of the current
|
|
108
|
+
chunk and prepends the corresponding opening tokens to the beginning of the
|
|
109
|
+
next chunk.
|
|
110
|
+
|
|
111
|
+
Splitting priority:
|
|
112
|
+
1. At a newline character when the buffer size is close to the limit
|
|
113
|
+
(within `recommended_margin`).
|
|
114
|
+
2. Anywhere when the buffer size is very close to the limit
|
|
115
|
+
(within `safety_margin`).
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
text: The Markdown string to split.
|
|
119
|
+
limit: The maximum desired length for each chunk. Defaults to
|
|
120
|
+
`constants.MessageLimit.MAX_TEXT_LENGTH`.
|
|
121
|
+
recommended_margin: The preferred distance from the `limit` at which
|
|
122
|
+
to split, ideally looking for a newline.
|
|
123
|
+
safety_margin: The absolute minimum distance from the `limit` at which
|
|
124
|
+
a split must occur, regardless of the character.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A list of strings, where each string is a chunk of the original text,
|
|
128
|
+
with formatting tokens adjusted to maintain validity across chunks.
|
|
129
|
+
"""
|
|
130
|
+
if len(text) <= limit:
|
|
131
|
+
return [text]
|
|
132
|
+
|
|
133
|
+
chunks: list[str] = []
|
|
134
|
+
buffer: list[str] = []
|
|
135
|
+
stack: deque[str] = deque()
|
|
136
|
+
|
|
137
|
+
i = 0
|
|
138
|
+
escaped: bool = False
|
|
139
|
+
|
|
140
|
+
while i < len(text):
|
|
141
|
+
token, shift = _get_next_token(text=text, pos=i, escaped=escaped)
|
|
142
|
+
escaped = text[i] == "\\"
|
|
143
|
+
if token:
|
|
144
|
+
buffer.append(token)
|
|
145
|
+
if stack and stack[-1] == token:
|
|
146
|
+
stack.pop()
|
|
147
|
+
else:
|
|
148
|
+
stack.append(token)
|
|
149
|
+
i += shift
|
|
150
|
+
else:
|
|
151
|
+
buffer.append(text[i])
|
|
152
|
+
i += 1
|
|
153
|
+
|
|
154
|
+
if len("".join(buffer)) >= limit - recommended_margin:
|
|
155
|
+
if text[i] == "\n":
|
|
156
|
+
closing_sequence = "\n".join(reversed(stack))
|
|
157
|
+
chunks.append("".join(buffer) + closing_sequence)
|
|
158
|
+
buffer = list(stack)
|
|
159
|
+
|
|
160
|
+
elif len("".join(buffer)) >= limit - safety_margin:
|
|
161
|
+
closing_sequence = "\n".join(reversed(stack))
|
|
162
|
+
chunks.append("".join(buffer) + closing_sequence)
|
|
163
|
+
buffer = list(stack)
|
|
164
|
+
|
|
165
|
+
if buffer:
|
|
166
|
+
chunks.append("".join(buffer))
|
|
167
|
+
return chunks
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def send_message(
|
|
171
|
+
update: Update,
|
|
172
|
+
context: ContextTypes.DEFAULT_TYPE,
|
|
173
|
+
reply: bool = True,
|
|
174
|
+
**kwargs: Any,
|
|
175
|
+
) -> TelegramMessage:
|
|
176
|
+
telegram_chat = get_telegram_chat(update=update)
|
|
177
|
+
telegram_message = get_telegram_message(update=update)
|
|
178
|
+
|
|
179
|
+
if reply:
|
|
180
|
+
return await context.bot.send_message(
|
|
181
|
+
chat_id=telegram_chat.id,
|
|
182
|
+
reply_to_message_id=telegram_message.message_id,
|
|
183
|
+
**kwargs,
|
|
184
|
+
)
|
|
185
|
+
return await context.bot.send_message(chat_id=telegram_chat.id, **kwargs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def send_long_message(
|
|
189
|
+
message: str,
|
|
190
|
+
update: Update,
|
|
191
|
+
context: ContextTypes.DEFAULT_TYPE,
|
|
192
|
+
parse_mode: str | None = None,
|
|
193
|
+
normalize_md: bool = True,
|
|
194
|
+
reply: bool = True,
|
|
195
|
+
) -> None:
|
|
196
|
+
if normalize_md:
|
|
197
|
+
message = telegramify_markdown.markdownify(message)
|
|
198
|
+
chunks = split_markdown_v2(message)
|
|
199
|
+
else:
|
|
200
|
+
chunks = [
|
|
201
|
+
message[i : i + constants.MessageLimit.MAX_TEXT_LENGTH]
|
|
202
|
+
for i in range(0, len(message), constants.MessageLimit.MAX_TEXT_LENGTH)
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
for chunk_number, chunk in enumerate(chunks):
|
|
206
|
+
await send_message(
|
|
207
|
+
update=update,
|
|
208
|
+
context=context,
|
|
209
|
+
text=chunk,
|
|
210
|
+
parse_mode=parse_mode,
|
|
211
|
+
reply=chunk_number == 0 if reply else False,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def send_audio(
|
|
216
|
+
audio: bytes,
|
|
217
|
+
update: Update,
|
|
218
|
+
context: ContextTypes.DEFAULT_TYPE,
|
|
219
|
+
) -> None:
|
|
220
|
+
telegram_chat = get_telegram_chat(update=update)
|
|
221
|
+
telegram_message = get_telegram_message(update=update)
|
|
222
|
+
await context.bot.send_chat_action(chat_id=telegram_chat.id, action=constants.ChatAction.RECORD_VOICE)
|
|
223
|
+
|
|
224
|
+
await context.bot.send_audio(
|
|
225
|
+
chat_id=telegram_chat.id, audio=audio, title="voice", reply_to_message_id=telegram_message.message_id
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def download_image(image_url: str) -> bytes:
|
|
230
|
+
parsed_url = urlparse(image_url)
|
|
231
|
+
params = parse_qs(parsed_url.query)
|
|
232
|
+
image_url = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}"
|
|
233
|
+
response = await httpx.AsyncClient().get(url=image_url, params=params)
|
|
234
|
+
response.raise_for_status()
|
|
235
|
+
return response.content
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def send_images(
|
|
239
|
+
images: list[str] | list[BytesIO],
|
|
240
|
+
update: Update,
|
|
241
|
+
context: ContextTypes.DEFAULT_TYPE,
|
|
242
|
+
) -> None:
|
|
243
|
+
telegram_chat = get_telegram_chat(update=update)
|
|
244
|
+
telegram_message = get_telegram_message(update=update)
|
|
245
|
+
await context.bot.send_chat_action(chat_id=telegram_chat.id, action=constants.ChatAction.UPLOAD_PHOTO)
|
|
246
|
+
|
|
247
|
+
if isinstance(images[0], str):
|
|
248
|
+
logger.info(f"Downloading {len(images)} images for {user_data(update)} via URLs...")
|
|
249
|
+
image_files = [await download_image(image_url=cast(str, url)) for url in images]
|
|
250
|
+
try:
|
|
251
|
+
logger.info(f"Uploading {len(images)} images to {user_data(update)} in the {chat_data(update)}")
|
|
252
|
+
await context.bot.send_media_group(
|
|
253
|
+
chat_id=telegram_chat.id,
|
|
254
|
+
media=[InputMediaPhoto(url) for url in image_files],
|
|
255
|
+
reply_to_message_id=telegram_message.message_id,
|
|
256
|
+
read_timeout=IMAGE_UPLOAD_TIMEOUT,
|
|
257
|
+
write_timeout=IMAGE_UPLOAD_TIMEOUT,
|
|
258
|
+
)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.error(
|
|
261
|
+
f"{user_data(update)} image generation request succeeded, but we couldn't send the image "
|
|
262
|
+
f"due to exception: {e}. Trying to send it via text message..."
|
|
263
|
+
)
|
|
264
|
+
image_urls = cast(list[str], images)
|
|
265
|
+
await send_message(
|
|
266
|
+
update=update,
|
|
267
|
+
context=context,
|
|
268
|
+
text="\n".join(image_urls),
|
|
269
|
+
disable_web_page_preview=False,
|
|
270
|
+
)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
logger.info(f"Uploading {len(images)} image(s) to {user_data(update)} in the {chat_data(update)}")
|
|
274
|
+
media_photos: list[BytesIO] = []
|
|
275
|
+
media_docs: list[BytesIO] = []
|
|
276
|
+
|
|
277
|
+
for file in images:
|
|
278
|
+
if not isinstance(file, BytesIO):
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
file.seek(0, 2)
|
|
282
|
+
size = file.tell()
|
|
283
|
+
file.seek(0)
|
|
284
|
+
if size < FileSizeLimit.PHOTOSIZE_UPLOAD:
|
|
285
|
+
media_photos.append(file)
|
|
286
|
+
elif size < FileSizeLimit.FILESIZE_UPLOAD:
|
|
287
|
+
media_docs.append(file)
|
|
288
|
+
else:
|
|
289
|
+
logger.error(f"{user_data(update)} File size ({size}) exceeds file size limit, skipping it..")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if media_photos:
|
|
293
|
+
await context.bot.send_media_group(
|
|
294
|
+
chat_id=telegram_chat.id,
|
|
295
|
+
media=[InputMediaPhoto(img) for img in media_photos],
|
|
296
|
+
reply_to_message_id=telegram_message.message_id,
|
|
297
|
+
write_timeout=IMAGE_UPLOAD_TIMEOUT,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if media_docs:
|
|
301
|
+
logger.info(f"Uploading {len(images)} image(s) as file(s) to {user_data(update)} in the {chat_data(update)}")
|
|
302
|
+
|
|
303
|
+
await context.bot.send_media_group(
|
|
304
|
+
chat_id=telegram_chat.id,
|
|
305
|
+
media=[InputMediaDocument(media=img, filename="file.jpeg") for img in media_docs],
|
|
306
|
+
reply_to_message_id=telegram_message.message_id,
|
|
307
|
+
write_timeout=FILE_UPLOAD_TIMEOUT,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def send_text_file(file_content: str, file_name: str, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
312
|
+
telegram_chat = get_telegram_chat(update=update)
|
|
313
|
+
text_file = BytesIO(file_content.encode("utf-8"))
|
|
314
|
+
text_file.name = file_name
|
|
315
|
+
|
|
316
|
+
await context.bot.send_document(
|
|
317
|
+
chat_id=telegram_chat.id,
|
|
318
|
+
document=text_file,
|
|
319
|
+
filename=file_name,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def send_message_in_plain_text_and_file(
|
|
324
|
+
message: str,
|
|
325
|
+
update: Update,
|
|
326
|
+
context: ContextTypes.DEFAULT_TYPE,
|
|
327
|
+
reply: bool = True,
|
|
328
|
+
) -> None:
|
|
329
|
+
telegram_chat = get_telegram_chat(update=update)
|
|
330
|
+
|
|
331
|
+
await send_long_message(message=message, update=update, context=context, normalize_md=False, reply=reply)
|
|
332
|
+
file = BytesIO()
|
|
333
|
+
file.write(message.encode())
|
|
334
|
+
file.seek(0)
|
|
335
|
+
explain_message_text = (
|
|
336
|
+
"Oops! 😯It looks like your answer contains some code, but Telegram can't display it properly. "
|
|
337
|
+
"I'll additionally add your answer to the markdown file. 👇"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await send_message(update=update, context=context, text=explain_message_text, reply=False)
|
|
341
|
+
await context.bot.send_document(
|
|
342
|
+
chat_id=telegram_chat.id,
|
|
343
|
+
document=file,
|
|
344
|
+
filename="answer.md",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def send_gpt_answer_message(
|
|
349
|
+
gpt_answer: str, update: Update, context: ContextTypes.DEFAULT_TYPE, reply: bool = True
|
|
350
|
+
) -> None:
|
|
351
|
+
try:
|
|
352
|
+
await send_long_message(
|
|
353
|
+
message=gpt_answer,
|
|
354
|
+
update=update,
|
|
355
|
+
context=context,
|
|
356
|
+
parse_mode=constants.ParseMode.MARKDOWN_V2,
|
|
357
|
+
reply=reply,
|
|
358
|
+
)
|
|
359
|
+
except BadRequest as e:
|
|
360
|
+
# Trying to handle an exception connected with markdown parsing: just re-sending the message in a text mode.
|
|
361
|
+
logger.error(
|
|
362
|
+
f"{user_data(update)} got a Telegram Bad Request error in the {chat_data(update)} "
|
|
363
|
+
f"while receiving GPT answer: {e}. Trying to re-send it in plain text mode."
|
|
364
|
+
)
|
|
365
|
+
await send_message_in_plain_text_and_file(message=gpt_answer, update=update, context=context, reply=reply)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def current_user_action(context: ContextTypes.DEFAULT_TYPE) -> UserAction:
|
|
369
|
+
"""Get the current action state associated with the user.
|
|
370
|
+
|
|
371
|
+
Retrieve the action stored under the `UserContext.ACTION` key in the user's
|
|
372
|
+
context data. If `user_data` is missing or the action is not set, it defaults
|
|
373
|
+
to `UserAction.NONE`.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
context: The update context provided by the `python-telegram-bot` library.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
The current `UserAction` enum member associated with the user. Defaults to
|
|
380
|
+
`UserAction.NONE` if no action is set or `user_data` is unavailable.
|
|
381
|
+
"""
|
|
382
|
+
if context.user_data is None:
|
|
383
|
+
return UserAction.NONE
|
|
384
|
+
return context.user_data.get(UserContext.ACTION, UserAction.NONE)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def set_user_action(context: ContextTypes.DEFAULT_TYPE, action: UserAction) -> None:
|
|
388
|
+
"""Set the current action state for the user.
|
|
389
|
+
|
|
390
|
+
Store the provided `UserAction` under the `UserContext.ACTION` key in the user's
|
|
391
|
+
context data. If `user_data` does not exist, do nothing.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
context: The update context provided by the `python-telegram-bot` library.
|
|
395
|
+
action: The `UserAction` enum member to set as the user's current action state.
|
|
396
|
+
"""
|
|
397
|
+
if context.user_data is not None:
|
|
398
|
+
context.user_data[UserContext.ACTION] = action
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def user_interacts_with_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
|
|
403
|
+
telegram_message = get_telegram_message(update=update)
|
|
404
|
+
prompt = telegram_message.text
|
|
405
|
+
|
|
406
|
+
if not prompt:
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
if context.bot.first_name in prompt or context.bot.username in prompt:
|
|
410
|
+
return True
|
|
411
|
+
|
|
412
|
+
reply_message = telegram_message.reply_to_message
|
|
413
|
+
if not reply_message or not reply_message.from_user:
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
return reply_message.from_user.id == context.bot.id
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def get_user_context(context: ContextTypes.DEFAULT_TYPE, key: UserContext, expected_type: Type[R]) -> R | None:
|
|
420
|
+
"""Retrieve a specific value from the user's context data.
|
|
421
|
+
|
|
422
|
+
Safely access the `user_data` dictionary associated with the current user
|
|
423
|
+
in the Telegram bot context and return the value associated with the given key,
|
|
424
|
+
cast to the expected type.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
context: The update context provided by the `python-telegram-bot` library.
|
|
428
|
+
key: An enum member (UserContext) representing the key for the data to retrieve.
|
|
429
|
+
expected_type: The Python type the retrieved value is expected to conform to.
|
|
430
|
+
Used for casting the result.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
The value associated with the key, cast to `expected_type`, if it exists
|
|
434
|
+
and `user_data` is available. Otherwise, returns None.
|
|
435
|
+
"""
|
|
436
|
+
if context.user_data is not None:
|
|
437
|
+
return cast(R, context.user_data.get(key, None))
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def set_user_context(context: ContextTypes.DEFAULT_TYPE, key: UserContext, value: object | None) -> None:
|
|
442
|
+
"""Set or update a specific value in the user's context data.
|
|
443
|
+
|
|
444
|
+
Safely access the `user_data` dictionary associated with the current user
|
|
445
|
+
and store the provided value under the given key. If `user_data` does not
|
|
446
|
+
exist, do nothing.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
context: The update context provided by the `python-telegram-bot` library.
|
|
450
|
+
key: An enum member (UserContext) representing the key under which to store the value.
|
|
451
|
+
value: The value to store in the user's context data. Can be any object or None.
|
|
452
|
+
"""
|
|
453
|
+
if context.user_data is not None:
|
|
454
|
+
context.user_data[key] = value
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def user_is_allowed(tg_user: TelegramUser) -> bool:
|
|
459
|
+
if not telegram_settings.users_whitelist:
|
|
460
|
+
return True
|
|
461
|
+
return any(identifier in telegram_settings.users_whitelist for identifier in (str(tg_user.id), tg_user.username))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def group_is_allowed(tg_chat: TelegramChat) -> bool:
|
|
465
|
+
if not telegram_settings.groups_whitelist:
|
|
466
|
+
return True
|
|
467
|
+
return tg_chat.id in telegram_settings.groups_whitelist
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def check_user_allowance(
|
|
471
|
+
func: Callable[P, Coroutine[Any, Any, R]],
|
|
472
|
+
) -> Callable[P, Coroutine[Any, Any, R | None]]:
|
|
473
|
+
"""Decorator controlling access to the chatbot.
|
|
474
|
+
|
|
475
|
+
This deco checks:
|
|
476
|
+
- if the specific user is allowed to interact with the chatbot, using ALLOW_BOTS and USERS_WHITELIST;
|
|
477
|
+
- if the chatbot is allowed to be in a specific group, using a GROUPS_WHITELIST.
|
|
478
|
+
|
|
479
|
+
If the specific user is disallowed to interact with the chatbot, the corresponding message will be sent.
|
|
480
|
+
If the chatbot is disallowed to be in a specific group, it will send the corresponding message
|
|
481
|
+
and leave it immediately.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
func: async function that may rise openai exception.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
Wrapper function object.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
|
|
491
|
+
update: Update = cast(Update, kwargs.get("update", None) or args[1])
|
|
492
|
+
context: ContextTypes.DEFAULT_TYPE = cast(ContextTypes.DEFAULT_TYPE, kwargs.get("context") or args[2])
|
|
493
|
+
telegram_chat = get_telegram_chat(update=update)
|
|
494
|
+
telegram_user = get_telegram_user(update=update)
|
|
495
|
+
|
|
496
|
+
if telegram_user.is_bot and not telegram_settings.allow_bots:
|
|
497
|
+
logger.warning(f"Bots are not allowed. Request from {user_data(update)} was ignored.")
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
if telegram_chat.type in PERSONAL_CHAT_TYPES and not user_is_allowed(tg_user=telegram_user):
|
|
501
|
+
logger.warning(f"{user_data(update)} is not allowed to work with me. Request rejected.")
|
|
502
|
+
await send_message(
|
|
503
|
+
update=update,
|
|
504
|
+
context=context,
|
|
505
|
+
text=telegram_settings.message_for_disallowed_users,
|
|
506
|
+
)
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
if telegram_chat.type in GROUP_CHAT_TYPES and not group_is_allowed(tg_chat=telegram_chat):
|
|
510
|
+
message = (
|
|
511
|
+
f"The group {telegram_chat.effective_name} (id: {telegram_chat.id}, link: {telegram_chat.link}) "
|
|
512
|
+
f"does not exist in the whitelist. Leaving it..."
|
|
513
|
+
)
|
|
514
|
+
logger.warning(message)
|
|
515
|
+
await context.bot.send_message(chat_id=telegram_chat.id, text=message, disable_web_page_preview=True)
|
|
516
|
+
await telegram_chat.leave()
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
return await func(*args, **kwargs)
|
|
520
|
+
|
|
521
|
+
return wrapper
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Sergei Nagaev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|