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.
Files changed (70) hide show
  1. chibi/__init__.py +0 -0
  2. chibi/__main__.py +343 -0
  3. chibi/cli.py +90 -0
  4. chibi/config/__init__.py +6 -0
  5. chibi/config/app.py +123 -0
  6. chibi/config/gpt.py +108 -0
  7. chibi/config/logging.py +15 -0
  8. chibi/config/telegram.py +43 -0
  9. chibi/config_generator.py +233 -0
  10. chibi/constants.py +362 -0
  11. chibi/exceptions.py +58 -0
  12. chibi/models.py +496 -0
  13. chibi/schemas/__init__.py +0 -0
  14. chibi/schemas/anthropic.py +20 -0
  15. chibi/schemas/app.py +54 -0
  16. chibi/schemas/cloudflare.py +65 -0
  17. chibi/schemas/mistralai.py +56 -0
  18. chibi/schemas/suno.py +83 -0
  19. chibi/service.py +135 -0
  20. chibi/services/bot.py +276 -0
  21. chibi/services/lock_manager.py +20 -0
  22. chibi/services/mcp/manager.py +242 -0
  23. chibi/services/metrics.py +54 -0
  24. chibi/services/providers/__init__.py +16 -0
  25. chibi/services/providers/alibaba.py +79 -0
  26. chibi/services/providers/anthropic.py +40 -0
  27. chibi/services/providers/cloudflare.py +98 -0
  28. chibi/services/providers/constants/suno.py +2 -0
  29. chibi/services/providers/customopenai.py +11 -0
  30. chibi/services/providers/deepseek.py +15 -0
  31. chibi/services/providers/eleven_labs.py +85 -0
  32. chibi/services/providers/gemini_native.py +489 -0
  33. chibi/services/providers/grok.py +40 -0
  34. chibi/services/providers/minimax.py +96 -0
  35. chibi/services/providers/mistralai_native.py +312 -0
  36. chibi/services/providers/moonshotai.py +20 -0
  37. chibi/services/providers/openai.py +74 -0
  38. chibi/services/providers/provider.py +892 -0
  39. chibi/services/providers/suno.py +130 -0
  40. chibi/services/providers/tools/__init__.py +23 -0
  41. chibi/services/providers/tools/cmd.py +132 -0
  42. chibi/services/providers/tools/common.py +127 -0
  43. chibi/services/providers/tools/constants.py +78 -0
  44. chibi/services/providers/tools/exceptions.py +1 -0
  45. chibi/services/providers/tools/file_editor.py +875 -0
  46. chibi/services/providers/tools/mcp_management.py +274 -0
  47. chibi/services/providers/tools/mcp_simple.py +72 -0
  48. chibi/services/providers/tools/media.py +451 -0
  49. chibi/services/providers/tools/memory.py +252 -0
  50. chibi/services/providers/tools/schemas.py +10 -0
  51. chibi/services/providers/tools/send.py +435 -0
  52. chibi/services/providers/tools/tool.py +163 -0
  53. chibi/services/providers/tools/utils.py +146 -0
  54. chibi/services/providers/tools/web.py +261 -0
  55. chibi/services/providers/utils.py +182 -0
  56. chibi/services/task_manager.py +93 -0
  57. chibi/services/user.py +269 -0
  58. chibi/storage/abstract.py +54 -0
  59. chibi/storage/database.py +86 -0
  60. chibi/storage/dynamodb.py +257 -0
  61. chibi/storage/local.py +70 -0
  62. chibi/storage/redis.py +91 -0
  63. chibi/utils/__init__.py +0 -0
  64. chibi/utils/app.py +249 -0
  65. chibi/utils/telegram.py +521 -0
  66. chibi_bot-1.6.0b0.dist-info/LICENSE +21 -0
  67. chibi_bot-1.6.0b0.dist-info/METADATA +340 -0
  68. chibi_bot-1.6.0b0.dist-info/RECORD +70 -0
  69. chibi_bot-1.6.0b0.dist-info/WHEEL +4 -0
  70. chibi_bot-1.6.0b0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,257 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any
4
+
5
+ import boto3
6
+ from botocore.exceptions import ClientError
7
+
8
+ from chibi.models import Message, User
9
+ from chibi.storage.abstract import Database
10
+
11
+
12
+ class DynamoDBStorage(Database):
13
+ """DynamoDB storage backend implementing Database interface.
14
+
15
+ Uses two DynamoDB tables:
16
+ - users table (PK=user_id)
17
+ - messages table (PK=user_id, SK=message_id)
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ users_table_name: str,
23
+ messages_table_name: str,
24
+ aws_region: str,
25
+ aws_access_key_id: str | None = None,
26
+ aws_secret_access_key: str | None = None,
27
+ ) -> None:
28
+ """Initialize the DynamoDBStorage.
29
+
30
+ Args:
31
+ users_table_name: Name of the DynamoDB table for users.
32
+ messages_table_name: Name of the DynamoDB table for messages.
33
+ aws_region: AWS region for the DynamoDB tables.
34
+ aws_access_key_id: AWS access key ID. Defaults to None.
35
+ aws_secret_access_key: AWS secret access key. Defaults to None.
36
+ """
37
+ session = boto3.Session(
38
+ aws_access_key_id=aws_access_key_id,
39
+ aws_secret_access_key=aws_secret_access_key,
40
+ region_name=aws_region,
41
+ )
42
+ self.dynamodb = session.resource("dynamodb")
43
+ self.users_table = self.dynamodb.Table(users_table_name)
44
+ self.messages_table = self.dynamodb.Table(messages_table_name)
45
+
46
+ @classmethod
47
+ async def create(
48
+ cls,
49
+ region: str,
50
+ access_key: str | None,
51
+ secret_access_key: str | None,
52
+ users_table: str,
53
+ messages_table: str,
54
+ ) -> "DynamoDBStorage":
55
+ """Create and initializes an instance of DynamoDBStorage.
56
+
57
+ Args:
58
+ region: AWS region.
59
+ access_key: AWS access key ID.
60
+ secret_access_key: AWS secret access key.
61
+ users_table: Name of the users table.
62
+ messages_table: Name of the messages table.
63
+
64
+ Returns:
65
+ DynamoDBStorage: An instance of the DynamoDBStorage class.
66
+ """
67
+ instance = cls(
68
+ users_table_name=users_table,
69
+ messages_table_name=messages_table,
70
+ aws_region=region,
71
+ aws_access_key_id=access_key,
72
+ aws_secret_access_key=secret_access_key,
73
+ )
74
+ await instance.connect()
75
+ return instance
76
+
77
+ async def connect(self) -> None:
78
+ """Ensure users and messages tables exist (creates if missing).
79
+
80
+ Raises:
81
+ ClientError: If there's an issue with DynamoDB operations other than
82
+ ResourceNotFoundException when checking/creating tables.
83
+ """
84
+ client = self.dynamodb.meta.client
85
+ # users table
86
+ try:
87
+ client.describe_table(TableName=self.users_table.table_name)
88
+ except ClientError as e:
89
+ code = e.response.get("Error", {}).get("Code")
90
+ if code == "ResourceNotFoundException":
91
+ client.create_table(
92
+ TableName=self.users_table.table_name,
93
+ KeySchema=[{"AttributeName": "user_id", "KeyType": "HASH"}],
94
+ AttributeDefinitions=[{"AttributeName": "user_id", "AttributeType": "S"}],
95
+ BillingMode="PAY_PER_REQUEST",
96
+ )
97
+ client.get_waiter("table_exists").wait(TableName=self.users_table.table_name)
98
+ else:
99
+ raise
100
+ # messages table
101
+ try:
102
+ client.describe_table(TableName=self.messages_table.table_name)
103
+ except ClientError as e:
104
+ code = e.response.get("Error", {}).get("Code")
105
+ if code == "ResourceNotFoundException":
106
+ client.create_table(
107
+ TableName=self.messages_table.table_name,
108
+ KeySchema=[
109
+ {"AttributeName": "user_id", "KeyType": "HASH"},
110
+ {"AttributeName": "message_id", "KeyType": "RANGE"},
111
+ ],
112
+ AttributeDefinitions=[
113
+ {"AttributeName": "user_id", "AttributeType": "S"},
114
+ {"AttributeName": "message_id", "AttributeType": "S"},
115
+ ],
116
+ BillingMode="PAY_PER_REQUEST",
117
+ )
118
+ client.get_waiter("table_exists").wait(TableName=self.messages_table.table_name)
119
+ else:
120
+ raise
121
+
122
+ async def get_user(self, user_id: int) -> User | None:
123
+ """Retrieve a User by ID.
124
+
125
+ Args:
126
+ user_id: The ID of the user to retrieve.
127
+
128
+ Returns:
129
+ The User object if found, otherwise None.
130
+ """
131
+
132
+ def _sync() -> User | None:
133
+ try:
134
+ resp = self.users_table.get_item(Key={"user_id": str(user_id)})
135
+ item = resp.get("Item")
136
+ if not item or "data" not in item:
137
+ return None
138
+ return User.model_validate_json(item["data"])
139
+ except ClientError:
140
+ return None
141
+
142
+ return await asyncio.to_thread(_sync)
143
+
144
+ async def create_user(self, user_id: int) -> User:
145
+ """Create a new User record.
146
+
147
+ Args:
148
+ user_id: The ID for the new user.
149
+
150
+ Returns:
151
+ The newly created User object.
152
+ """
153
+ user = User(id=user_id)
154
+ await self.save_user(user)
155
+ return user
156
+
157
+ async def save_user(self, user: User) -> None:
158
+ """Persist the User object to DynamoDB.
159
+
160
+ Args:
161
+ user: The User object to persist.
162
+ """
163
+ await asyncio.to_thread(
164
+ self.users_table.put_item,
165
+ Item={"user_id": str(user.id), "data": user.model_dump_json()},
166
+ )
167
+
168
+ async def get_or_create_user(self, user_id: int) -> User:
169
+ """Retrieve a User by ID or creates if missing; loads messages.
170
+
171
+ Args:
172
+ user_id: The ID of the user to retrieve or create.
173
+
174
+ Returns:
175
+ The retrieved or newly created User object, with messages loaded.
176
+ """
177
+ user = await self.get_user(user_id)
178
+ if not user:
179
+ user = await self.create_user(user_id)
180
+
181
+ # load messages
182
+ def _load() -> list[dict[str, Any]]:
183
+ resp = self.messages_table.query(
184
+ KeyConditionExpression="user_id = :u",
185
+ ExpressionAttributeValues={":u": str(user_id)},
186
+ )
187
+ return resp.get("Items", [])
188
+
189
+ items = await asyncio.to_thread(_load)
190
+ now_ts = int(time.time())
191
+ msgs: list[Message] = []
192
+ for it in items:
193
+ exp = it.get("expire_at")
194
+ if exp is None or exp >= now_ts:
195
+ msgs.append(
196
+ Message(
197
+ id=int(it["message_id"]),
198
+ role=it.get("role", ""),
199
+ content=it.get("content", ""),
200
+ expire_at=exp,
201
+ )
202
+ )
203
+ msgs.sort(key=lambda m: m.id)
204
+ user.messages = msgs
205
+ return user
206
+
207
+ async def add_message(self, user: User, message: Message, ttl: int | None = None) -> None:
208
+ """Add a Message record with optional TTL in seconds.
209
+
210
+ Args:
211
+ user: The user to whom the message belongs.
212
+ message: The message object to add.
213
+ ttl: Optional Time To Live for the message in seconds.
214
+ """
215
+ expire_at: int | None = None
216
+ if ttl is not None:
217
+ expire_at = int(time.time()) + ttl
218
+
219
+ item: dict[str, Any] = {
220
+ "user_id": str(user.id),
221
+ "message_id": str(message.id),
222
+ "role": message.role,
223
+ "content": message.content,
224
+ }
225
+ if expire_at is not None:
226
+ item["expire_at"] = expire_at
227
+
228
+ await asyncio.to_thread(self.messages_table.put_item, Item=item)
229
+
230
+ async def get_messages(self, user: User) -> list[dict[str, str]]:
231
+ """Retrieve non-expired messages as simple dicts.
232
+
233
+ Args:
234
+ user: The user whose messages are to be retrieved.
235
+
236
+ Returns:
237
+ A list of non-expired messages, where each message is a dictionary (excluding 'expire_at' and 'id').
238
+ """
239
+ user_ref = await self.get_or_create_user(user.id)
240
+ return [msg.model_dump(exclude={"expire_at", "id"}) for msg in user_ref.messages]
241
+
242
+ async def drop_messages(self, user: User) -> None:
243
+ """Delete all messages for a user.
244
+
245
+ Args:
246
+ user: The user whose messages are to be deleted.
247
+ """
248
+
249
+ def _sync() -> None:
250
+ resp = self.messages_table.query(
251
+ KeyConditionExpression="user_id = :u",
252
+ ExpressionAttributeValues={":u": str(user.id)},
253
+ )
254
+ for it in resp.get("Items", []):
255
+ self.messages_table.delete_item(Key={"user_id": it["user_id"], "message_id": it["message_id"]})
256
+
257
+ await asyncio.to_thread(_sync)
chibi/storage/local.py ADDED
@@ -0,0 +1,70 @@
1
+ import os
2
+ import pickle
3
+ import time
4
+ from typing import Optional
5
+
6
+ from loguru import logger
7
+
8
+ from chibi.models import Message, User
9
+ from chibi.storage.abstract import Database
10
+
11
+
12
+ class LocalStorage(Database):
13
+ def __init__(self, storage_path: str):
14
+ self.storage_path = storage_path
15
+ logger.info("Local storage initialized.")
16
+
17
+ def _get_storage_filename(self, user_id: int) -> str:
18
+ return os.path.join(self.storage_path, f"{user_id}.pkl")
19
+
20
+ async def save_user(self, user: User) -> None:
21
+ filename = self._get_storage_filename(user.id)
22
+ with open(filename, "wb") as f:
23
+ pickle.dump(user.model_dump(), f)
24
+
25
+ async def create_user(self, user_id: int) -> User:
26
+ user = User(id=user_id)
27
+ await self.save_user(user=user)
28
+ return user
29
+
30
+ async def get_user(self, user_id: int) -> User | None:
31
+ filename = self._get_storage_filename(user_id)
32
+ user = None
33
+ if not os.path.exists(filename):
34
+ return None
35
+ with open(filename, "rb") as f:
36
+ data = pickle.load(f)
37
+ if isinstance(data, dict):
38
+ user = User(**data)
39
+ if isinstance(data, User):
40
+ user = User(**data.dict())
41
+ if user:
42
+ current_time = time.time()
43
+ if hasattr(user, "images"):
44
+ user.images = [img for img in user.images if img.expire_at > current_time]
45
+ return user
46
+
47
+ return None
48
+
49
+ async def add_message(self, user: User, message: Message, ttl: Optional[int] = None) -> None:
50
+ user_refreshed = await self.get_or_create_user(user_id=user.id)
51
+ expire_at = time.time() + ttl if ttl else None
52
+
53
+ message.expire_at = expire_at
54
+ user_refreshed.messages.append(message)
55
+ await self.save_user(user_refreshed)
56
+
57
+ async def get_messages(self, user: User) -> list[dict[str, str]]:
58
+ user_refreshed = await self.get_or_create_user(user_id=user.id)
59
+ current_time = time.time()
60
+
61
+ msgs = [
62
+ msg.model_dump(exclude={"expire_at", "id"})
63
+ for msg in user_refreshed.messages
64
+ if msg.expire_at is None or msg.expire_at > current_time
65
+ ]
66
+ return msgs
67
+
68
+ async def drop_messages(self, user: User) -> None:
69
+ user.messages = []
70
+ await self.save_user(user=user)
chibi/storage/redis.py ADDED
@@ -0,0 +1,91 @@
1
+ from typing import Optional
2
+ from urllib.parse import urlparse
3
+
4
+ from loguru import logger
5
+ from redis.asyncio import Redis, from_url
6
+
7
+ from chibi.models import Message, User
8
+ from chibi.storage.abstract import Database
9
+
10
+
11
+ class RedisStorage(Database):
12
+ def __init__(self, url: str, password: str | None = None, db: int = 0) -> None:
13
+ self.redis: Redis
14
+ self.url = url
15
+ self.password = password
16
+ self.db = db
17
+ logger.info("Redis storage initialized.")
18
+
19
+ @classmethod
20
+ async def create(cls, url: str, password: str | None = None, db: int = 0) -> "RedisStorage":
21
+ instance = cls(url, password, db)
22
+ await instance.connect()
23
+ return instance
24
+
25
+ async def connect(self) -> None:
26
+ redis_dsn = self._combine_redis_dsn(base_dsn=self.url, password=self.password)
27
+ self.redis = await from_url(redis_dsn)
28
+
29
+ def _combine_redis_dsn(self, base_dsn: str, password: str | None) -> str:
30
+ if not password:
31
+ return base_dsn
32
+
33
+ parsed_dsn = urlparse(base_dsn)
34
+ password_in_dsn = parsed_dsn.password or None
35
+
36
+ if password_in_dsn:
37
+ logger.warning(
38
+ "Redis password specified twice: in the REDIS_PASSWORD and REDIS environment variables. "
39
+ "Trying to use the password from the Redis DSN..."
40
+ )
41
+ return base_dsn
42
+
43
+ if host := parsed_dsn.hostname:
44
+ return base_dsn.replace(host, f":{password}@{host}")
45
+
46
+ raise ValueError("Incorrect Redis DSN string provided.")
47
+
48
+ async def save_user(self, user: User) -> None:
49
+ user_key = f"user:{user.id}"
50
+ user_data = user.model_dump_json()
51
+ await self.redis.set(user_key, user_data)
52
+
53
+ async def create_user(self, user_id: int) -> User:
54
+ user = User(id=user_id)
55
+ user_key = f"user:{user_id}"
56
+ await self.redis.set(user_key, user.model_dump_json())
57
+ return user
58
+
59
+ async def get_user(self, user_id: int) -> Optional[User]:
60
+ user_key = f"user:{user_id}"
61
+ user_data = await self.redis.get(user_key)
62
+ if not user_data:
63
+ return None
64
+
65
+ user = User.model_validate_json(user_data)
66
+ message_keys_pattern = f"user:{user.id}:message:*"
67
+ message_keys = set(await self.redis.keys(message_keys_pattern))
68
+ user_messages = [Message.model_validate_json(await self.redis.get(message_key)) for message_key in message_keys]
69
+ user.messages = sorted(user_messages, key=lambda msg: msg.id)
70
+
71
+ return user
72
+
73
+ async def add_message(self, user: User, message: Message, ttl: Optional[int] = None) -> None:
74
+ message_key = f"user:{user.id}:message:{message.id}"
75
+ await self.redis.set(name=message_key, value=message.model_dump_json(exclude={"expire_at"}))
76
+ if ttl:
77
+ await self.redis.expire(name=message_key, time=ttl)
78
+
79
+ async def get_messages(self, user: User) -> list[dict[str, str]]:
80
+ user_refreshed = await self.get_or_create_user(user_id=user.id)
81
+ return [msg.model_dump(exclude={"expire_at", "id"}) for msg in user_refreshed.messages]
82
+
83
+ async def drop_messages(self, user: User) -> None:
84
+ message_keys_pattern = f"user:{user.id}:message:*"
85
+ message_keys = await self.redis.keys(message_keys_pattern)
86
+
87
+ for message_key in message_keys:
88
+ await self.redis.delete(message_key)
89
+
90
+ async def close(self) -> None:
91
+ await self.redis.aclose()
File without changes
chibi/utils/app.py ADDED
@@ -0,0 +1,249 @@
1
+ from functools import wraps
2
+ from pathlib import Path
3
+ from typing import Any, Callable
4
+
5
+ import httpx
6
+ from loguru import logger
7
+ from telegram import Update
8
+ from telegram.ext import ContextTypes
9
+
10
+ from chibi.config import application_settings, gpt_settings, telegram_settings
11
+ from chibi.constants import SETTING_DISABLED, SETTING_ENABLED, SETTING_SET, SETTING_UNSET
12
+ from chibi.exceptions import (
13
+ NoApiKeyProvidedError,
14
+ NoModelSelectedError,
15
+ NoProviderSelectedError,
16
+ NoResponseError,
17
+ NotAuthorizedError,
18
+ RecursionLimitExceeded,
19
+ ServiceRateLimitError,
20
+ ServiceResponseError,
21
+ )
22
+ from chibi.utils.telegram import chat_data, send_message, user_data
23
+
24
+
25
+ class SingletonMeta(type):
26
+ _instances: dict[type, Any] = {}
27
+
28
+ def __call__(cls, *args: Any, **kwargs: Any) -> Any:
29
+ if cls not in cls._instances:
30
+ instance = super().__call__(*args, **kwargs)
31
+ cls._instances[cls] = instance
32
+ return cls._instances[cls]
33
+
34
+
35
+ async def run_heartbeat(context: ContextTypes.DEFAULT_TYPE) -> None:
36
+ """Send a heartbeat GET request to a configured monitoring URL.
37
+
38
+ This function is designed to be called periodically by a scheduler
39
+ (python-telegram-bot's JobQueue) to signal the bot's operational status
40
+ to an external monitoring service (e.g., Healthchecks.io, Uptime Kuma, etc.).
41
+
42
+ Args:
43
+ context: The callback context provided by the JobQueue.
44
+ """
45
+ if not application_settings.heartbeat_url:
46
+ return None
47
+
48
+ transport = httpx.AsyncHTTPTransport(
49
+ retries=application_settings.heartbeat_retry_calls,
50
+ proxy=application_settings.heartbeat_proxy,
51
+ )
52
+
53
+ async with httpx.AsyncClient(transport=transport, proxy=application_settings.heartbeat_proxy) as client:
54
+ try:
55
+ await context.bot.get_me()
56
+
57
+ result = await client.get(application_settings.heartbeat_url)
58
+ except Exception as error:
59
+ logger.error(f"Uptime Checker failed with an Exception: {error}")
60
+ return
61
+ if result.is_error:
62
+ logger.error(f"Uptime Checker failed, status_code: {result.status_code}, msg: {result.text}")
63
+
64
+
65
+ def _provider_statuses() -> list[str]:
66
+ """Prepare a provider clients statuses data for logging.
67
+
68
+ Returns:
69
+ list of string containing the provider clients statuses data.
70
+ """
71
+ from chibi.services.providers import RegisteredProviders
72
+
73
+ statuses = [
74
+ "<magenta>Provider clients:</magenta>",
75
+ ]
76
+ for provider_name in RegisteredProviders.all.keys():
77
+ status = SETTING_SET if provider_name in RegisteredProviders.available else SETTING_UNSET
78
+ statuses.append(f"{provider_name} client: {status}")
79
+ return statuses
80
+
81
+
82
+ def log_application_settings() -> None:
83
+ mode = "<yellow>PUBLIC</yellow>" if gpt_settings.public_mode else "<cyan>PRIVATE</cyan>"
84
+ storage = "<red>REDIS</red>" if application_settings.redis else "<yellow>LOCAL</yellow>"
85
+ proxy = f"<cyan>{telegram_settings.proxy}</cyan>" if telegram_settings.proxy else SETTING_UNSET
86
+ users_whitelist = (
87
+ f"<cyan>{','.join(telegram_settings.users_whitelist)}</cyan>"
88
+ if telegram_settings.users_whitelist
89
+ else SETTING_UNSET
90
+ )
91
+ groups_whitelist = (
92
+ f"<cyan>{telegram_settings.groups_whitelist}</cyan>" if telegram_settings.groups_whitelist else SETTING_UNSET
93
+ )
94
+ models_whitelist = (
95
+ f"<cyan>{', '.join(gpt_settings.models_whitelist)}</cyan>" if gpt_settings.models_whitelist else SETTING_UNSET
96
+ )
97
+ images_whitelist = (
98
+ f"<cyan>{','.join(gpt_settings.image_generations_whitelist)}</cyan>"
99
+ if gpt_settings.image_generations_whitelist
100
+ else SETTING_UNSET
101
+ )
102
+
103
+ messages = [
104
+ "<magenta>General Settings:</magenta>",
105
+ f"Application is initialized in the {mode} mode using {storage} storage.",
106
+ f"Proxy is {proxy}",
107
+ "<magenta>LLM Settings:</magenta>",
108
+ f"Bot name is <cyan>{telegram_settings.bot_name}</cyan>",
109
+ # f"Initial assistant prompt: <cyan>{gpt_settings.assistant_prompt}</cyan>",
110
+ f"Messages TTL: <cyan>{gpt_settings.max_conversation_age_minutes} minutes</cyan>",
111
+ f"Maximum conversation history size: <cyan>{gpt_settings.max_history_tokens}</cyan> tokens",
112
+ f"Maximum answer size: <cyan>{gpt_settings.max_tokens}</cyan> tokens",
113
+ f"Images generation limit: <cyan>{gpt_settings.image_generations_monthly_limit}</cyan>",
114
+ f"Filesystem access: {SETTING_ENABLED if gpt_settings.filesystem_access else SETTING_DISABLED}",
115
+ "<magenta>Whitelists:</magenta>",
116
+ f"Images limit whitelist: {images_whitelist}",
117
+ f"Users whitelist: {users_whitelist}",
118
+ f"Groups whitelist: {groups_whitelist}",
119
+ f"Models whitelist: {models_whitelist}",
120
+ "<magenta>Heartbeat:</magenta>",
121
+ f"Heartbeat mechanism: {SETTING_SET if application_settings.heartbeat_url else SETTING_UNSET}",
122
+ ]
123
+ messages += _provider_statuses()
124
+
125
+ for message in messages:
126
+ logger.opt(colors=True).info(message)
127
+
128
+ if application_settings.redis_password:
129
+ logger.opt(colors=True).warning(
130
+ "`REDIS_PASSWORD` environment variable is <red>deprecated</red>. Use `REDIS` instead, i.e. "
131
+ "`redis://:password@localhost:6379/0`"
132
+ )
133
+
134
+
135
+ def handle_gpt_exceptions(func: Callable[..., Any]) -> Callable[..., Any]:
136
+ """Decorator handling openai module's exceptions.
137
+
138
+ If the specific exception occurred, handles it and sends the corresponding message.
139
+
140
+ Args:
141
+ func: async function that may rise openai exception.
142
+
143
+ Returns:
144
+ Wrapper function object.
145
+ """
146
+
147
+ @wraps(func)
148
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
149
+ update: Update = kwargs.get("update") or args[1]
150
+ context: ContextTypes.DEFAULT_TYPE = kwargs.get("context") or args[2]
151
+ error_msg_prefix = f"{user_data(update)} didn't get a GPT answer in the {chat_data(update)}"
152
+ try:
153
+ return await func(*args, **kwargs)
154
+ except NoApiKeyProvidedError as e:
155
+ logger.error(f"{error_msg_prefix}: {e}")
156
+ await send_message(
157
+ update=update,
158
+ context=context,
159
+ text="Oops! It looks like you didn't set the API key for this provider.",
160
+ )
161
+ return None
162
+
163
+ except NotAuthorizedError as e:
164
+ logger.error(f"{error_msg_prefix}: {e}")
165
+ await send_message(
166
+ update=update,
167
+ context=context,
168
+ text=(
169
+ "We encountered an authorization problem when interacting with a remote service.\n"
170
+ f"Please check your {e.provider} API key."
171
+ ),
172
+ )
173
+ return None
174
+
175
+ except ServiceResponseError as e:
176
+ logger.error(f"{error_msg_prefix}: {e}")
177
+
178
+ await send_message(
179
+ update=update,
180
+ context=context,
181
+ text=(
182
+ f"😲Lol... we got an unexpected response from the {e.provider} service! \n"
183
+ f"Please, try again a bit later."
184
+ ),
185
+ )
186
+ return None
187
+
188
+ except NoResponseError as e:
189
+ logger.error(f"{error_msg_prefix}: {e}")
190
+ return None
191
+
192
+ except ServiceRateLimitError as e:
193
+ logger.error(f"{error_msg_prefix}: {e}")
194
+ await send_message(
195
+ update=update,
196
+ context=context,
197
+ text=f"Rate Limit exceeded for {e.provider}. We should back off a bit.",
198
+ )
199
+ return None
200
+
201
+ except NoModelSelectedError as e:
202
+ logger.error(f"{error_msg_prefix}: {e}")
203
+
204
+ await send_message(
205
+ update=update,
206
+ context=context,
207
+ text="Please, select your model first.",
208
+ )
209
+ return None
210
+
211
+ except NoProviderSelectedError as e:
212
+ logger.error(f"{error_msg_prefix}: {e}")
213
+
214
+ await send_message(
215
+ update=update,
216
+ context=context,
217
+ text="Please, select your provider first.",
218
+ )
219
+ return None
220
+
221
+ except RecursionLimitExceeded as e:
222
+ logger.error(f"{error_msg_prefix}: {e}")
223
+
224
+ await send_message(
225
+ update=update,
226
+ context=context,
227
+ text=(
228
+ f"{e.provider} ({e.model}) exceeded the limit on the maximum number of consecutive tool calls "
229
+ f"({e.exceeded_limit}) and was stopped. The model has likely entered an infinite loop of tool "
230
+ f"calls. Please check the logs. If the model was functioning as intended, you should either "
231
+ f"rephrase the task or increase the value of the `MAX_CONSECUTIVE_TOOL_CALLS` setting."
232
+ ),
233
+ )
234
+
235
+ except Exception as e:
236
+ logger.exception(f"{error_msg_prefix}: {e!r}")
237
+ msg = (
238
+ "I'm sorry, but there seems to be a little hiccup with your request at the moment 😥 Would you mind "
239
+ "trying again later? Don't worry, I'll be here to assist you whenever you're ready! 😼"
240
+ )
241
+ await send_message(update=update, context=context, text=msg)
242
+ # raise
243
+
244
+ return wrapper
245
+
246
+
247
+ def get_builtin_skill_names() -> list[str]:
248
+ path = Path(application_settings.skills_dir)
249
+ return [f.name for f in path.iterdir() if f.is_file()]