signalwire-agents 0.1.1__py3-none-any.whl → 0.1.5__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 (34) hide show
  1. signalwire_agents/__init__.py +1 -1
  2. signalwire_agents/agent_server.py +1 -1
  3. signalwire_agents/core/__init__.py +29 -0
  4. signalwire_agents/core/agent_base.py +2541 -0
  5. signalwire_agents/core/function_result.py +123 -0
  6. signalwire_agents/core/pom_builder.py +204 -0
  7. signalwire_agents/core/security/__init__.py +9 -0
  8. signalwire_agents/core/security/session_manager.py +179 -0
  9. signalwire_agents/core/state/__init__.py +17 -0
  10. signalwire_agents/core/state/file_state_manager.py +219 -0
  11. signalwire_agents/core/state/state_manager.py +101 -0
  12. signalwire_agents/core/swaig_function.py +172 -0
  13. signalwire_agents/core/swml_builder.py +214 -0
  14. signalwire_agents/core/swml_handler.py +227 -0
  15. signalwire_agents/core/swml_renderer.py +368 -0
  16. signalwire_agents/core/swml_service.py +1057 -0
  17. signalwire_agents/prefabs/__init__.py +26 -0
  18. signalwire_agents/prefabs/concierge.py +267 -0
  19. signalwire_agents/prefabs/faq_bot.py +305 -0
  20. signalwire_agents/prefabs/info_gatherer.py +263 -0
  21. signalwire_agents/prefabs/receptionist.py +295 -0
  22. signalwire_agents/prefabs/survey.py +378 -0
  23. signalwire_agents/utils/__init__.py +9 -0
  24. signalwire_agents/utils/pom_utils.py +9 -0
  25. signalwire_agents/utils/schema_utils.py +357 -0
  26. signalwire_agents/utils/token_generators.py +9 -0
  27. signalwire_agents/utils/validators.py +9 -0
  28. {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.5.dist-info}/METADATA +1 -1
  29. signalwire_agents-0.1.5.dist-info/RECORD +34 -0
  30. signalwire_agents-0.1.1.dist-info/RECORD +0 -9
  31. {signalwire_agents-0.1.1.data → signalwire_agents-0.1.5.data}/data/schema.json +0 -0
  32. {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.5.dist-info}/WHEEL +0 -0
  33. {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.5.dist-info}/licenses/LICENSE +0 -0
  34. {signalwire_agents-0.1.1.dist-info → signalwire_agents-0.1.5.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