webscout 8.3__py3-none-any.whl → 8.3.2__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.

Potentially problematic release.


This version of webscout might be problematic. Click here for more details.

Files changed (120) hide show
  1. webscout/AIauto.py +4 -4
  2. webscout/AIbase.py +61 -1
  3. webscout/AIutel.py +46 -53
  4. webscout/Bing_search.py +418 -0
  5. webscout/Extra/YTToolkit/ytapi/patterns.py +45 -45
  6. webscout/Extra/YTToolkit/ytapi/stream.py +1 -1
  7. webscout/Extra/YTToolkit/ytapi/video.py +10 -10
  8. webscout/Extra/autocoder/autocoder_utiles.py +1 -1
  9. webscout/Extra/gguf.py +706 -177
  10. webscout/Litlogger/formats.py +9 -0
  11. webscout/Litlogger/handlers.py +18 -0
  12. webscout/Litlogger/logger.py +43 -1
  13. webscout/Provider/AISEARCH/genspark_search.py +7 -7
  14. webscout/Provider/AISEARCH/scira_search.py +3 -2
  15. webscout/Provider/GeminiProxy.py +140 -0
  16. webscout/Provider/LambdaChat.py +7 -1
  17. webscout/Provider/MCPCore.py +78 -75
  18. webscout/Provider/OPENAI/BLACKBOXAI.py +1046 -1017
  19. webscout/Provider/OPENAI/GeminiProxy.py +328 -0
  20. webscout/Provider/OPENAI/Qwen3.py +303 -303
  21. webscout/Provider/OPENAI/README.md +5 -0
  22. webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
  23. webscout/Provider/OPENAI/TogetherAI.py +355 -0
  24. webscout/Provider/OPENAI/__init__.py +16 -1
  25. webscout/Provider/OPENAI/autoproxy.py +332 -0
  26. webscout/Provider/OPENAI/base.py +101 -14
  27. webscout/Provider/OPENAI/chatgpt.py +15 -2
  28. webscout/Provider/OPENAI/chatgptclone.py +14 -3
  29. webscout/Provider/OPENAI/deepinfra.py +339 -328
  30. webscout/Provider/OPENAI/e2b.py +295 -74
  31. webscout/Provider/OPENAI/mcpcore.py +109 -70
  32. webscout/Provider/OPENAI/opkfc.py +18 -6
  33. webscout/Provider/OPENAI/scirachat.py +59 -50
  34. webscout/Provider/OPENAI/toolbaz.py +2 -10
  35. webscout/Provider/OPENAI/writecream.py +166 -166
  36. webscout/Provider/OPENAI/x0gpt.py +367 -367
  37. webscout/Provider/OPENAI/xenai.py +514 -0
  38. webscout/Provider/OPENAI/yep.py +389 -383
  39. webscout/Provider/STT/__init__.py +3 -0
  40. webscout/Provider/STT/base.py +281 -0
  41. webscout/Provider/STT/elevenlabs.py +265 -0
  42. webscout/Provider/TTI/__init__.py +4 -1
  43. webscout/Provider/TTI/aiarta.py +399 -365
  44. webscout/Provider/TTI/base.py +74 -2
  45. webscout/Provider/TTI/bing.py +231 -0
  46. webscout/Provider/TTI/fastflux.py +63 -30
  47. webscout/Provider/TTI/gpt1image.py +149 -0
  48. webscout/Provider/TTI/imagen.py +196 -0
  49. webscout/Provider/TTI/magicstudio.py +60 -29
  50. webscout/Provider/TTI/piclumen.py +43 -32
  51. webscout/Provider/TTI/pixelmuse.py +232 -225
  52. webscout/Provider/TTI/pollinations.py +43 -32
  53. webscout/Provider/TTI/together.py +287 -0
  54. webscout/Provider/TTI/utils.py +2 -1
  55. webscout/Provider/TTS/README.md +1 -0
  56. webscout/Provider/TTS/__init__.py +2 -1
  57. webscout/Provider/TTS/freetts.py +140 -0
  58. webscout/Provider/TTS/speechma.py +45 -39
  59. webscout/Provider/TogetherAI.py +366 -0
  60. webscout/Provider/UNFINISHED/ChutesAI.py +314 -0
  61. webscout/Provider/UNFINISHED/fetch_together_models.py +95 -0
  62. webscout/Provider/XenAI.py +324 -0
  63. webscout/Provider/__init__.py +8 -0
  64. webscout/Provider/deepseek_assistant.py +378 -0
  65. webscout/Provider/scira_chat.py +3 -2
  66. webscout/Provider/toolbaz.py +0 -1
  67. webscout/auth/__init__.py +44 -0
  68. webscout/auth/api_key_manager.py +189 -0
  69. webscout/auth/auth_system.py +100 -0
  70. webscout/auth/config.py +76 -0
  71. webscout/auth/database.py +400 -0
  72. webscout/auth/exceptions.py +67 -0
  73. webscout/auth/middleware.py +248 -0
  74. webscout/auth/models.py +130 -0
  75. webscout/auth/providers.py +257 -0
  76. webscout/auth/rate_limiter.py +254 -0
  77. webscout/auth/request_models.py +127 -0
  78. webscout/auth/request_processing.py +226 -0
  79. webscout/auth/routes.py +526 -0
  80. webscout/auth/schemas.py +103 -0
  81. webscout/auth/server.py +312 -0
  82. webscout/auth/static/favicon.svg +11 -0
  83. webscout/auth/swagger_ui.py +203 -0
  84. webscout/auth/templates/components/authentication.html +237 -0
  85. webscout/auth/templates/components/base.html +103 -0
  86. webscout/auth/templates/components/endpoints.html +750 -0
  87. webscout/auth/templates/components/examples.html +491 -0
  88. webscout/auth/templates/components/footer.html +75 -0
  89. webscout/auth/templates/components/header.html +27 -0
  90. webscout/auth/templates/components/models.html +286 -0
  91. webscout/auth/templates/components/navigation.html +70 -0
  92. webscout/auth/templates/static/api.js +455 -0
  93. webscout/auth/templates/static/icons.js +168 -0
  94. webscout/auth/templates/static/main.js +784 -0
  95. webscout/auth/templates/static/particles.js +201 -0
  96. webscout/auth/templates/static/styles.css +3353 -0
  97. webscout/auth/templates/static/ui.js +374 -0
  98. webscout/auth/templates/swagger_ui.html +170 -0
  99. webscout/client.py +49 -3
  100. webscout/litagent/Readme.md +12 -3
  101. webscout/litagent/agent.py +99 -62
  102. webscout/scout/core/scout.py +104 -26
  103. webscout/scout/element.py +139 -18
  104. webscout/swiftcli/core/cli.py +14 -3
  105. webscout/swiftcli/decorators/output.py +59 -9
  106. webscout/update_checker.py +31 -49
  107. webscout/version.py +1 -1
  108. webscout/webscout_search.py +4 -12
  109. webscout/webscout_search_async.py +3 -10
  110. webscout/yep_search.py +2 -11
  111. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/METADATA +41 -11
  112. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/RECORD +116 -68
  113. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/entry_points.txt +1 -1
  114. webscout/Provider/HF_space/__init__.py +0 -0
  115. webscout/Provider/HF_space/qwen_qwen2.py +0 -206
  116. webscout/Provider/OPENAI/api.py +0 -1035
  117. webscout/Provider/TTI/artbit.py +0 -0
  118. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/WHEEL +0 -0
  119. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/licenses/LICENSE.md +0 -0
  120. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,100 @@
1
+ """
2
+ Authentication system initialization for the Webscout API.
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ from typing import Optional
8
+ from fastapi import FastAPI
9
+
10
+ from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
11
+ from .database import DatabaseManager
12
+ from .api_key_manager import APIKeyManager
13
+ from .rate_limiter import RateLimiter
14
+ from .middleware import AuthMiddleware
15
+
16
+ # Setup logger
17
+ logger = Logger(
18
+ name="webscout.api",
19
+ level=LogLevel.INFO,
20
+ handlers=[ConsoleHandler(stream=sys.stdout)],
21
+ fmt=LogFormat.DEFAULT
22
+ )
23
+
24
+ # Global authentication system instances
25
+ db_manager: Optional[DatabaseManager] = None
26
+ api_key_manager: Optional[APIKeyManager] = None
27
+ rate_limiter: Optional[RateLimiter] = None
28
+ auth_middleware: Optional[AuthMiddleware] = None
29
+
30
+
31
+ def initialize_auth_system(app: FastAPI, auth_required: bool = True, rate_limit_enabled: bool = True) -> None:
32
+ """Initialize the authentication system."""
33
+ global db_manager, api_key_manager, rate_limiter, auth_middleware
34
+
35
+ if not auth_required:
36
+ logger.info("Auth system is disabled (no-auth mode): skipping DB and API key manager initialization.")
37
+ db_manager = None
38
+ api_key_manager = None
39
+ rate_limiter = None
40
+ auth_middleware = None
41
+ return
42
+
43
+ try:
44
+ # Initialize database manager
45
+ mongo_url = os.getenv("MONGODB_URL")
46
+ data_dir = os.getenv("WEBSCOUT_DATA_DIR", "data")
47
+
48
+ db_manager = DatabaseManager(mongo_url, data_dir)
49
+
50
+ # Initialize API key manager
51
+ api_key_manager = APIKeyManager(db_manager)
52
+
53
+ # Initialize rate limiter
54
+ rate_limiter = RateLimiter(db_manager)
55
+
56
+ # Initialize auth middleware with configuration
57
+ auth_middleware = AuthMiddleware(
58
+ api_key_manager,
59
+ rate_limiter,
60
+ auth_required=auth_required,
61
+ rate_limit_enabled=rate_limit_enabled
62
+ )
63
+
64
+ # Add auth middleware to app
65
+ app.middleware("http")(auth_middleware)
66
+
67
+ # Add startup event to initialize database
68
+ async def startup_event():
69
+ if db_manager:
70
+ await db_manager.initialize()
71
+ logger.info("Authentication system initialized successfully")
72
+ logger.info(f"Auth required: {auth_required}, Rate limiting: {rate_limit_enabled}")
73
+
74
+ # Store startup function for later use
75
+ app.state.startup_event = startup_event
76
+
77
+ logger.info("Authentication system setup completed")
78
+
79
+ except Exception as e:
80
+ logger.error(f"Failed to initialize authentication system: {e}")
81
+ # Fall back to legacy auth if new system fails
82
+ logger.warning("Falling back to legacy authentication system")
83
+
84
+
85
+ def get_auth_components():
86
+ """Get the initialized authentication components."""
87
+ if db_manager is None:
88
+ return {
89
+ "db_manager": None,
90
+ "api_key_manager": None,
91
+ "rate_limiter": None,
92
+ "auth_middleware": None
93
+ }
94
+
95
+ return {
96
+ "db_manager": db_manager,
97
+ "api_key_manager": api_key_manager,
98
+ "rate_limiter": rate_limiter,
99
+ "auth_middleware": auth_middleware
100
+ }
@@ -0,0 +1,76 @@
1
+ """
2
+ Configuration management for the Webscout API server.
3
+ """
4
+
5
+ from typing import List, Dict, Optional, Any
6
+ from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
7
+ import sys
8
+
9
+ # Configuration constants
10
+ DEFAULT_PORT = 8000
11
+ DEFAULT_HOST = "0.0.0.0"
12
+
13
+ # Setup logger
14
+ logger = Logger(
15
+ name="webscout.api",
16
+ level=LogLevel.INFO,
17
+ handlers=[ConsoleHandler(stream=sys.stdout)],
18
+ fmt=LogFormat.DEFAULT
19
+ )
20
+
21
+
22
+ class ServerConfig:
23
+ """Centralized configuration management for the API server."""
24
+
25
+ def __init__(self):
26
+ self.api_key: Optional[str] = None
27
+ self.provider_map: Dict[str, Any] = {}
28
+ self.default_provider: str = "ChatGPT"
29
+ self.base_url: Optional[str] = None
30
+ self.host: str = DEFAULT_HOST
31
+ self.port: int = DEFAULT_PORT
32
+ self.debug: bool = False
33
+ self.cors_origins: List[str] = ["*"]
34
+ self.max_request_size: int = 10 * 1024 * 1024 # 10MB
35
+ self.request_timeout: int = 300 # 5 minutes
36
+ self.auth_required: bool = True # New: Enable/disable authentication
37
+ self.rate_limit_enabled: bool = True # New: Enable/disable rate limiting
38
+ self.default_rate_limit: int = 60 # Default rate limit for no-auth mode
39
+
40
+ def update(self, **kwargs) -> None:
41
+ """Update configuration with provided values."""
42
+ for key, value in kwargs.items():
43
+ if hasattr(self, key) and value is not None:
44
+ setattr(self, key, value)
45
+ logger.info(f"Config updated: {key} = {value}")
46
+
47
+ def validate(self) -> None:
48
+ """Validate configuration settings."""
49
+ if self.port < 1 or self.port > 65535:
50
+ raise ValueError(f"Invalid port number: {self.port}")
51
+
52
+ if self.default_provider not in self.provider_map and self.provider_map:
53
+ available_providers = list(set(v.__name__ for v in self.provider_map.values()))
54
+ logger.warning(f"Default provider '{self.default_provider}' not found. Available: {available_providers}")
55
+
56
+
57
+ class AppConfig:
58
+ """Legacy configuration class for backward compatibility."""
59
+ api_key: Optional[str] = None
60
+ provider_map = {}
61
+ tti_provider_map = {} # Add TTI provider map
62
+ default_provider = "ChatGPT"
63
+ default_tti_provider = "PollinationsAI" # Add default TTI provider
64
+ base_url: Optional[str] = None
65
+ auth_required: bool = True # New: Enable/disable authentication
66
+ rate_limit_enabled: bool = True # New: Enable/disable rate limiting
67
+ default_rate_limit: int = 60 # Default rate limit for no-auth mode
68
+
69
+ @classmethod
70
+ def set_config(cls, **data):
71
+ """Set configuration values."""
72
+ for key, value in data.items():
73
+ setattr(cls, key, value)
74
+ # Sync with new config system
75
+ from .server import config
76
+ config.update(**data)
@@ -0,0 +1,400 @@
1
+ # webscout/auth/database.py
2
+
3
+ import json
4
+ import os
5
+ import asyncio
6
+ from datetime import datetime, timezone
7
+ from typing import Optional, List, Dict, Any, Union
8
+ from pathlib import Path
9
+ import threading
10
+ import logging
11
+
12
+ try:
13
+ import motor.motor_asyncio
14
+ HAS_MOTOR = True
15
+ except ImportError:
16
+ HAS_MOTOR = False
17
+
18
+ from .models import User, APIKey, RateLimitEntry
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class JSONDatabase:
24
+ """JSON file-based database fallback."""
25
+
26
+ def __init__(self, data_dir: str = "data"):
27
+ self.data_dir = Path(data_dir)
28
+ self.data_dir.mkdir(exist_ok=True)
29
+
30
+ self.users_file = self.data_dir / "users.json"
31
+ self.api_keys_file = self.data_dir / "api_keys.json"
32
+ self.rate_limits_file = self.data_dir / "rate_limits.json"
33
+
34
+ self._lock = threading.RLock()
35
+
36
+ # Initialize files if they don't exist
37
+ for file_path in [self.users_file, self.api_keys_file, self.rate_limits_file]:
38
+ if not file_path.exists():
39
+ self._write_json(file_path, [])
40
+
41
+ def _read_json(self, file_path: Path) -> List[Dict[str, Any]]:
42
+ """Read JSON file safely."""
43
+ try:
44
+ with open(file_path, 'r', encoding='utf-8') as f:
45
+ return json.load(f)
46
+ except (FileNotFoundError, json.JSONDecodeError):
47
+ return []
48
+
49
+ def _write_json(self, file_path: Path, data: List[Dict[str, Any]]) -> None:
50
+ """Write JSON file safely."""
51
+ with self._lock:
52
+ # Write to temporary file first, then rename for atomicity
53
+ temp_file = file_path.with_suffix('.tmp')
54
+ try:
55
+ with open(temp_file, 'w', encoding='utf-8') as f:
56
+ json.dump(data, f, indent=2, ensure_ascii=False)
57
+ temp_file.replace(file_path)
58
+ except Exception as e:
59
+ if temp_file.exists():
60
+ temp_file.unlink()
61
+ raise e
62
+
63
+ async def create_user(self, user: User) -> User:
64
+ """Create a new user."""
65
+ users = self._read_json(self.users_file)
66
+
67
+ # Check if user already exists
68
+ for existing_user in users:
69
+ if existing_user.get("username") == user.username:
70
+ raise ValueError(f"User with username '{user.username}' already exists")
71
+
72
+ users.append(user.to_dict())
73
+ self._write_json(self.users_file, users)
74
+ return user
75
+
76
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
77
+ """Get user by ID."""
78
+ users = self._read_json(self.users_file)
79
+ for user_data in users:
80
+ if user_data.get("id") == user_id:
81
+ return User.from_dict(user_data)
82
+ return None
83
+
84
+ async def get_user_by_username(self, username: str) -> Optional[User]:
85
+ """Get user by username."""
86
+ users = self._read_json(self.users_file)
87
+ for user_data in users:
88
+ if user_data.get("username") == username:
89
+ return User.from_dict(user_data)
90
+ return None
91
+
92
+ async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
93
+ """Get user by Telegram ID."""
94
+ users = self._read_json(self.users_file)
95
+ for user_data in users:
96
+ if user_data.get("telegram_id") == telegram_id:
97
+ return User.from_dict(user_data)
98
+ return None
99
+
100
+ async def create_api_key(self, api_key: APIKey) -> APIKey:
101
+ """Create a new API key."""
102
+ api_keys = self._read_json(self.api_keys_file)
103
+
104
+ # Check if key already exists
105
+ for existing_key in api_keys:
106
+ if existing_key.get("key") == api_key.key:
107
+ raise ValueError("API key already exists")
108
+
109
+ api_keys.append(api_key.to_dict())
110
+ self._write_json(self.api_keys_file, api_keys)
111
+ return api_key
112
+
113
+ async def get_api_key(self, key: str) -> Optional[APIKey]:
114
+ """Get API key by key value."""
115
+ api_keys = self._read_json(self.api_keys_file)
116
+ for key_data in api_keys:
117
+ if key_data.get("key") == key:
118
+ return APIKey.from_dict(key_data)
119
+ return None
120
+
121
+ async def update_api_key(self, api_key: APIKey) -> APIKey:
122
+ """Update an existing API key."""
123
+ api_keys = self._read_json(self.api_keys_file)
124
+
125
+ for i, key_data in enumerate(api_keys):
126
+ if key_data.get("id") == api_key.id:
127
+ api_keys[i] = api_key.to_dict()
128
+ self._write_json(self.api_keys_file, api_keys)
129
+ return api_key
130
+
131
+ raise ValueError(f"API key with ID '{api_key.id}' not found")
132
+
133
+ async def get_api_keys_by_user(self, user_id: str) -> List[APIKey]:
134
+ """Get all API keys for a user."""
135
+ api_keys = self._read_json(self.api_keys_file)
136
+ user_keys = []
137
+
138
+ for key_data in api_keys:
139
+ if key_data.get("user_id") == user_id:
140
+ user_keys.append(APIKey.from_dict(key_data))
141
+
142
+ return user_keys
143
+
144
+ async def get_rate_limit_entry(self, api_key_id: str) -> Optional[RateLimitEntry]:
145
+ """Get rate limit entry for API key."""
146
+ rate_limits = self._read_json(self.rate_limits_file)
147
+
148
+ for entry_data in rate_limits:
149
+ if entry_data.get("api_key_id") == api_key_id:
150
+ return RateLimitEntry.from_dict(entry_data)
151
+
152
+ return None
153
+
154
+ async def update_rate_limit_entry(self, entry: RateLimitEntry) -> RateLimitEntry:
155
+ """Update rate limit entry."""
156
+ rate_limits = self._read_json(self.rate_limits_file)
157
+
158
+ for i, entry_data in enumerate(rate_limits):
159
+ if entry_data.get("api_key_id") == entry.api_key_id:
160
+ rate_limits[i] = entry.to_dict()
161
+ self._write_json(self.rate_limits_file, rate_limits)
162
+ return entry
163
+
164
+ # Create new entry if not found
165
+ rate_limits.append(entry.to_dict())
166
+ self._write_json(self.rate_limits_file, rate_limits)
167
+ return entry
168
+
169
+ async def get_all_rate_limit_entries(self) -> list:
170
+ """Return all rate limit entries (for maintenance/cleanup)."""
171
+ # Only for JSONDatabase
172
+ entries = self._read_json(self.rate_limits_file)
173
+ return [RateLimitEntry.from_dict(e) for e in entries]
174
+
175
+
176
+ class MongoDatabase:
177
+ """MongoDB database implementation."""
178
+
179
+ def __init__(self, connection_string: str = "mongodb://localhost:27017", database_name: str = "webscout"):
180
+ self.connection_string = connection_string
181
+ self.database_name = database_name
182
+ self.client = None
183
+ self.db = None
184
+ self._connected = False
185
+
186
+ async def connect(self) -> bool:
187
+ """Connect to MongoDB."""
188
+ if not HAS_MOTOR:
189
+ logger.warning("motor package not available, cannot connect to MongoDB")
190
+ return False
191
+
192
+ try:
193
+ self.client = motor.motor_asyncio.AsyncIOMotorClient(self.connection_string)
194
+ self.db = self.client[self.database_name]
195
+
196
+ # Test connection
197
+ await self.client.admin.command('ping')
198
+ self._connected = True
199
+ logger.info("Connected to MongoDB successfully")
200
+ return True
201
+ except Exception as e:
202
+ logger.warning(f"Failed to connect to MongoDB: {e}")
203
+ self._connected = False
204
+ return False
205
+
206
+ async def create_user(self, user: User) -> User:
207
+ """Create a new user."""
208
+ if not self._connected:
209
+ raise RuntimeError("Database not connected")
210
+
211
+ # Check if user exists
212
+ existing = await self.db.users.find_one({"username": user.username})
213
+ if existing:
214
+ raise ValueError(f"User with username '{user.username}' already exists")
215
+
216
+ await self.db.users.insert_one(user.to_dict())
217
+ return user
218
+
219
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
220
+ """Get user by ID."""
221
+ if not self._connected:
222
+ raise RuntimeError("Database not connected")
223
+
224
+ user_data = await self.db.users.find_one({"id": user_id})
225
+ return User.from_dict(user_data) if user_data else None
226
+
227
+ async def get_user_by_username(self, username: str) -> Optional[User]:
228
+ """Get user by username."""
229
+ if not self._connected:
230
+ raise RuntimeError("Database not connected")
231
+
232
+ user_data = await self.db.users.find_one({"username": username})
233
+ return User.from_dict(user_data) if user_data else None
234
+
235
+ async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
236
+ """Get user by Telegram ID."""
237
+ if not self._connected:
238
+ raise RuntimeError("Database not connected")
239
+
240
+ user_data = await self.db.users.find_one({"telegram_id": telegram_id})
241
+ return User.from_dict(user_data) if user_data else None
242
+
243
+ async def create_api_key(self, api_key: APIKey) -> APIKey:
244
+ """Create a new API key."""
245
+ if not self._connected:
246
+ raise RuntimeError("Database not connected")
247
+
248
+ # Check if key exists
249
+ existing = await self.db.api_keys.find_one({"key": api_key.key})
250
+ if existing:
251
+ raise ValueError("API key already exists")
252
+
253
+ await self.db.api_keys.insert_one(api_key.to_dict())
254
+ return api_key
255
+
256
+ async def get_api_key(self, key: str) -> Optional[APIKey]:
257
+ """Get API key by key value."""
258
+ if not self._connected:
259
+ raise RuntimeError("Database not connected")
260
+
261
+ key_data = await self.db.api_keys.find_one({"key": key})
262
+ return APIKey.from_dict(key_data) if key_data else None
263
+
264
+ async def update_api_key(self, api_key: APIKey) -> APIKey:
265
+ """Update an existing API key."""
266
+ if not self._connected:
267
+ raise RuntimeError("Database not connected")
268
+
269
+ result = await self.db.api_keys.update_one(
270
+ {"id": api_key.id},
271
+ {"$set": api_key.to_dict()}
272
+ )
273
+
274
+ if result.matched_count == 0:
275
+ raise ValueError(f"API key with ID '{api_key.id}' not found")
276
+
277
+ return api_key
278
+
279
+ async def get_api_keys_by_user(self, user_id: str) -> List[APIKey]:
280
+ """Get all API keys for a user."""
281
+ if not self._connected:
282
+ raise RuntimeError("Database not connected")
283
+
284
+ cursor = self.db.api_keys.find({"user_id": user_id})
285
+ keys = []
286
+ async for key_data in cursor:
287
+ keys.append(APIKey.from_dict(key_data))
288
+ return keys
289
+
290
+ async def get_rate_limit_entry(self, api_key_id: str) -> Optional[RateLimitEntry]:
291
+ """Get rate limit entry for API key."""
292
+ if not self._connected:
293
+ raise RuntimeError("Database not connected")
294
+
295
+ entry_data = await self.db.rate_limits.find_one({"api_key_id": api_key_id})
296
+ return RateLimitEntry.from_dict(entry_data) if entry_data else None
297
+
298
+ async def update_rate_limit_entry(self, entry: RateLimitEntry) -> RateLimitEntry:
299
+ """Update rate limit entry."""
300
+ if not self._connected:
301
+ raise RuntimeError("Database not connected")
302
+
303
+ await self.db.rate_limits.update_one(
304
+ {"api_key_id": entry.api_key_id},
305
+ {"$set": entry.to_dict()},
306
+ upsert=True
307
+ )
308
+ return entry
309
+
310
+ async def get_all_rate_limit_entries(self) -> list:
311
+ """Return all rate limit entries (for maintenance/cleanup) from MongoDB."""
312
+ if not self._connected:
313
+ raise RuntimeError("Database not connected")
314
+ entries = []
315
+ cursor = self.db.rate_limits.find({})
316
+ async for entry_data in cursor:
317
+ entries.append(RateLimitEntry.from_dict(entry_data))
318
+ return entries
319
+
320
+
321
+ class DatabaseManager:
322
+ """Database manager that handles MongoDB with JSON fallback."""
323
+
324
+ def __init__(self, mongo_connection_string: Optional[str] = None, data_dir: str = "data"):
325
+ self.mongo_connection_string = mongo_connection_string or os.getenv("MONGODB_URL")
326
+ self.data_dir = data_dir
327
+
328
+ self.mongo_db = None
329
+ self.json_db = JSONDatabase(data_dir)
330
+ self.use_mongo = False
331
+
332
+ logger.info(f"Database manager initialized with data_dir: {data_dir}")
333
+
334
+ async def initialize(self) -> None:
335
+ """Initialize database connection."""
336
+ if self.mongo_connection_string:
337
+ try:
338
+ self.mongo_db = MongoDatabase(self.mongo_connection_string)
339
+ self.use_mongo = await self.mongo_db.connect()
340
+ if self.use_mongo:
341
+ logger.info("Using MongoDB as primary database")
342
+ else:
343
+ logger.info("MongoDB connection failed, falling back to JSON database")
344
+ except Exception as e:
345
+ logger.warning(f"MongoDB initialization failed: {e}, using JSON database")
346
+ self.use_mongo = False
347
+ else:
348
+ logger.info("No MongoDB connection string provided, using JSON database")
349
+
350
+ @property
351
+ def db(self) -> Union[MongoDatabase, JSONDatabase]:
352
+ """Get the active database instance."""
353
+ return self.mongo_db if self.use_mongo else self.json_db
354
+
355
+ async def create_user(self, user: User) -> User:
356
+ """Create a new user."""
357
+ return await self.db.create_user(user)
358
+
359
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
360
+ """Get user by ID."""
361
+ return await self.db.get_user_by_id(user_id)
362
+
363
+ async def get_user_by_username(self, username: str) -> Optional[User]:
364
+ """Get user by username."""
365
+ return await self.db.get_user_by_username(username)
366
+
367
+ async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
368
+ """Get user by Telegram ID."""
369
+ return await self.db.get_user_by_telegram_id(telegram_id)
370
+
371
+ async def create_api_key(self, api_key: APIKey) -> APIKey:
372
+ """Create a new API key."""
373
+ return await self.db.create_api_key(api_key)
374
+
375
+ async def get_api_key(self, key: str) -> Optional[APIKey]:
376
+ """Get API key by key value."""
377
+ return await self.db.get_api_key(key)
378
+
379
+ async def update_api_key(self, api_key: APIKey) -> APIKey:
380
+ """Update an existing API key."""
381
+ return await self.db.update_api_key(api_key)
382
+
383
+ async def get_api_keys_by_user(self, user_id: str) -> List[APIKey]:
384
+ """Get all API keys for a user."""
385
+ return await self.db.get_api_keys_by_user(user_id)
386
+
387
+ async def get_rate_limit_entry(self, api_key_id: str) -> Optional[RateLimitEntry]:
388
+ """Get rate limit entry for API key."""
389
+ return await self.db.get_rate_limit_entry(api_key_id)
390
+
391
+ async def update_rate_limit_entry(self, entry: RateLimitEntry) -> RateLimitEntry:
392
+ """Update rate limit entry."""
393
+ return await self.db.update_rate_limit_entry(entry)
394
+
395
+ def get_status(self) -> Dict[str, str]:
396
+ """Get database status."""
397
+ return {
398
+ "type": "MongoDB" if self.use_mongo else "JSON",
399
+ "status": "connected" if (self.use_mongo and self.mongo_db._connected) or (not self.use_mongo) else "disconnected"
400
+ }
@@ -0,0 +1,67 @@
1
+ """
2
+ Custom exceptions for the Webscout API.
3
+ """
4
+
5
+ import json
6
+ import re
7
+ from typing import Optional
8
+ from fastapi.responses import JSONResponse
9
+ from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
10
+ from .request_models import ErrorDetail, ErrorResponse
11
+
12
+
13
+ def clean_text(text):
14
+ """Clean text by removing null bytes and control characters except newlines and tabs."""
15
+ if not isinstance(text, str):
16
+ return text
17
+
18
+ # Remove null bytes
19
+ text = text.replace('\x00', '')
20
+
21
+ # Keep newlines, tabs, and other printable characters, remove other control chars
22
+ # This regex matches control characters except \n, \r, \t
23
+ return re.sub(r'[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
24
+
25
+
26
+ class APIError(Exception):
27
+ """Custom exception for API errors."""
28
+
29
+ def __init__(self, message: str, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR,
30
+ error_type: str = "server_error", param: Optional[str] = None,
31
+ code: Optional[str] = None):
32
+ self.message = message
33
+ self.status_code = status_code
34
+ self.error_type = error_type
35
+ self.param = param
36
+ self.code = code
37
+ super().__init__(message)
38
+
39
+ def to_response(self) -> JSONResponse:
40
+ """Convert to FastAPI JSONResponse."""
41
+ error_detail = ErrorDetail(
42
+ message=self.message,
43
+ type=self.error_type,
44
+ param=self.param,
45
+ code=self.code
46
+ )
47
+ error_response = ErrorResponse(error=error_detail)
48
+ return JSONResponse(
49
+ status_code=self.status_code,
50
+ content=error_response.model_dump(exclude_none=True)
51
+ )
52
+
53
+
54
+ def format_exception(e) -> str:
55
+ """Format exception for JSON response."""
56
+ if isinstance(e, str):
57
+ message = e
58
+ else:
59
+ message = f"{e.__class__.__name__}: {str(e)}"
60
+ return json.dumps({
61
+ "error": {
62
+ "message": message,
63
+ "type": "server_error",
64
+ "param": None,
65
+ "code": "internal_server_error"
66
+ }
67
+ })