signalwire-agents 0.1.1__py3-none-any.whl → 0.1.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.
- signalwire_agents/__init__.py +1 -1
- signalwire_agents/agent_server.py +1 -1
- signalwire_agents/core/__init__.py +29 -0
- signalwire_agents/core/agent_base.py +2541 -0
- signalwire_agents/core/function_result.py +123 -0
- signalwire_agents/core/pom_builder.py +204 -0
- signalwire_agents/core/security/__init__.py +9 -0
- signalwire_agents/core/security/session_manager.py +179 -0
- signalwire_agents/core/state/__init__.py +17 -0
- signalwire_agents/core/state/file_state_manager.py +219 -0
- signalwire_agents/core/state/state_manager.py +101 -0
- signalwire_agents/core/swaig_function.py +172 -0
- signalwire_agents/core/swml_builder.py +214 -0
- signalwire_agents/core/swml_handler.py +227 -0
- signalwire_agents/core/swml_renderer.py +368 -0
- signalwire_agents/core/swml_service.py +1057 -0
- signalwire_agents/prefabs/__init__.py +26 -0
- signalwire_agents/prefabs/concierge.py +267 -0
- signalwire_agents/prefabs/faq_bot.py +305 -0
- signalwire_agents/prefabs/info_gatherer.py +263 -0
- signalwire_agents/prefabs/receptionist.py +294 -0
- signalwire_agents/prefabs/survey.py +378 -0
- signalwire_agents/utils/__init__.py +9 -0
- signalwire_agents/utils/pom_utils.py +9 -0
- signalwire_agents/utils/schema_utils.py +357 -0
- signalwire_agents/utils/token_generators.py +9 -0
- signalwire_agents/utils/validators.py +9 -0
- {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.2.dist-info}/METADATA +1 -1
- signalwire_agents-0.1.2.dist-info/RECORD +34 -0
- signalwire_agents-0.1.1.dist-info/RECORD +0 -9
- {signalwire_agents-0.1.1.data → signalwire_agents-0.1.2.data}/data/schema.json +0 -0
- {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.2.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1057 @@
|
|
1
|
+
"""
|
2
|
+
Copyright (c) 2025 SignalWire
|
3
|
+
|
4
|
+
This file is part of the SignalWire AI Agents SDK.
|
5
|
+
|
6
|
+
Licensed under the MIT License.
|
7
|
+
See LICENSE file in the project root for full license information.
|
8
|
+
"""
|
9
|
+
|
10
|
+
"""
|
11
|
+
SWMLService - Base class for SWML document creation and serving
|
12
|
+
|
13
|
+
This class provides the foundation for creating and serving SWML documents.
|
14
|
+
It handles schema validation, document creation, and web service functionality.
|
15
|
+
"""
|
16
|
+
|
17
|
+
import os
|
18
|
+
import inspect
|
19
|
+
import json
|
20
|
+
import secrets
|
21
|
+
import base64
|
22
|
+
import logging
|
23
|
+
import sys
|
24
|
+
import types
|
25
|
+
from typing import Dict, List, Any, Optional, Union, Callable, Tuple, Type
|
26
|
+
from urllib.parse import urlparse
|
27
|
+
|
28
|
+
# Import and configure structlog
|
29
|
+
try:
|
30
|
+
import structlog
|
31
|
+
|
32
|
+
# Only configure if not already configured
|
33
|
+
if not hasattr(structlog, "_configured") or not structlog._configured:
|
34
|
+
structlog.configure(
|
35
|
+
processors=[
|
36
|
+
structlog.stdlib.filter_by_level,
|
37
|
+
structlog.stdlib.add_logger_name,
|
38
|
+
structlog.stdlib.add_log_level,
|
39
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
40
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
41
|
+
structlog.processors.StackInfoRenderer(),
|
42
|
+
structlog.processors.format_exc_info,
|
43
|
+
structlog.processors.UnicodeDecoder(),
|
44
|
+
structlog.processors.JSONRenderer()
|
45
|
+
],
|
46
|
+
context_class=dict,
|
47
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
48
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
49
|
+
cache_logger_on_first_use=True,
|
50
|
+
)
|
51
|
+
|
52
|
+
# Set up root logger with structlog
|
53
|
+
logging.basicConfig(
|
54
|
+
format="%(message)s",
|
55
|
+
stream=sys.stdout,
|
56
|
+
level=logging.INFO,
|
57
|
+
)
|
58
|
+
|
59
|
+
# Mark as configured to avoid duplicate configuration
|
60
|
+
structlog._configured = True
|
61
|
+
|
62
|
+
# Create the module logger
|
63
|
+
logger = structlog.get_logger("swml_service")
|
64
|
+
|
65
|
+
except ImportError:
|
66
|
+
# Fallback to standard logging if structlog is not available
|
67
|
+
logging.basicConfig(
|
68
|
+
level=logging.INFO,
|
69
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
70
|
+
stream=sys.stdout
|
71
|
+
)
|
72
|
+
logger = logging.getLogger("swml_service")
|
73
|
+
|
74
|
+
try:
|
75
|
+
import fastapi
|
76
|
+
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Query, Body, Request, Response
|
77
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
78
|
+
from pydantic import BaseModel
|
79
|
+
except ImportError:
|
80
|
+
raise ImportError(
|
81
|
+
"fastapi is required. Install it with: pip install fastapi"
|
82
|
+
)
|
83
|
+
|
84
|
+
from signalwire_agents.utils.schema_utils import SchemaUtils
|
85
|
+
from signalwire_agents.core.swml_handler import VerbHandlerRegistry, SWMLVerbHandler
|
86
|
+
|
87
|
+
|
88
|
+
class SWMLService:
|
89
|
+
"""
|
90
|
+
Base class for creating and serving SWML documents.
|
91
|
+
|
92
|
+
This class provides core functionality for:
|
93
|
+
- Loading and validating SWML schema
|
94
|
+
- Creating SWML documents
|
95
|
+
- Setting up web endpoints for serving SWML
|
96
|
+
- Managing authentication
|
97
|
+
- Registering SWML functions
|
98
|
+
|
99
|
+
It serves as the foundation for more specialized services like AgentBase.
|
100
|
+
"""
|
101
|
+
|
102
|
+
def __init__(
|
103
|
+
self,
|
104
|
+
name: str,
|
105
|
+
route: str = "/",
|
106
|
+
host: str = "0.0.0.0",
|
107
|
+
port: int = 3000,
|
108
|
+
basic_auth: Optional[Tuple[str, str]] = None,
|
109
|
+
schema_path: Optional[str] = None
|
110
|
+
):
|
111
|
+
"""
|
112
|
+
Initialize a new SWML service
|
113
|
+
|
114
|
+
Args:
|
115
|
+
name: Service name/identifier
|
116
|
+
route: HTTP route path for this service
|
117
|
+
host: Host to bind the web server to
|
118
|
+
port: Port to bind the web server to
|
119
|
+
basic_auth: Optional (username, password) tuple for basic auth
|
120
|
+
schema_path: Optional path to the schema file
|
121
|
+
"""
|
122
|
+
self.name = name
|
123
|
+
self.route = route.rstrip("/") # Ensure no trailing slash
|
124
|
+
self.host = host
|
125
|
+
self.port = port
|
126
|
+
self.ssl_enabled = False
|
127
|
+
self.domain = None
|
128
|
+
|
129
|
+
# Initialize logger for this instance
|
130
|
+
self.log = logger.bind(service=name)
|
131
|
+
self.log.info("service_initializing", route=self.route, host=host, port=port)
|
132
|
+
|
133
|
+
# Set basic auth credentials
|
134
|
+
if basic_auth is not None:
|
135
|
+
# Use provided credentials
|
136
|
+
self._basic_auth = basic_auth
|
137
|
+
else:
|
138
|
+
# Check environment variables first
|
139
|
+
env_user = os.environ.get('SWML_BASIC_AUTH_USER')
|
140
|
+
env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
|
141
|
+
|
142
|
+
if env_user and env_pass:
|
143
|
+
# Use environment variables
|
144
|
+
self._basic_auth = (env_user, env_pass)
|
145
|
+
else:
|
146
|
+
# Generate random credentials as fallback
|
147
|
+
username = f"user_{secrets.token_hex(4)}"
|
148
|
+
password = secrets.token_urlsafe(16)
|
149
|
+
self._basic_auth = (username, password)
|
150
|
+
|
151
|
+
# Find the schema file if not provided
|
152
|
+
if schema_path is None:
|
153
|
+
schema_path = self._find_schema_path()
|
154
|
+
if schema_path:
|
155
|
+
self.log.debug("schema_found", path=schema_path)
|
156
|
+
else:
|
157
|
+
self.log.warning("schema_not_found")
|
158
|
+
|
159
|
+
# Initialize schema utils
|
160
|
+
self.schema_utils = SchemaUtils(schema_path)
|
161
|
+
|
162
|
+
# Initialize verb handler registry
|
163
|
+
self.verb_registry = VerbHandlerRegistry()
|
164
|
+
|
165
|
+
# Server state
|
166
|
+
self._app = None
|
167
|
+
self._router = None
|
168
|
+
self._running = False
|
169
|
+
|
170
|
+
# Initialize SWML document state
|
171
|
+
self._current_document = self._create_empty_document()
|
172
|
+
|
173
|
+
# Dictionary to cache dynamically created methods (instance level cache)
|
174
|
+
self._verb_methods_cache = {}
|
175
|
+
|
176
|
+
# Create auto-vivified methods for all verbs
|
177
|
+
self._create_verb_methods()
|
178
|
+
|
179
|
+
# Initialize routing callbacks dictionary (path -> callback)
|
180
|
+
self._routing_callbacks = {}
|
181
|
+
|
182
|
+
def _create_verb_methods(self) -> None:
|
183
|
+
"""
|
184
|
+
Create auto-vivified methods for all verbs at initialization time
|
185
|
+
"""
|
186
|
+
print("Creating auto-vivified methods for all verbs")
|
187
|
+
|
188
|
+
# Get all verb names from the schema
|
189
|
+
verb_names = self.schema_utils.get_all_verb_names()
|
190
|
+
print(f"Found {len(verb_names)} verbs in schema")
|
191
|
+
|
192
|
+
# Create a method for each verb
|
193
|
+
for verb_name in verb_names:
|
194
|
+
# Skip verbs that already have specific methods
|
195
|
+
if hasattr(self, verb_name):
|
196
|
+
print(f"Skipping {verb_name} - already has a method")
|
197
|
+
continue
|
198
|
+
|
199
|
+
# Handle sleep verb specially since it takes an integer directly
|
200
|
+
if verb_name == "sleep":
|
201
|
+
def sleep_method(self_instance, duration=None, **kwargs):
|
202
|
+
"""
|
203
|
+
Add the sleep verb to the document.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
duration: The amount of time to sleep in milliseconds
|
207
|
+
"""
|
208
|
+
print(f"Executing auto-vivified method for 'sleep'")
|
209
|
+
# Sleep verb takes a direct integer parameter in SWML
|
210
|
+
if duration is not None:
|
211
|
+
return self_instance.add_verb("sleep", duration)
|
212
|
+
elif kwargs:
|
213
|
+
# Try to get the value from kwargs
|
214
|
+
return self_instance.add_verb("sleep", next(iter(kwargs.values())))
|
215
|
+
else:
|
216
|
+
raise TypeError("sleep() missing required argument: 'duration'")
|
217
|
+
|
218
|
+
# Set it as an attribute of self
|
219
|
+
setattr(self, verb_name, types.MethodType(sleep_method, self))
|
220
|
+
|
221
|
+
# Also cache it for later
|
222
|
+
self._verb_methods_cache[verb_name] = sleep_method
|
223
|
+
|
224
|
+
print(f"Created special method for {verb_name}")
|
225
|
+
continue
|
226
|
+
|
227
|
+
# Generate the method implementation for normal verbs
|
228
|
+
def make_verb_method(name):
|
229
|
+
def verb_method(self_instance, **kwargs):
|
230
|
+
"""
|
231
|
+
Dynamically generated method for SWML verb
|
232
|
+
"""
|
233
|
+
print(f"Executing auto-vivified method for '{name}'")
|
234
|
+
config = {}
|
235
|
+
for key, value in kwargs.items():
|
236
|
+
if value is not None:
|
237
|
+
config[key] = value
|
238
|
+
return self_instance.add_verb(name, config)
|
239
|
+
|
240
|
+
# Add docstring to the method
|
241
|
+
verb_properties = self.schema_utils.get_verb_properties(name)
|
242
|
+
if "description" in verb_properties:
|
243
|
+
verb_method.__doc__ = f"Add the {name} verb to the document.\n\n{verb_properties['description']}"
|
244
|
+
else:
|
245
|
+
verb_method.__doc__ = f"Add the {name} verb to the document."
|
246
|
+
|
247
|
+
return verb_method
|
248
|
+
|
249
|
+
# Create the method with closure over the verb name
|
250
|
+
method = make_verb_method(verb_name)
|
251
|
+
|
252
|
+
# Set it as an attribute of self
|
253
|
+
setattr(self, verb_name, types.MethodType(method, self))
|
254
|
+
|
255
|
+
# Also cache it for later
|
256
|
+
self._verb_methods_cache[verb_name] = method
|
257
|
+
|
258
|
+
print(f"Created method for {verb_name}")
|
259
|
+
|
260
|
+
def __getattr__(self, name: str) -> Any:
|
261
|
+
"""
|
262
|
+
Dynamically generate and return SWML verb methods when accessed
|
263
|
+
|
264
|
+
This method is called when an attribute lookup fails through the normal
|
265
|
+
mechanisms. It checks if the attribute name corresponds to a SWML verb
|
266
|
+
defined in the schema, and if so, dynamically creates a method for that verb.
|
267
|
+
|
268
|
+
Args:
|
269
|
+
name: The name of the attribute being accessed
|
270
|
+
|
271
|
+
Returns:
|
272
|
+
The dynamically created verb method if name is a valid SWML verb,
|
273
|
+
otherwise raises AttributeError
|
274
|
+
|
275
|
+
Raises:
|
276
|
+
AttributeError: If name is not a valid SWML verb
|
277
|
+
"""
|
278
|
+
print(f"DEBUG: __getattr__ called for '{name}'")
|
279
|
+
|
280
|
+
# Simple version to match our test script
|
281
|
+
# First check if this is a valid SWML verb
|
282
|
+
verb_names = self.schema_utils.get_all_verb_names()
|
283
|
+
|
284
|
+
if name in verb_names:
|
285
|
+
print(f"DEBUG: '{name}' is a valid verb")
|
286
|
+
|
287
|
+
# Check if we already have this method in the cache
|
288
|
+
if not hasattr(self, '_verb_methods_cache'):
|
289
|
+
self._verb_methods_cache = {}
|
290
|
+
|
291
|
+
if name in self._verb_methods_cache:
|
292
|
+
print(f"DEBUG: Using cached method for '{name}'")
|
293
|
+
return types.MethodType(self._verb_methods_cache[name], self)
|
294
|
+
|
295
|
+
# Handle sleep verb specially since it takes an integer directly
|
296
|
+
if name == "sleep":
|
297
|
+
def sleep_method(self_instance, duration=None, **kwargs):
|
298
|
+
"""
|
299
|
+
Add the sleep verb to the document.
|
300
|
+
|
301
|
+
Args:
|
302
|
+
duration: The amount of time to sleep in milliseconds
|
303
|
+
"""
|
304
|
+
print(f"DEBUG: Executing auto-vivified method for 'sleep'")
|
305
|
+
# Sleep verb takes a direct integer parameter in SWML
|
306
|
+
if duration is not None:
|
307
|
+
return self_instance.add_verb("sleep", duration)
|
308
|
+
elif kwargs:
|
309
|
+
# Try to get the value from kwargs
|
310
|
+
return self_instance.add_verb("sleep", next(iter(kwargs.values())))
|
311
|
+
else:
|
312
|
+
raise TypeError("sleep() missing required argument: 'duration'")
|
313
|
+
|
314
|
+
# Cache the method for future use
|
315
|
+
print(f"DEBUG: Caching special method for '{name}'")
|
316
|
+
self._verb_methods_cache[name] = sleep_method
|
317
|
+
|
318
|
+
# Return the bound method
|
319
|
+
return types.MethodType(sleep_method, self)
|
320
|
+
|
321
|
+
# Generate the method implementation for normal verbs
|
322
|
+
def verb_method(self_instance, **kwargs):
|
323
|
+
"""
|
324
|
+
Dynamically generated method for SWML verb
|
325
|
+
"""
|
326
|
+
print(f"DEBUG: Executing auto-vivified method for '{name}'")
|
327
|
+
config = {}
|
328
|
+
for key, value in kwargs.items():
|
329
|
+
if value is not None:
|
330
|
+
config[key] = value
|
331
|
+
return self_instance.add_verb(name, config)
|
332
|
+
|
333
|
+
# Add docstring to the method
|
334
|
+
verb_properties = self.schema_utils.get_verb_properties(name)
|
335
|
+
if "description" in verb_properties:
|
336
|
+
verb_method.__doc__ = f"Add the {name} verb to the document.\n\n{verb_properties['description']}"
|
337
|
+
else:
|
338
|
+
verb_method.__doc__ = f"Add the {name} verb to the document."
|
339
|
+
|
340
|
+
# Cache the method for future use
|
341
|
+
print(f"DEBUG: Caching method for '{name}'")
|
342
|
+
self._verb_methods_cache[name] = verb_method
|
343
|
+
|
344
|
+
# Return the bound method
|
345
|
+
return types.MethodType(verb_method, self)
|
346
|
+
|
347
|
+
# Not a valid verb
|
348
|
+
msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
|
349
|
+
print(f"DEBUG: {msg}")
|
350
|
+
raise AttributeError(msg)
|
351
|
+
|
352
|
+
def _find_schema_path(self) -> Optional[str]:
|
353
|
+
"""
|
354
|
+
Find the schema.json file location
|
355
|
+
|
356
|
+
Returns:
|
357
|
+
Path to schema.json if found, None otherwise
|
358
|
+
"""
|
359
|
+
# Try package resources first (most reliable after pip install)
|
360
|
+
try:
|
361
|
+
import importlib.resources
|
362
|
+
try:
|
363
|
+
# Python 3.9+
|
364
|
+
try:
|
365
|
+
# Python 3.13+
|
366
|
+
path = importlib.resources.files("signalwire_agents").joinpath("schema.json")
|
367
|
+
return str(path)
|
368
|
+
except Exception:
|
369
|
+
# Python 3.9-3.12
|
370
|
+
with importlib.resources.files("signalwire_agents").joinpath("schema.json") as path:
|
371
|
+
return str(path)
|
372
|
+
except AttributeError:
|
373
|
+
# Python 3.7-3.8
|
374
|
+
with importlib.resources.path("signalwire_agents", "schema.json") as path:
|
375
|
+
return str(path)
|
376
|
+
except (ImportError, ModuleNotFoundError):
|
377
|
+
pass
|
378
|
+
|
379
|
+
# Fall back to pkg_resources for older Python or alternative lookup
|
380
|
+
try:
|
381
|
+
import pkg_resources
|
382
|
+
return pkg_resources.resource_filename("signalwire_agents", "schema.json")
|
383
|
+
except (ImportError, ModuleNotFoundError, pkg_resources.DistributionNotFound):
|
384
|
+
pass
|
385
|
+
|
386
|
+
# Fall back to manual search in various locations
|
387
|
+
import sys
|
388
|
+
|
389
|
+
# Get package directory
|
390
|
+
package_dir = os.path.dirname(os.path.dirname(__file__))
|
391
|
+
|
392
|
+
# Potential locations for schema.json
|
393
|
+
potential_paths = [
|
394
|
+
os.path.join(os.getcwd(), "schema.json"), # Current working directory
|
395
|
+
os.path.join(package_dir, "schema.json"), # Package directory
|
396
|
+
os.path.join(os.path.dirname(package_dir), "schema.json"), # Parent of package directory
|
397
|
+
os.path.join(sys.prefix, "schema.json"), # Python installation directory
|
398
|
+
os.path.join(package_dir, "data", "schema.json"), # Data subdirectory
|
399
|
+
os.path.join(os.path.dirname(package_dir), "data", "schema.json"), # Parent's data subdirectory
|
400
|
+
]
|
401
|
+
|
402
|
+
# Try to find the schema file
|
403
|
+
for path in potential_paths:
|
404
|
+
if os.path.exists(path):
|
405
|
+
return path
|
406
|
+
|
407
|
+
return None
|
408
|
+
|
409
|
+
def _create_empty_document(self) -> Dict[str, Any]:
|
410
|
+
"""
|
411
|
+
Create an empty SWML document
|
412
|
+
|
413
|
+
Returns:
|
414
|
+
Empty SWML document structure
|
415
|
+
"""
|
416
|
+
return {
|
417
|
+
"version": "1.0.0",
|
418
|
+
"sections": {
|
419
|
+
"main": []
|
420
|
+
}
|
421
|
+
}
|
422
|
+
|
423
|
+
def reset_document(self) -> None:
|
424
|
+
"""
|
425
|
+
Reset the current document to an empty state
|
426
|
+
"""
|
427
|
+
self._current_document = self._create_empty_document()
|
428
|
+
|
429
|
+
def add_verb(self, verb_name: str, config: Union[Dict[str, Any], int]) -> bool:
|
430
|
+
"""
|
431
|
+
Add a verb to the main section of the current document
|
432
|
+
|
433
|
+
Args:
|
434
|
+
verb_name: The name of the verb to add
|
435
|
+
config: Configuration for the verb or direct value for certain verbs (e.g., sleep)
|
436
|
+
|
437
|
+
Returns:
|
438
|
+
True if the verb was added successfully, False otherwise
|
439
|
+
"""
|
440
|
+
# Special case for verbs that take direct values (like sleep)
|
441
|
+
if verb_name == "sleep" and isinstance(config, int):
|
442
|
+
# Sleep verb takes a direct integer value
|
443
|
+
verb_obj = {verb_name: config}
|
444
|
+
self._current_document["sections"]["main"].append(verb_obj)
|
445
|
+
return True
|
446
|
+
|
447
|
+
# Ensure config is a dictionary for other verbs
|
448
|
+
if not isinstance(config, dict):
|
449
|
+
self.log.warning(f"invalid_config_type", verb=verb_name,
|
450
|
+
expected="dict", got=type(config).__name__)
|
451
|
+
return False
|
452
|
+
|
453
|
+
# Check if we have a specialized handler for this verb
|
454
|
+
if self.verb_registry.has_handler(verb_name):
|
455
|
+
handler = self.verb_registry.get_handler(verb_name)
|
456
|
+
is_valid, errors = handler.validate_config(config)
|
457
|
+
else:
|
458
|
+
# Use schema-based validation for standard verbs
|
459
|
+
is_valid, errors = self.schema_utils.validate_verb(verb_name, config)
|
460
|
+
|
461
|
+
if not is_valid:
|
462
|
+
# Log validation errors
|
463
|
+
self.log.warning(f"verb_validation_error", verb=verb_name, errors=errors)
|
464
|
+
return False
|
465
|
+
|
466
|
+
# Add the verb to the main section
|
467
|
+
verb_obj = {verb_name: config}
|
468
|
+
self._current_document["sections"]["main"].append(verb_obj)
|
469
|
+
return True
|
470
|
+
|
471
|
+
def add_section(self, section_name: str) -> bool:
|
472
|
+
"""
|
473
|
+
Add a new section to the document
|
474
|
+
|
475
|
+
Args:
|
476
|
+
section_name: Name of the section to add
|
477
|
+
|
478
|
+
Returns:
|
479
|
+
True if the section was added, False if it already exists
|
480
|
+
"""
|
481
|
+
if section_name in self._current_document["sections"]:
|
482
|
+
return False
|
483
|
+
|
484
|
+
self._current_document["sections"][section_name] = []
|
485
|
+
return True
|
486
|
+
|
487
|
+
def add_verb_to_section(self, section_name: str, verb_name: str, config: Union[Dict[str, Any], int]) -> bool:
|
488
|
+
"""
|
489
|
+
Add a verb to a specific section
|
490
|
+
|
491
|
+
Args:
|
492
|
+
section_name: Name of the section to add to
|
493
|
+
verb_name: The name of the verb to add
|
494
|
+
config: Configuration for the verb or direct value for certain verbs (e.g., sleep)
|
495
|
+
|
496
|
+
Returns:
|
497
|
+
True if the verb was added successfully, False otherwise
|
498
|
+
"""
|
499
|
+
# Make sure the section exists
|
500
|
+
if section_name not in self._current_document["sections"]:
|
501
|
+
self.add_section(section_name)
|
502
|
+
|
503
|
+
# Special case for verbs that take direct values (like sleep)
|
504
|
+
if verb_name == "sleep" and isinstance(config, int):
|
505
|
+
# Sleep verb takes a direct integer value
|
506
|
+
verb_obj = {verb_name: config}
|
507
|
+
self._current_document["sections"][section_name].append(verb_obj)
|
508
|
+
return True
|
509
|
+
|
510
|
+
# Ensure config is a dictionary for other verbs
|
511
|
+
if not isinstance(config, dict):
|
512
|
+
self.log.warning(f"invalid_config_type", verb=verb_name, section=section_name,
|
513
|
+
expected="dict", got=type(config).__name__)
|
514
|
+
return False
|
515
|
+
|
516
|
+
# Check if we have a specialized handler for this verb
|
517
|
+
if self.verb_registry.has_handler(verb_name):
|
518
|
+
handler = self.verb_registry.get_handler(verb_name)
|
519
|
+
is_valid, errors = handler.validate_config(config)
|
520
|
+
else:
|
521
|
+
# Use schema-based validation for standard verbs
|
522
|
+
is_valid, errors = self.schema_utils.validate_verb(verb_name, config)
|
523
|
+
|
524
|
+
if not is_valid:
|
525
|
+
# Log validation errors
|
526
|
+
self.log.warning(f"verb_validation_error", verb=verb_name, section=section_name, errors=errors)
|
527
|
+
return False
|
528
|
+
|
529
|
+
# Add the verb to the section
|
530
|
+
verb_obj = {verb_name: config}
|
531
|
+
self._current_document["sections"][section_name].append(verb_obj)
|
532
|
+
return True
|
533
|
+
|
534
|
+
def get_document(self) -> Dict[str, Any]:
|
535
|
+
"""
|
536
|
+
Get the current SWML document
|
537
|
+
|
538
|
+
Returns:
|
539
|
+
The current SWML document as a dictionary
|
540
|
+
"""
|
541
|
+
return self._current_document
|
542
|
+
|
543
|
+
def render_document(self) -> str:
|
544
|
+
"""
|
545
|
+
Render the current SWML document as a JSON string
|
546
|
+
|
547
|
+
Returns:
|
548
|
+
The current SWML document as a JSON string
|
549
|
+
"""
|
550
|
+
return json.dumps(self._current_document)
|
551
|
+
|
552
|
+
def register_verb_handler(self, handler: SWMLVerbHandler) -> None:
|
553
|
+
"""
|
554
|
+
Register a custom verb handler
|
555
|
+
|
556
|
+
Args:
|
557
|
+
handler: The verb handler to register
|
558
|
+
"""
|
559
|
+
self.verb_registry.register_handler(handler)
|
560
|
+
|
561
|
+
def as_router(self) -> APIRouter:
|
562
|
+
"""
|
563
|
+
Get a FastAPI router for this service
|
564
|
+
|
565
|
+
Returns:
|
566
|
+
FastAPI router
|
567
|
+
"""
|
568
|
+
router = APIRouter()
|
569
|
+
|
570
|
+
# Root endpoint - without trailing slash
|
571
|
+
@router.get("")
|
572
|
+
@router.post("")
|
573
|
+
async def handle_root_no_slash(request: Request, response: Response):
|
574
|
+
"""Handle GET/POST requests to the root endpoint"""
|
575
|
+
return await self._handle_request(request, response)
|
576
|
+
|
577
|
+
# Root endpoint - with trailing slash
|
578
|
+
@router.get("/")
|
579
|
+
@router.post("/")
|
580
|
+
async def handle_root_with_slash(request: Request, response: Response):
|
581
|
+
"""Handle GET/POST requests to the root endpoint with trailing slash"""
|
582
|
+
return await self._handle_request(request, response)
|
583
|
+
|
584
|
+
# Add endpoints for all registered routing callbacks
|
585
|
+
if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
|
586
|
+
for callback_path, callback_fn in self._routing_callbacks.items():
|
587
|
+
# Skip the root path as it's already handled
|
588
|
+
if callback_path == "/":
|
589
|
+
continue
|
590
|
+
|
591
|
+
# Register the endpoint without trailing slash
|
592
|
+
@router.get(callback_path)
|
593
|
+
@router.post(callback_path)
|
594
|
+
async def handle_callback_no_slash(request: Request, response: Response, cb_path=callback_path):
|
595
|
+
"""Handle GET/POST requests to a registered callback path"""
|
596
|
+
# Store the callback path in request state for _handle_request to use
|
597
|
+
request.state.callback_path = cb_path
|
598
|
+
return await self._handle_request(request, response)
|
599
|
+
|
600
|
+
# Register the endpoint with trailing slash if it doesn't already have one
|
601
|
+
if not callback_path.endswith('/'):
|
602
|
+
slash_path = f"{callback_path}/"
|
603
|
+
|
604
|
+
@router.get(slash_path)
|
605
|
+
@router.post(slash_path)
|
606
|
+
async def handle_callback_with_slash(request: Request, response: Response, cb_path=callback_path):
|
607
|
+
"""Handle GET/POST requests to a registered callback path with trailing slash"""
|
608
|
+
# Store the callback path in request state for _handle_request to use
|
609
|
+
request.state.callback_path = cb_path
|
610
|
+
return await self._handle_request(request, response)
|
611
|
+
|
612
|
+
self.log.info("callback_endpoint_registered", path=callback_path)
|
613
|
+
|
614
|
+
self._router = router
|
615
|
+
return router
|
616
|
+
|
617
|
+
def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
|
618
|
+
path: str = "/sip") -> None:
|
619
|
+
"""
|
620
|
+
Register a callback function that will be called to determine routing
|
621
|
+
based on POST data.
|
622
|
+
|
623
|
+
When a routing callback is registered, an endpoint at the specified path is automatically
|
624
|
+
created that will handle requests. This endpoint will use the callback to
|
625
|
+
determine if the request should be processed by this service or redirected.
|
626
|
+
|
627
|
+
The callback should take a request object and request body dictionary and return:
|
628
|
+
- A route string if it should be routed to a different endpoint
|
629
|
+
- None if normal processing should continue
|
630
|
+
|
631
|
+
Args:
|
632
|
+
callback_fn: The callback function to register
|
633
|
+
path: The path where this callback should be registered (default: "/sip")
|
634
|
+
"""
|
635
|
+
# Normalize the path to ensure consistent lookup
|
636
|
+
normalized_path = path.rstrip("/")
|
637
|
+
if not normalized_path.startswith("/"):
|
638
|
+
normalized_path = f"/{normalized_path}"
|
639
|
+
|
640
|
+
self.log.info("registering_routing_callback", path=normalized_path)
|
641
|
+
self._routing_callbacks[normalized_path] = callback_fn
|
642
|
+
|
643
|
+
@staticmethod
|
644
|
+
def extract_sip_username(request_body: Dict[str, Any]) -> Optional[str]:
|
645
|
+
"""
|
646
|
+
Extract SIP username from request body
|
647
|
+
|
648
|
+
This extracts the username portion of a SIP URI from the 'to' field
|
649
|
+
in the call data of a request body.
|
650
|
+
|
651
|
+
Args:
|
652
|
+
request_body: The parsed JSON body of the request
|
653
|
+
|
654
|
+
Returns:
|
655
|
+
The extracted SIP username, or None if not found
|
656
|
+
"""
|
657
|
+
try:
|
658
|
+
# Check if we have call data with a 'to' field
|
659
|
+
if "call" in request_body and "to" in request_body["call"]:
|
660
|
+
to_field = request_body["call"]["to"]
|
661
|
+
|
662
|
+
# Handle SIP URIs like "sip:username@domain"
|
663
|
+
if to_field.startswith("sip:"):
|
664
|
+
# Extract username part (between "sip:" and "@")
|
665
|
+
uri_parts = to_field[4:].split("@", 1)
|
666
|
+
if uri_parts:
|
667
|
+
return uri_parts[0]
|
668
|
+
|
669
|
+
# Handle TEL URIs like "tel:+1234567890"
|
670
|
+
elif to_field.startswith("tel:"):
|
671
|
+
# Extract phone number part
|
672
|
+
return to_field[4:]
|
673
|
+
|
674
|
+
# Otherwise, return the whole 'to' field
|
675
|
+
else:
|
676
|
+
return to_field
|
677
|
+
except (KeyError, AttributeError):
|
678
|
+
# If any exception occurs during extraction, return None
|
679
|
+
pass
|
680
|
+
|
681
|
+
return None
|
682
|
+
|
683
|
+
async def _handle_request(self, request: Request, response: Response):
|
684
|
+
"""
|
685
|
+
Internal handler for both GET and POST requests
|
686
|
+
|
687
|
+
Args:
|
688
|
+
request: FastAPI Request object
|
689
|
+
response: FastAPI Response object
|
690
|
+
|
691
|
+
Returns:
|
692
|
+
Response with SWML document or error
|
693
|
+
"""
|
694
|
+
# Check auth
|
695
|
+
if not self._check_basic_auth(request):
|
696
|
+
response.headers["WWW-Authenticate"] = "Basic"
|
697
|
+
return HTTPException(status_code=401, detail="Unauthorized")
|
698
|
+
|
699
|
+
# Get callback path from request state
|
700
|
+
callback_path = getattr(request.state, "callback_path", None)
|
701
|
+
|
702
|
+
# Process request body if it's a POST
|
703
|
+
body = {}
|
704
|
+
if request.method == "POST":
|
705
|
+
try:
|
706
|
+
raw_body = await request.body()
|
707
|
+
if raw_body:
|
708
|
+
body = await request.json()
|
709
|
+
|
710
|
+
# Check if this is a callback path and we have a callback registered for it
|
711
|
+
if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
|
712
|
+
callback_fn = self._routing_callbacks[callback_path]
|
713
|
+
self.log.debug("checking_routing",
|
714
|
+
path=callback_path,
|
715
|
+
body_keys=list(body.keys()))
|
716
|
+
|
717
|
+
# Call the callback function
|
718
|
+
try:
|
719
|
+
route = callback_fn(request, body)
|
720
|
+
|
721
|
+
if route is not None:
|
722
|
+
self.log.info("routing_request", route=route)
|
723
|
+
# We should return a redirect to the new route
|
724
|
+
# Use 307 to preserve the POST method and its body
|
725
|
+
response = Response(status_code=307)
|
726
|
+
response.headers["Location"] = route
|
727
|
+
return response
|
728
|
+
except Exception as e:
|
729
|
+
self.log.error("error_in_routing_callback", error=str(e))
|
730
|
+
except Exception as e:
|
731
|
+
self.log.error("error_parsing_body", error=str(e))
|
732
|
+
# Continue with empty body if parsing fails
|
733
|
+
pass
|
734
|
+
|
735
|
+
# Allow for customized handling in subclasses
|
736
|
+
modifications = self.on_request(body, callback_path)
|
737
|
+
|
738
|
+
# Apply any modifications if needed
|
739
|
+
if modifications and isinstance(modifications, dict):
|
740
|
+
# Get a copy of the current document
|
741
|
+
document = self.get_document()
|
742
|
+
|
743
|
+
# Apply modifications (simplified implementation)
|
744
|
+
# In a real implementation, you might want a more sophisticated merge strategy
|
745
|
+
for key, value in modifications.items():
|
746
|
+
if key in document:
|
747
|
+
document[key] = value
|
748
|
+
|
749
|
+
# Create a new document with the modifications
|
750
|
+
modified_doc = json.dumps(document)
|
751
|
+
return Response(content=modified_doc, media_type="application/json")
|
752
|
+
|
753
|
+
# Get the current SWML document
|
754
|
+
swml = self.render_document()
|
755
|
+
|
756
|
+
# Return the SWML document
|
757
|
+
return Response(content=swml, media_type="application/json")
|
758
|
+
|
759
|
+
def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
|
760
|
+
"""
|
761
|
+
Called when SWML is requested, with request data when available
|
762
|
+
|
763
|
+
Subclasses can override this to inspect or modify SWML based on the request
|
764
|
+
|
765
|
+
Args:
|
766
|
+
request_data: Optional dictionary containing the parsed POST body
|
767
|
+
callback_path: Optional callback path
|
768
|
+
|
769
|
+
Returns:
|
770
|
+
Optional dict to modify/augment the SWML document
|
771
|
+
"""
|
772
|
+
# Default implementation does nothing
|
773
|
+
return None
|
774
|
+
|
775
|
+
def serve(self, host: Optional[str] = None, port: Optional[int] = None,
|
776
|
+
ssl_cert: Optional[str] = None, ssl_key: Optional[str] = None,
|
777
|
+
ssl_enabled: Optional[bool] = None, domain: Optional[str] = None) -> None:
|
778
|
+
"""
|
779
|
+
Start a web server for this service
|
780
|
+
|
781
|
+
Args:
|
782
|
+
host: Optional host to override the default
|
783
|
+
port: Optional port to override the default
|
784
|
+
ssl_cert: Path to SSL certificate file
|
785
|
+
ssl_key: Path to SSL private key file
|
786
|
+
ssl_enabled: Whether to enable SSL/HTTPS
|
787
|
+
domain: Domain name for the SSL certificate and external URLs
|
788
|
+
"""
|
789
|
+
import uvicorn
|
790
|
+
|
791
|
+
# Determine SSL settings from parameters or environment variables
|
792
|
+
self.ssl_enabled = ssl_enabled if ssl_enabled is not None else os.environ.get('SWML_SSL_ENABLED', '').lower() in ('true', '1', 'yes')
|
793
|
+
ssl_cert_path = ssl_cert or os.environ.get('SWML_SSL_CERT_PATH', '')
|
794
|
+
ssl_key_path = ssl_key or os.environ.get('SWML_SSL_KEY_PATH', '')
|
795
|
+
self.domain = domain or os.environ.get('SWML_DOMAIN', '')
|
796
|
+
|
797
|
+
# Validate SSL configuration if enabled
|
798
|
+
if self.ssl_enabled:
|
799
|
+
if not ssl_cert_path or not os.path.exists(ssl_cert_path):
|
800
|
+
self.log.warning("ssl_cert_not_found", path=ssl_cert_path)
|
801
|
+
self.ssl_enabled = False
|
802
|
+
elif not ssl_key_path or not os.path.exists(ssl_key_path):
|
803
|
+
self.log.warning("ssl_key_not_found", path=ssl_key_path)
|
804
|
+
self.ssl_enabled = False
|
805
|
+
elif not self.domain:
|
806
|
+
self.log.warning("ssl_domain_not_specified")
|
807
|
+
# We'll continue, but URLs might not be correctly generated
|
808
|
+
|
809
|
+
if self._app is None:
|
810
|
+
app = FastAPI()
|
811
|
+
router = self.as_router()
|
812
|
+
app.include_router(router, prefix=self.route)
|
813
|
+
self._app = app
|
814
|
+
|
815
|
+
host = host or self.host
|
816
|
+
port = port or self.port
|
817
|
+
|
818
|
+
# Print the auth credentials
|
819
|
+
username, password = self._basic_auth
|
820
|
+
|
821
|
+
# Use correct protocol and host in displayed URL
|
822
|
+
protocol = "https" if self.ssl_enabled else "http"
|
823
|
+
display_host = self.domain if self.ssl_enabled and self.domain else f"{host}:{port}"
|
824
|
+
|
825
|
+
self.log.info("starting_server",
|
826
|
+
url=f"{protocol}://{display_host}{self.route}",
|
827
|
+
ssl_enabled=self.ssl_enabled,
|
828
|
+
username=username,
|
829
|
+
password_length=len(password))
|
830
|
+
|
831
|
+
print(f"Service '{self.name}' is available at:")
|
832
|
+
print(f"URL: {protocol}://{display_host}{self.route}")
|
833
|
+
print(f"Basic Auth: {username}:{password}")
|
834
|
+
|
835
|
+
# Check if SIP routing is enabled and print additional info
|
836
|
+
if self._routing_callbacks:
|
837
|
+
print(f"Callback endpoints:")
|
838
|
+
for path in self._routing_callbacks:
|
839
|
+
print(f"{protocol}://{display_host}{path}")
|
840
|
+
|
841
|
+
# Start uvicorn with or without SSL
|
842
|
+
if self.ssl_enabled and ssl_cert_path and ssl_key_path:
|
843
|
+
self.log.info("starting_with_ssl", cert=ssl_cert_path, key=ssl_key_path)
|
844
|
+
uvicorn.run(
|
845
|
+
self._app,
|
846
|
+
host=host,
|
847
|
+
port=port,
|
848
|
+
ssl_certfile=ssl_cert_path,
|
849
|
+
ssl_keyfile=ssl_key_path
|
850
|
+
)
|
851
|
+
else:
|
852
|
+
uvicorn.run(self._app, host=host, port=port)
|
853
|
+
|
854
|
+
def stop(self) -> None:
|
855
|
+
"""
|
856
|
+
Stop the web server
|
857
|
+
"""
|
858
|
+
self._running = False
|
859
|
+
|
860
|
+
def _check_basic_auth(self, request: Request) -> bool:
|
861
|
+
"""
|
862
|
+
Check if the request has valid basic auth credentials
|
863
|
+
|
864
|
+
Args:
|
865
|
+
request: FastAPI Request object
|
866
|
+
|
867
|
+
Returns:
|
868
|
+
True if auth is valid, False otherwise
|
869
|
+
"""
|
870
|
+
auth_header = request.headers.get("Authorization")
|
871
|
+
if not auth_header:
|
872
|
+
return False
|
873
|
+
|
874
|
+
# Extract the credentials from the header
|
875
|
+
try:
|
876
|
+
scheme, credentials = auth_header.split()
|
877
|
+
if scheme.lower() != "basic":
|
878
|
+
return False
|
879
|
+
|
880
|
+
decoded = base64.b64decode(credentials).decode("utf-8")
|
881
|
+
username, password = decoded.split(":")
|
882
|
+
|
883
|
+
# Compare with our credentials
|
884
|
+
expected_username, expected_password = self._basic_auth
|
885
|
+
return username == expected_username and password == expected_password
|
886
|
+
except Exception:
|
887
|
+
return False
|
888
|
+
|
889
|
+
def get_basic_auth_credentials(self, include_source: bool = False) -> Union[Tuple[str, str], Tuple[str, str, str]]:
|
890
|
+
"""
|
891
|
+
Get the basic auth credentials
|
892
|
+
|
893
|
+
Args:
|
894
|
+
include_source: Whether to include the source of the credentials
|
895
|
+
|
896
|
+
Returns:
|
897
|
+
(username, password) tuple or (username, password, source) tuple if include_source is True
|
898
|
+
"""
|
899
|
+
username, password = self._basic_auth
|
900
|
+
|
901
|
+
if include_source:
|
902
|
+
# Determine source
|
903
|
+
env_user = os.environ.get('SWML_BASIC_AUTH_USER')
|
904
|
+
env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
|
905
|
+
|
906
|
+
if env_user and env_pass and env_user == username and env_pass == password:
|
907
|
+
source = "environment"
|
908
|
+
else:
|
909
|
+
source = "auto-generated"
|
910
|
+
|
911
|
+
return username, password, source
|
912
|
+
|
913
|
+
return username, password
|
914
|
+
|
915
|
+
# Keep the existing methods for backward compatibility
|
916
|
+
|
917
|
+
def add_answer_verb(self, max_duration: Optional[int] = None, codecs: Optional[str] = None) -> bool:
|
918
|
+
"""
|
919
|
+
Add an answer verb to the current document
|
920
|
+
|
921
|
+
Args:
|
922
|
+
max_duration: Maximum duration in seconds
|
923
|
+
codecs: Comma-separated list of codecs
|
924
|
+
|
925
|
+
Returns:
|
926
|
+
True if added successfully, False otherwise
|
927
|
+
"""
|
928
|
+
config = {}
|
929
|
+
if max_duration is not None:
|
930
|
+
config["max_duration"] = max_duration
|
931
|
+
if codecs is not None:
|
932
|
+
config["codecs"] = codecs
|
933
|
+
|
934
|
+
return self.add_verb("answer", config)
|
935
|
+
|
936
|
+
def add_hangup_verb(self, reason: Optional[str] = None) -> bool:
|
937
|
+
"""
|
938
|
+
Add a hangup verb to the current document
|
939
|
+
|
940
|
+
Args:
|
941
|
+
reason: Hangup reason (hangup, busy, decline)
|
942
|
+
|
943
|
+
Returns:
|
944
|
+
True if added successfully, False otherwise
|
945
|
+
"""
|
946
|
+
config = {}
|
947
|
+
if reason is not None:
|
948
|
+
config["reason"] = reason
|
949
|
+
|
950
|
+
return self.add_verb("hangup", config)
|
951
|
+
|
952
|
+
def add_ai_verb(self,
|
953
|
+
prompt_text: Optional[str] = None,
|
954
|
+
prompt_pom: Optional[List[Dict[str, Any]]] = None,
|
955
|
+
post_prompt: Optional[str] = None,
|
956
|
+
post_prompt_url: Optional[str] = None,
|
957
|
+
swaig: Optional[Dict[str, Any]] = None,
|
958
|
+
**kwargs) -> bool:
|
959
|
+
"""
|
960
|
+
Add an AI verb to the current document
|
961
|
+
|
962
|
+
Args:
|
963
|
+
prompt_text: Simple prompt text
|
964
|
+
prompt_pom: Prompt object model
|
965
|
+
post_prompt: Post-prompt text
|
966
|
+
post_prompt_url: Post-prompt URL
|
967
|
+
swaig: SWAIG configuration
|
968
|
+
**kwargs: Additional parameters
|
969
|
+
|
970
|
+
Returns:
|
971
|
+
True if added successfully, False otherwise
|
972
|
+
"""
|
973
|
+
config = {}
|
974
|
+
|
975
|
+
# Handle prompt
|
976
|
+
if prompt_text is not None:
|
977
|
+
config["prompt"] = prompt_text
|
978
|
+
elif prompt_pom is not None:
|
979
|
+
config["prompt"] = prompt_pom
|
980
|
+
|
981
|
+
# Handle post prompt
|
982
|
+
if post_prompt is not None:
|
983
|
+
config["post_prompt"] = post_prompt
|
984
|
+
|
985
|
+
# Handle post prompt URL
|
986
|
+
if post_prompt_url is not None:
|
987
|
+
config["post_prompt_url"] = post_prompt_url
|
988
|
+
|
989
|
+
# Handle SWAIG
|
990
|
+
if swaig is not None:
|
991
|
+
config["SWAIG"] = swaig
|
992
|
+
|
993
|
+
# Handle additional parameters
|
994
|
+
for key, value in kwargs.items():
|
995
|
+
if value is not None:
|
996
|
+
config[key] = value
|
997
|
+
|
998
|
+
return self.add_verb("ai", config)
|
999
|
+
|
1000
|
+
def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
|
1001
|
+
"""
|
1002
|
+
Helper method to build webhook URLs consistently
|
1003
|
+
|
1004
|
+
Args:
|
1005
|
+
endpoint: The endpoint path (e.g., "swaig", "post_prompt")
|
1006
|
+
query_params: Optional query parameters to append
|
1007
|
+
|
1008
|
+
Returns:
|
1009
|
+
Fully constructed webhook URL
|
1010
|
+
"""
|
1011
|
+
# Base URL construction
|
1012
|
+
if hasattr(self, '_proxy_url_base') and getattr(self, '_proxy_url_base', None):
|
1013
|
+
# For proxy URLs
|
1014
|
+
base = self._proxy_url_base.rstrip('/')
|
1015
|
+
|
1016
|
+
# Always add auth credentials
|
1017
|
+
username, password = self._basic_auth
|
1018
|
+
url = urlparse(base)
|
1019
|
+
base = url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
|
1020
|
+
else:
|
1021
|
+
# Determine protocol based on SSL settings
|
1022
|
+
protocol = "https" if getattr(self, 'ssl_enabled', False) else "http"
|
1023
|
+
|
1024
|
+
# Use domain if available and SSL is enabled
|
1025
|
+
if getattr(self, 'ssl_enabled', False) and getattr(self, 'domain', None):
|
1026
|
+
host_part = self.domain
|
1027
|
+
else:
|
1028
|
+
# For local URLs
|
1029
|
+
if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
|
1030
|
+
host = "localhost"
|
1031
|
+
else:
|
1032
|
+
host = self.host
|
1033
|
+
|
1034
|
+
host_part = f"{host}:{self.port}"
|
1035
|
+
|
1036
|
+
# Always include auth credentials
|
1037
|
+
username, password = self._basic_auth
|
1038
|
+
base = f"{protocol}://{username}:{password}@{host_part}"
|
1039
|
+
|
1040
|
+
# Ensure the endpoint has a trailing slash to prevent redirects
|
1041
|
+
if endpoint and not endpoint.endswith('/'):
|
1042
|
+
endpoint = f"{endpoint}/"
|
1043
|
+
|
1044
|
+
# Simple path - use the route directly with the endpoint
|
1045
|
+
path = f"{self.route}/{endpoint}"
|
1046
|
+
|
1047
|
+
# Construct full URL
|
1048
|
+
url = f"{base}{path}"
|
1049
|
+
|
1050
|
+
# Add query parameters if any (only if they have values)
|
1051
|
+
if query_params:
|
1052
|
+
filtered_params = {k: v for k, v in query_params.items() if v}
|
1053
|
+
if filtered_params:
|
1054
|
+
params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
|
1055
|
+
url = f"{url}?{params}"
|
1056
|
+
|
1057
|
+
return url
|