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