signalwire-agents 1.0.13__py3-none-any.whl → 1.0.14__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 +80 -58
- signalwire_agents/cli/dokku.py +945 -151
- {signalwire_agents-1.0.13.dist-info → signalwire_agents-1.0.14.dist-info}/METADATA +1 -1
- {signalwire_agents-1.0.13.dist-info → signalwire_agents-1.0.14.dist-info}/RECORD +12 -12
- {signalwire_agents-1.0.13.data → signalwire_agents-1.0.14.data}/data/share/man/man1/sw-agent-init.1 +0 -0
- {signalwire_agents-1.0.13.data → signalwire_agents-1.0.14.data}/data/share/man/man1/sw-search.1 +0 -0
- {signalwire_agents-1.0.13.data → signalwire_agents-1.0.14.data}/data/share/man/man1/swaig-test.1 +0 -0
- {signalwire_agents-1.0.13.dist-info → signalwire_agents-1.0.14.dist-info}/WHEEL +0 -0
- {signalwire_agents-1.0.13.dist-info → signalwire_agents-1.0.14.dist-info}/entry_points.txt +0 -0
- {signalwire_agents-1.0.13.dist-info → signalwire_agents-1.0.14.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-1.0.13.dist-info → signalwire_agents-1.0.14.dist-info}/top_level.txt +0 -0
signalwire_agents/cli/dokku.py
CHANGED
|
@@ -96,6 +96,7 @@ REQUIREMENTS_TEMPLATE = """signalwire-agents>=1.0.13
|
|
|
96
96
|
gunicorn>=21.0.0
|
|
97
97
|
uvicorn>=0.24.0
|
|
98
98
|
python-dotenv>=1.0.0
|
|
99
|
+
requests>=2.28.0
|
|
99
100
|
"""
|
|
100
101
|
|
|
101
102
|
CHECKS_TEMPLATE = """WAIT=5
|
|
@@ -138,9 +139,24 @@ Thumbs.db
|
|
|
138
139
|
"""
|
|
139
140
|
|
|
140
141
|
ENV_EXAMPLE_TEMPLATE = """# SignalWire Agent Configuration
|
|
142
|
+
# =============================================================================
|
|
143
|
+
|
|
144
|
+
# SignalWire Credentials (required for WebRTC calling)
|
|
145
|
+
SIGNALWIRE_SPACE_NAME=your-space
|
|
146
|
+
SIGNALWIRE_PROJECT_ID=your-project-id
|
|
147
|
+
SIGNALWIRE_TOKEN=your-api-token
|
|
148
|
+
|
|
149
|
+
# Public URL for SWML callbacks (required for WebRTC calling)
|
|
150
|
+
# This should be your publicly accessible URL (e.g., ngrok, dokku domain)
|
|
151
|
+
SWML_PROXY_URL_BASE=https://your-app.example.com
|
|
152
|
+
|
|
153
|
+
# Basic Auth for SWML endpoints (recommended)
|
|
141
154
|
SWML_BASIC_AUTH_USER=admin
|
|
142
155
|
SWML_BASIC_AUTH_PASSWORD=your-secure-password
|
|
143
156
|
|
|
157
|
+
# Agent Configuration
|
|
158
|
+
AGENT_NAME={app_name}
|
|
159
|
+
|
|
144
160
|
# App Configuration
|
|
145
161
|
APP_ENV=production
|
|
146
162
|
APP_NAME={app_name}
|
|
@@ -235,11 +251,15 @@ APP_TEMPLATE_WITH_WEB = '''#!/usr/bin/env python3
|
|
|
235
251
|
{agent_name} - SignalWire AI Agent
|
|
236
252
|
|
|
237
253
|
Deployed to Dokku with automatic health checks, SWAIG support, and web interface.
|
|
254
|
+
Includes WebRTC calling support with dynamic token generation.
|
|
238
255
|
"""
|
|
239
256
|
|
|
240
257
|
import os
|
|
258
|
+
import time
|
|
241
259
|
from pathlib import Path
|
|
242
260
|
from dotenv import load_dotenv
|
|
261
|
+
import requests
|
|
262
|
+
from starlette.responses import JSONResponse
|
|
243
263
|
from signalwire_agents import AgentBase, AgentServer, SwaigFunctionResult
|
|
244
264
|
|
|
245
265
|
# Load environment variables from .env file
|
|
@@ -301,41 +321,245 @@ class {agent_class}(AgentBase):
|
|
|
301
321
|
)
|
|
302
322
|
|
|
303
323
|
|
|
304
|
-
#
|
|
324
|
+
# =============================================================================
|
|
325
|
+
# SignalWire SWML Handler Management
|
|
326
|
+
# =============================================================================
|
|
327
|
+
|
|
328
|
+
def get_signalwire_host():
|
|
329
|
+
"""Get the full SignalWire host from space name."""
|
|
330
|
+
space = os.getenv("SIGNALWIRE_SPACE_NAME", "")
|
|
331
|
+
if not space:
|
|
332
|
+
return None
|
|
333
|
+
if "." in space:
|
|
334
|
+
return space
|
|
335
|
+
return f"{{space}}.signalwire.com"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def find_existing_handler(sw_host, auth, agent_name):
|
|
339
|
+
"""Find an existing SWML handler by name."""
|
|
340
|
+
try:
|
|
341
|
+
resp = requests.get(
|
|
342
|
+
f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers",
|
|
343
|
+
auth=auth,
|
|
344
|
+
headers={{"Accept": "application/json"}}
|
|
345
|
+
)
|
|
346
|
+
if resp.status_code != 200:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
handlers = resp.json().get("data", [])
|
|
350
|
+
for handler in handlers:
|
|
351
|
+
swml_webhook = handler.get("swml_webhook", {{}})
|
|
352
|
+
handler_name = swml_webhook.get("name") or handler.get("display_name")
|
|
353
|
+
if handler_name == agent_name:
|
|
354
|
+
handler_id = handler.get("id")
|
|
355
|
+
handler_url = swml_webhook.get("primary_request_url", "")
|
|
356
|
+
addr_resp = requests.get(
|
|
357
|
+
f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers/{{handler_id}}/addresses",
|
|
358
|
+
auth=auth,
|
|
359
|
+
headers={{"Accept": "application/json"}}
|
|
360
|
+
)
|
|
361
|
+
if addr_resp.status_code == 200:
|
|
362
|
+
addresses = addr_resp.json().get("data", [])
|
|
363
|
+
if addresses:
|
|
364
|
+
return {{
|
|
365
|
+
"id": handler_id,
|
|
366
|
+
"name": handler_name,
|
|
367
|
+
"url": handler_url,
|
|
368
|
+
"address_id": addresses[0]["id"],
|
|
369
|
+
"address": addresses[0]["channels"]["audio"]
|
|
370
|
+
}}
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"Error checking existing handlers: {{e}}")
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Store SWML handler info
|
|
377
|
+
swml_handler_info = {{"id": None, "address_id": None, "address": None}}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def setup_swml_handler():
|
|
381
|
+
"""Set up SWML handler on startup."""
|
|
382
|
+
sw_host = get_signalwire_host()
|
|
383
|
+
project = os.getenv("SIGNALWIRE_PROJECT_ID", "")
|
|
384
|
+
token = os.getenv("SIGNALWIRE_TOKEN", "")
|
|
385
|
+
agent_name = os.getenv("AGENT_NAME", "{agent_slug}")
|
|
386
|
+
proxy_url = os.getenv("SWML_PROXY_URL_BASE", "")
|
|
387
|
+
auth_user = os.getenv("SWML_BASIC_AUTH_USER", "")
|
|
388
|
+
auth_pass = os.getenv("SWML_BASIC_AUTH_PASSWORD", "")
|
|
389
|
+
|
|
390
|
+
if not all([sw_host, project, token]):
|
|
391
|
+
print("SignalWire credentials not configured - skipping SWML handler setup")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
if not proxy_url:
|
|
395
|
+
print("SWML_PROXY_URL_BASE not set - skipping SWML handler setup")
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
# Build SWML URL with basic auth
|
|
399
|
+
if auth_user and auth_pass and "://" in proxy_url:
|
|
400
|
+
scheme, rest = proxy_url.split("://", 1)
|
|
401
|
+
swml_url = f"{{scheme}}://{{auth_user}}:{{auth_pass}}@{{rest}}/swml"
|
|
402
|
+
else:
|
|
403
|
+
swml_url = proxy_url + "/swml"
|
|
404
|
+
|
|
405
|
+
auth = (project, token)
|
|
406
|
+
headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
|
|
407
|
+
|
|
408
|
+
existing = find_existing_handler(sw_host, auth, agent_name)
|
|
409
|
+
if existing:
|
|
410
|
+
swml_handler_info["id"] = existing["id"]
|
|
411
|
+
swml_handler_info["address_id"] = existing["address_id"]
|
|
412
|
+
swml_handler_info["address"] = existing["address"]
|
|
413
|
+
|
|
414
|
+
if existing.get("url") != swml_url:
|
|
415
|
+
try:
|
|
416
|
+
requests.put(
|
|
417
|
+
f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers/{{existing['id']}}",
|
|
418
|
+
json={{"primary_request_url": swml_url, "primary_request_method": "POST"}},
|
|
419
|
+
auth=auth,
|
|
420
|
+
headers=headers
|
|
421
|
+
)
|
|
422
|
+
print(f"Updated SWML handler: {{existing['name']}}")
|
|
423
|
+
except Exception as e:
|
|
424
|
+
print(f"Failed to update handler URL: {{e}}")
|
|
425
|
+
else:
|
|
426
|
+
print(f"Using existing SWML handler: {{existing['name']}}")
|
|
427
|
+
print(f"Call address: {{existing['address']}}")
|
|
428
|
+
else:
|
|
429
|
+
try:
|
|
430
|
+
handler_resp = requests.post(
|
|
431
|
+
f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers",
|
|
432
|
+
json={{
|
|
433
|
+
"name": agent_name,
|
|
434
|
+
"used_for": "calling",
|
|
435
|
+
"primary_request_url": swml_url,
|
|
436
|
+
"primary_request_method": "POST"
|
|
437
|
+
}},
|
|
438
|
+
auth=auth,
|
|
439
|
+
headers=headers
|
|
440
|
+
)
|
|
441
|
+
handler_resp.raise_for_status()
|
|
442
|
+
handler_id = handler_resp.json().get("id")
|
|
443
|
+
swml_handler_info["id"] = handler_id
|
|
444
|
+
|
|
445
|
+
addr_resp = requests.get(
|
|
446
|
+
f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers/{{handler_id}}/addresses",
|
|
447
|
+
auth=auth,
|
|
448
|
+
headers={{"Accept": "application/json"}}
|
|
449
|
+
)
|
|
450
|
+
addr_resp.raise_for_status()
|
|
451
|
+
addresses = addr_resp.json().get("data", [])
|
|
452
|
+
if addresses:
|
|
453
|
+
swml_handler_info["address_id"] = addresses[0]["id"]
|
|
454
|
+
swml_handler_info["address"] = addresses[0]["channels"]["audio"]
|
|
455
|
+
print(f"Created SWML handler: {{agent_name}}")
|
|
456
|
+
print(f"Call address: {{swml_handler_info['address']}}")
|
|
457
|
+
except Exception as e:
|
|
458
|
+
print(f"Failed to create SWML handler: {{e}}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# =============================================================================
|
|
462
|
+
# Server Setup
|
|
463
|
+
# =============================================================================
|
|
464
|
+
|
|
305
465
|
server = AgentServer(host="0.0.0.0", port=int(os.getenv("PORT", 3000)))
|
|
306
466
|
server.register({agent_class}())
|
|
307
467
|
|
|
308
|
-
# Serve static files from web/ directory
|
|
468
|
+
# Serve static files from web/ directory
|
|
309
469
|
web_dir = Path(__file__).parent / "web"
|
|
310
470
|
if web_dir.exists():
|
|
311
471
|
server.serve_static_files(str(web_dir))
|
|
312
472
|
|
|
313
|
-
# Expose the ASGI app for gunicorn
|
|
314
|
-
app = server.app
|
|
315
473
|
|
|
316
|
-
#
|
|
474
|
+
# =============================================================================
|
|
475
|
+
# API Endpoints
|
|
476
|
+
# =============================================================================
|
|
477
|
+
|
|
478
|
+
@server.app.get("/get_token")
|
|
479
|
+
def get_token():
|
|
480
|
+
"""Get a guest token for WebRTC calls."""
|
|
481
|
+
sw_host = get_signalwire_host()
|
|
482
|
+
project = os.getenv("SIGNALWIRE_PROJECT_ID", "")
|
|
483
|
+
token = os.getenv("SIGNALWIRE_TOKEN", "")
|
|
484
|
+
|
|
485
|
+
if not all([sw_host, project, token]):
|
|
486
|
+
return JSONResponse({{"error": "SignalWire credentials not configured"}}, status_code=500)
|
|
487
|
+
|
|
488
|
+
if not swml_handler_info["address_id"]:
|
|
489
|
+
return JSONResponse({{"error": "SWML handler not configured - check startup logs"}}, status_code=500)
|
|
490
|
+
|
|
491
|
+
auth = (project, token)
|
|
492
|
+
headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
expire_at = int(time.time()) + 3600 * 24 # 24 hours
|
|
496
|
+
|
|
497
|
+
guest_resp = requests.post(
|
|
498
|
+
f"https://{{sw_host}}/api/fabric/guests/tokens",
|
|
499
|
+
json={{
|
|
500
|
+
"allowed_addresses": [swml_handler_info["address_id"]],
|
|
501
|
+
"expire_at": expire_at
|
|
502
|
+
}},
|
|
503
|
+
auth=auth,
|
|
504
|
+
headers=headers
|
|
505
|
+
)
|
|
506
|
+
guest_resp.raise_for_status()
|
|
507
|
+
guest_token = guest_resp.json().get("token", "")
|
|
508
|
+
|
|
509
|
+
return {{
|
|
510
|
+
"token": guest_token,
|
|
511
|
+
"address": swml_handler_info["address"]
|
|
512
|
+
}}
|
|
513
|
+
|
|
514
|
+
except requests.exceptions.RequestException as e:
|
|
515
|
+
print(f"Token request failed: {{e}}")
|
|
516
|
+
return JSONResponse({{"error": str(e)}}, status_code=500)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@server.app.get("/get_credentials")
|
|
520
|
+
def get_credentials():
|
|
521
|
+
"""Get basic auth credentials for curl examples."""
|
|
522
|
+
return {{
|
|
523
|
+
"user": os.getenv("SWML_BASIC_AUTH_USER", ""),
|
|
524
|
+
"password": os.getenv("SWML_BASIC_AUTH_PASSWORD", "")
|
|
525
|
+
}}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@server.app.get("/get_resource_info")
|
|
529
|
+
def get_resource_info():
|
|
530
|
+
"""Get SWML handler resource info for dashboard link."""
|
|
531
|
+
sw_host = get_signalwire_host()
|
|
532
|
+
space_name = os.getenv("SIGNALWIRE_SPACE_NAME", "")
|
|
533
|
+
return {{
|
|
534
|
+
"space_name": space_name,
|
|
535
|
+
"resource_id": swml_handler_info["id"],
|
|
536
|
+
"dashboard_url": f"https://{{sw_host}}/neon/resources/{{swml_handler_info['id']}}/edit?t=addresses" if sw_host and swml_handler_info["id"] else None
|
|
537
|
+
}}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# =============================================================================
|
|
541
|
+
# Static File Handling
|
|
542
|
+
# =============================================================================
|
|
543
|
+
|
|
317
544
|
from fastapi import Request, HTTPException
|
|
318
545
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
319
546
|
import mimetypes
|
|
320
547
|
|
|
321
|
-
|
|
322
|
-
@app.api_route("/swml", methods=["GET", "POST"])
|
|
548
|
+
@server.app.api_route("/swml", methods=["GET", "POST"])
|
|
323
549
|
async def swml_redirect():
|
|
324
550
|
return RedirectResponse(url="/swml/", status_code=307)
|
|
325
551
|
|
|
326
|
-
@app.get("/{{full_path:path}}")
|
|
552
|
+
@server.app.get("/{{full_path:path}}")
|
|
327
553
|
async def serve_static(request: Request, full_path: str):
|
|
328
554
|
"""Serve static files from web/ directory"""
|
|
329
555
|
if not web_dir.exists():
|
|
330
556
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
331
557
|
|
|
332
|
-
# Handle root path
|
|
333
558
|
if not full_path or full_path == "/":
|
|
334
559
|
full_path = "index.html"
|
|
335
560
|
|
|
336
561
|
file_path = web_dir / full_path
|
|
337
562
|
|
|
338
|
-
# Security: prevent directory traversal
|
|
339
563
|
try:
|
|
340
564
|
file_path = file_path.resolve()
|
|
341
565
|
if not str(file_path).startswith(str(web_dir.resolve())):
|
|
@@ -347,12 +571,18 @@ async def serve_static(request: Request, full_path: str):
|
|
|
347
571
|
media_type, _ = mimetypes.guess_type(str(file_path))
|
|
348
572
|
return FileResponse(file_path, media_type=media_type)
|
|
349
573
|
|
|
350
|
-
# Try index.html for directory paths
|
|
351
574
|
if (web_dir / full_path / "index.html").exists():
|
|
352
575
|
return FileResponse(web_dir / full_path / "index.html", media_type="text/html")
|
|
353
576
|
|
|
354
577
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
355
578
|
|
|
579
|
+
|
|
580
|
+
# Set up SWML handler on startup
|
|
581
|
+
setup_swml_handler()
|
|
582
|
+
|
|
583
|
+
# Expose ASGI app for gunicorn
|
|
584
|
+
app = server.app
|
|
585
|
+
|
|
356
586
|
if __name__ == "__main__":
|
|
357
587
|
server.run()
|
|
358
588
|
'''
|
|
@@ -364,182 +594,684 @@ WEB_INDEX_TEMPLATE = '''<!DOCTYPE html>
|
|
|
364
594
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
365
595
|
<title>{agent_name}</title>
|
|
366
596
|
<style>
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
597
|
+
* {{ box-sizing: border-box; }}
|
|
598
|
+
body {{
|
|
599
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
600
|
+
max-width: 900px;
|
|
601
|
+
margin: 0 auto;
|
|
602
|
+
padding: 40px 20px;
|
|
603
|
+
background: #f8f9fa;
|
|
604
|
+
color: #333;
|
|
375
605
|
}}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
padding:
|
|
606
|
+
h1 {{
|
|
607
|
+
color: #044cf6;
|
|
608
|
+
border-bottom: 3px solid #044cf6;
|
|
609
|
+
padding-bottom: 10px;
|
|
380
610
|
}}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
display: flex;
|
|
387
|
-
align-items: center;
|
|
388
|
-
justify-content: center;
|
|
389
|
-
padding: 2rem;
|
|
611
|
+
h2 {{
|
|
612
|
+
color: #333;
|
|
613
|
+
margin-top: 40px;
|
|
614
|
+
border-bottom: 1px solid #ddd;
|
|
615
|
+
padding-bottom: 8px;
|
|
390
616
|
}}
|
|
391
|
-
.
|
|
392
|
-
|
|
393
|
-
|
|
617
|
+
.status {{
|
|
618
|
+
background: #d4edda;
|
|
619
|
+
border: 1px solid #c3e6cb;
|
|
620
|
+
color: #155724;
|
|
621
|
+
padding: 15px;
|
|
622
|
+
border-radius: 8px;
|
|
623
|
+
margin: 20px 0;
|
|
394
624
|
}}
|
|
395
|
-
.
|
|
396
|
-
background:
|
|
397
|
-
border-radius:
|
|
398
|
-
|
|
399
|
-
|
|
625
|
+
.call-section {{
|
|
626
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
627
|
+
border-radius: 12px;
|
|
628
|
+
padding: 30px;
|
|
629
|
+
margin: 20px 0;
|
|
630
|
+
color: white;
|
|
400
631
|
text-align: center;
|
|
401
632
|
}}
|
|
402
|
-
.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
633
|
+
.call-section h2 {{
|
|
634
|
+
color: white;
|
|
635
|
+
border: none;
|
|
636
|
+
margin-top: 0;
|
|
637
|
+
}}
|
|
638
|
+
.call-controls {{
|
|
407
639
|
display: flex;
|
|
408
|
-
|
|
640
|
+
gap: 15px;
|
|
409
641
|
justify-content: center;
|
|
410
|
-
|
|
642
|
+
align-items: center;
|
|
643
|
+
flex-wrap: wrap;
|
|
411
644
|
}}
|
|
412
|
-
.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
645
|
+
.call-btn {{
|
|
646
|
+
padding: 15px 40px;
|
|
647
|
+
font-size: 18px;
|
|
648
|
+
font-weight: bold;
|
|
649
|
+
border: none;
|
|
650
|
+
border-radius: 8px;
|
|
651
|
+
cursor: pointer;
|
|
652
|
+
transition: all 0.3s ease;
|
|
416
653
|
}}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
654
|
+
.call-btn:disabled {{
|
|
655
|
+
opacity: 0.5;
|
|
656
|
+
cursor: not-allowed;
|
|
420
657
|
}}
|
|
421
|
-
.
|
|
422
|
-
|
|
423
|
-
|
|
658
|
+
.call-btn.connect {{
|
|
659
|
+
background: #10b981;
|
|
660
|
+
color: white;
|
|
424
661
|
}}
|
|
425
|
-
.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
font-size: 0.875rem;
|
|
434
|
-
font-weight: 500;
|
|
662
|
+
.call-btn.connect:hover:not(:disabled) {{
|
|
663
|
+
background: #059669;
|
|
664
|
+
transform: translateY(-2px);
|
|
665
|
+
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4);
|
|
666
|
+
}}
|
|
667
|
+
.call-btn.disconnect {{
|
|
668
|
+
background: #ef4444;
|
|
669
|
+
color: white;
|
|
435
670
|
}}
|
|
436
|
-
.
|
|
437
|
-
|
|
438
|
-
width: 8px;
|
|
439
|
-
height: 8px;
|
|
440
|
-
background: #22c55e;
|
|
441
|
-
border-radius: 50%;
|
|
671
|
+
.call-btn.disconnect:hover:not(:disabled) {{
|
|
672
|
+
background: #dc2626;
|
|
442
673
|
}}
|
|
443
|
-
.
|
|
444
|
-
margin-top:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
padding-top: 1.5rem;
|
|
674
|
+
.call-status {{
|
|
675
|
+
margin-top: 15px;
|
|
676
|
+
font-size: 14px;
|
|
677
|
+
opacity: 0.9;
|
|
448
678
|
}}
|
|
449
|
-
.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
679
|
+
.destination-input {{
|
|
680
|
+
padding: 12px 15px;
|
|
681
|
+
font-size: 14px;
|
|
682
|
+
border: 2px solid rgba(255,255,255,0.3);
|
|
683
|
+
border-radius: 8px;
|
|
684
|
+
background: rgba(255,255,255,0.1);
|
|
685
|
+
color: white;
|
|
686
|
+
width: 250px;
|
|
687
|
+
}}
|
|
688
|
+
.destination-input::placeholder {{
|
|
689
|
+
color: rgba(255,255,255,0.6);
|
|
690
|
+
}}
|
|
691
|
+
.destination-input:focus {{
|
|
692
|
+
outline: none;
|
|
693
|
+
border-color: rgba(255,255,255,0.6);
|
|
694
|
+
background: rgba(255,255,255,0.2);
|
|
455
695
|
}}
|
|
456
696
|
.endpoint {{
|
|
697
|
+
background: white;
|
|
698
|
+
border: 1px solid #ddd;
|
|
699
|
+
border-radius: 8px;
|
|
700
|
+
padding: 20px;
|
|
701
|
+
margin: 15px 0;
|
|
702
|
+
}}
|
|
703
|
+
.endpoint h3 {{
|
|
704
|
+
margin-top: 0;
|
|
705
|
+
color: #044cf6;
|
|
706
|
+
}}
|
|
707
|
+
.method {{
|
|
708
|
+
display: inline-block;
|
|
709
|
+
padding: 4px 10px;
|
|
710
|
+
border-radius: 4px;
|
|
711
|
+
font-weight: bold;
|
|
712
|
+
font-size: 12px;
|
|
713
|
+
margin-right: 10px;
|
|
714
|
+
}}
|
|
715
|
+
.method.get {{ background: #61affe; color: white; }}
|
|
716
|
+
.method.post {{ background: #49cc90; color: white; }}
|
|
717
|
+
.path {{
|
|
718
|
+
font-family: monospace;
|
|
719
|
+
font-size: 16px;
|
|
720
|
+
color: #333;
|
|
721
|
+
}}
|
|
722
|
+
code, pre {{
|
|
723
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
724
|
+
}}
|
|
725
|
+
code {{
|
|
726
|
+
background: #e9ecef;
|
|
727
|
+
padding: 2px 6px;
|
|
728
|
+
border-radius: 4px;
|
|
729
|
+
font-size: 14px;
|
|
730
|
+
}}
|
|
731
|
+
pre {{
|
|
732
|
+
background: #1e1e1e;
|
|
733
|
+
color: #d4d4d4;
|
|
734
|
+
padding: 15px;
|
|
735
|
+
border-radius: 8px;
|
|
736
|
+
overflow-x: auto;
|
|
737
|
+
font-size: 13px;
|
|
738
|
+
line-height: 1.5;
|
|
739
|
+
margin: 0;
|
|
740
|
+
}}
|
|
741
|
+
pre .comment {{ color: #6a9955; }}
|
|
742
|
+
.tabs {{
|
|
457
743
|
display: flex;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
border-bottom: 1px solid var(--border);
|
|
744
|
+
gap: 0;
|
|
745
|
+
margin-top: 15px;
|
|
461
746
|
}}
|
|
462
|
-
.
|
|
747
|
+
.tab {{
|
|
748
|
+
padding: 8px 16px;
|
|
749
|
+
background: #e9ecef;
|
|
750
|
+
border: 1px solid #ddd;
|
|
463
751
|
border-bottom: none;
|
|
752
|
+
border-radius: 8px 8px 0 0;
|
|
753
|
+
cursor: pointer;
|
|
754
|
+
font-size: 13px;
|
|
755
|
+
font-weight: 500;
|
|
756
|
+
color: #666;
|
|
757
|
+
transition: all 0.2s;
|
|
464
758
|
}}
|
|
465
|
-
.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
border-
|
|
470
|
-
margin-right: 1rem;
|
|
471
|
-
min-width: 50px;
|
|
472
|
-
text-align: center;
|
|
759
|
+
.tab:hover {{ background: #dee2e6; }}
|
|
760
|
+
.tab.active {{
|
|
761
|
+
background: #1e1e1e;
|
|
762
|
+
color: #d4d4d4;
|
|
763
|
+
border-color: #1e1e1e;
|
|
473
764
|
}}
|
|
474
|
-
.
|
|
475
|
-
|
|
476
|
-
|
|
765
|
+
.tab-content {{
|
|
766
|
+
display: none;
|
|
767
|
+
border-radius: 0 8px 8px 8px;
|
|
477
768
|
}}
|
|
478
|
-
.
|
|
479
|
-
|
|
480
|
-
|
|
769
|
+
.tab-content.active {{ display: block; }}
|
|
770
|
+
.browser-panel {{
|
|
771
|
+
background: #f8f9fa;
|
|
772
|
+
border: 1px solid #ddd;
|
|
773
|
+
border-radius: 0 8px 8px 8px;
|
|
774
|
+
padding: 15px;
|
|
481
775
|
}}
|
|
482
|
-
.
|
|
483
|
-
|
|
484
|
-
|
|
776
|
+
.try-btn {{
|
|
777
|
+
padding: 10px 20px;
|
|
778
|
+
background: #044cf6;
|
|
779
|
+
color: white;
|
|
780
|
+
border: none;
|
|
781
|
+
border-radius: 6px;
|
|
782
|
+
cursor: pointer;
|
|
783
|
+
font-weight: 500;
|
|
784
|
+
font-size: 14px;
|
|
785
|
+
transition: all 0.2s;
|
|
786
|
+
}}
|
|
787
|
+
.try-btn:hover {{ background: #0339c2; }}
|
|
788
|
+
.try-btn:disabled {{
|
|
789
|
+
background: #ccc;
|
|
790
|
+
cursor: not-allowed;
|
|
791
|
+
}}
|
|
792
|
+
.response-area {{
|
|
793
|
+
margin-top: 15px;
|
|
794
|
+
display: none;
|
|
795
|
+
}}
|
|
796
|
+
.response-area.visible {{ display: block; }}
|
|
797
|
+
.response-header {{
|
|
798
|
+
display: flex;
|
|
799
|
+
justify-content: space-between;
|
|
800
|
+
align-items: center;
|
|
801
|
+
margin-bottom: 8px;
|
|
802
|
+
}}
|
|
803
|
+
.response-status {{
|
|
804
|
+
font-size: 13px;
|
|
805
|
+
font-weight: 500;
|
|
806
|
+
}}
|
|
807
|
+
.response-status.success {{ color: #10b981; }}
|
|
808
|
+
.response-status.error {{ color: #ef4444; }}
|
|
809
|
+
.response-time {{
|
|
810
|
+
font-size: 12px;
|
|
811
|
+
color: #666;
|
|
485
812
|
}}
|
|
486
|
-
.
|
|
813
|
+
.response-body {{
|
|
814
|
+
background: #1e1e1e;
|
|
815
|
+
color: #d4d4d4;
|
|
816
|
+
padding: 15px;
|
|
817
|
+
border-radius: 8px;
|
|
818
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
819
|
+
font-size: 12px;
|
|
820
|
+
max-height: 300px;
|
|
821
|
+
overflow: auto;
|
|
822
|
+
white-space: pre-wrap;
|
|
823
|
+
word-break: break-word;
|
|
824
|
+
}}
|
|
825
|
+
.curl-panel {{
|
|
826
|
+
position: relative;
|
|
827
|
+
}}
|
|
828
|
+
.copy-btn {{
|
|
829
|
+
position: absolute;
|
|
830
|
+
top: 10px;
|
|
831
|
+
right: 10px;
|
|
832
|
+
padding: 5px 10px;
|
|
833
|
+
background: rgba(255,255,255,0.1);
|
|
834
|
+
color: #999;
|
|
835
|
+
border: 1px solid rgba(255,255,255,0.2);
|
|
836
|
+
border-radius: 4px;
|
|
837
|
+
cursor: pointer;
|
|
838
|
+
font-size: 11px;
|
|
839
|
+
transition: all 0.2s;
|
|
840
|
+
}}
|
|
841
|
+
.copy-btn:hover {{
|
|
842
|
+
background: rgba(255,255,255,0.2);
|
|
843
|
+
color: #fff;
|
|
844
|
+
}}
|
|
845
|
+
.audio-settings {{
|
|
846
|
+
display: flex;
|
|
847
|
+
gap: 20px;
|
|
848
|
+
justify-content: center;
|
|
849
|
+
margin-top: 15px;
|
|
850
|
+
flex-wrap: wrap;
|
|
851
|
+
}}
|
|
852
|
+
.audio-setting {{
|
|
853
|
+
display: flex;
|
|
854
|
+
align-items: center;
|
|
855
|
+
gap: 8px;
|
|
856
|
+
font-size: 13px;
|
|
857
|
+
color: rgba(255,255,255,0.9);
|
|
858
|
+
}}
|
|
859
|
+
.audio-setting input[type="checkbox"] {{
|
|
860
|
+
width: 18px;
|
|
861
|
+
height: 18px;
|
|
862
|
+
cursor: pointer;
|
|
863
|
+
}}
|
|
864
|
+
.audio-setting label {{
|
|
865
|
+
cursor: pointer;
|
|
866
|
+
}}
|
|
867
|
+
.phone-info {{
|
|
868
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
869
|
+
border: 1px solid #044cf6;
|
|
870
|
+
border-radius: 12px;
|
|
871
|
+
padding: 20px 25px;
|
|
872
|
+
margin: 30px 0;
|
|
873
|
+
max-width: 800px;
|
|
487
874
|
margin-left: auto;
|
|
488
|
-
|
|
489
|
-
|
|
875
|
+
margin-right: auto;
|
|
876
|
+
}}
|
|
877
|
+
.phone-info h3 {{
|
|
878
|
+
margin: 0 0 10px 0;
|
|
879
|
+
color: #fff;
|
|
880
|
+
font-size: 16px;
|
|
881
|
+
}}
|
|
882
|
+
.phone-info p {{
|
|
883
|
+
margin: 0 0 15px 0;
|
|
884
|
+
color: rgba(255,255,255,0.8);
|
|
885
|
+
font-size: 14px;
|
|
886
|
+
line-height: 1.5;
|
|
887
|
+
}}
|
|
888
|
+
.phone-info a {{
|
|
889
|
+
display: inline-block;
|
|
890
|
+
padding: 10px 20px;
|
|
891
|
+
background: #044cf6;
|
|
892
|
+
color: white;
|
|
893
|
+
text-decoration: none;
|
|
894
|
+
border-radius: 6px;
|
|
895
|
+
font-weight: 500;
|
|
896
|
+
font-size: 14px;
|
|
897
|
+
transition: all 0.2s;
|
|
898
|
+
}}
|
|
899
|
+
.phone-info a:hover {{
|
|
900
|
+
background: #0339c2;
|
|
901
|
+
}}
|
|
902
|
+
.phone-info.hidden {{
|
|
903
|
+
display: none;
|
|
904
|
+
}}
|
|
905
|
+
.footer {{
|
|
906
|
+
margin-top: 50px;
|
|
907
|
+
padding-top: 20px;
|
|
908
|
+
border-top: 1px solid #ddd;
|
|
909
|
+
color: #666;
|
|
910
|
+
font-size: 14px;
|
|
490
911
|
}}
|
|
491
912
|
</style>
|
|
492
913
|
</head>
|
|
493
914
|
<body>
|
|
494
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
915
|
+
<h1>{agent_name}</h1>
|
|
916
|
+
|
|
917
|
+
<div class="status">
|
|
918
|
+
Your agent is running and ready to receive calls!
|
|
919
|
+
</div>
|
|
920
|
+
|
|
921
|
+
<div class="call-section">
|
|
922
|
+
<h2>Call Your Agent</h2>
|
|
923
|
+
<p>Test your agent directly from the browser using WebRTC.</p>
|
|
924
|
+
<div class="call-controls">
|
|
925
|
+
<input type="text" id="destination" class="destination-input" placeholder="Address will be auto-filled" />
|
|
926
|
+
<button id="connectBtn" class="call-btn connect">Call Agent</button>
|
|
927
|
+
<button id="disconnectBtn" class="call-btn disconnect" disabled>Hang Up</button>
|
|
928
|
+
</div>
|
|
929
|
+
<div class="audio-settings">
|
|
930
|
+
<div class="audio-setting">
|
|
931
|
+
<input type="checkbox" id="echoCancellation" checked>
|
|
932
|
+
<label for="echoCancellation">Echo Cancellation</label>
|
|
933
|
+
</div>
|
|
934
|
+
<div class="audio-setting">
|
|
935
|
+
<input type="checkbox" id="noiseSuppression">
|
|
936
|
+
<label for="noiseSuppression">Noise Suppression</label>
|
|
937
|
+
</div>
|
|
938
|
+
<div class="audio-setting">
|
|
939
|
+
<input type="checkbox" id="autoGainControl">
|
|
940
|
+
<label for="autoGainControl">Auto Gain Control</label>
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
<div id="callStatus" class="call-status"></div>
|
|
944
|
+
</div>
|
|
945
|
+
|
|
946
|
+
<div id="phoneInfo" class="phone-info hidden">
|
|
947
|
+
<h3>Want to call from a phone number?</h3>
|
|
948
|
+
<p>You can assign a SignalWire phone number to this agent. Click below to add a number in the dashboard.</p>
|
|
949
|
+
<a id="dashboardLink" href="#" target="_blank">Add Phone Number in Dashboard</a>
|
|
950
|
+
</div>
|
|
951
|
+
|
|
952
|
+
<h2>Endpoints</h2>
|
|
953
|
+
|
|
954
|
+
<div class="endpoint">
|
|
955
|
+
<h3><span class="method post">POST</span> <span class="path">/swml</span></h3>
|
|
956
|
+
<p>Main SWML endpoint for SignalWire to fetch agent configuration.</p>
|
|
957
|
+
<div class="tabs">
|
|
958
|
+
<div class="tab active" onclick="switchTab(this, 'swml-browser')">Browser</div>
|
|
959
|
+
<div class="tab" onclick="switchTab(this, 'swml-curl')">curl</div>
|
|
960
|
+
</div>
|
|
961
|
+
<div id="swml-browser" class="tab-content active">
|
|
962
|
+
<div class="browser-panel">
|
|
963
|
+
<button class="try-btn" onclick="tryEndpoint('POST', '/swml', {{}}, 'swml-response', true)">Try it</button>
|
|
964
|
+
<div id="swml-response" class="response-area"></div>
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
<div id="swml-curl" class="tab-content">
|
|
968
|
+
<div class="curl-panel">
|
|
969
|
+
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
|
970
|
+
<pre><span class="comment"># Get the SWML configuration</span>
|
|
971
|
+
curl -X POST <span class="base-url"></span>/swml \\
|
|
972
|
+
-u <span class="auth-creds"></span> \\
|
|
973
|
+
-H "Content-Type: application/json" \\
|
|
974
|
+
-d '{{}}'</pre>
|
|
500
975
|
</div>
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
<div class="endpoint">
|
|
523
|
-
<span class="method post">POST</span>
|
|
524
|
-
<span class="path">/swml/swaig</span>
|
|
525
|
-
<span class="desc">SWAIG functions</span>
|
|
526
|
-
</div>
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
|
|
979
|
+
<div class="endpoint">
|
|
980
|
+
<h3><span class="method get">GET</span> <span class="path">/get_token</span></h3>
|
|
981
|
+
<p>Get a guest token for WebRTC calls. Returns a token and call address.</p>
|
|
982
|
+
<div class="tabs">
|
|
983
|
+
<div class="tab active" onclick="switchTab(this, 'token-browser')">Browser</div>
|
|
984
|
+
<div class="tab" onclick="switchTab(this, 'token-curl')">curl</div>
|
|
985
|
+
</div>
|
|
986
|
+
<div id="token-browser" class="tab-content active">
|
|
987
|
+
<div class="browser-panel">
|
|
988
|
+
<button class="try-btn" onclick="tryEndpoint('GET', '/get_token', null, 'token-response')">Try it</button>
|
|
989
|
+
<div id="token-response" class="response-area"></div>
|
|
990
|
+
</div>
|
|
991
|
+
</div>
|
|
992
|
+
<div id="token-curl" class="tab-content">
|
|
993
|
+
<div class="curl-panel">
|
|
994
|
+
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
|
995
|
+
<pre><span class="comment"># Get a guest token</span>
|
|
996
|
+
curl <span class="base-url"></span>/get_token</pre>
|
|
527
997
|
</div>
|
|
528
998
|
</div>
|
|
529
999
|
</div>
|
|
1000
|
+
|
|
1001
|
+
<div class="endpoint">
|
|
1002
|
+
<h3><span class="method post">POST</span> <span class="path">/swml/swaig/</span></h3>
|
|
1003
|
+
<p>SWAIG function endpoint. Test your agent's functions.</p>
|
|
1004
|
+
<div class="tabs">
|
|
1005
|
+
<div class="tab active" onclick="switchTab(this, 'swaig-browser')">Browser</div>
|
|
1006
|
+
<div class="tab" onclick="switchTab(this, 'swaig-curl')">curl</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div id="swaig-browser" class="tab-content active">
|
|
1009
|
+
<div class="browser-panel">
|
|
1010
|
+
<button class="try-btn" onclick="tryEndpoint('POST', '/swml/swaig/', {{function: 'get_info', argument: {{parsed: [{{topic: 'SignalWire'}}]}}}}, 'swaig-response', true)">Try it</button>
|
|
1011
|
+
<div id="swaig-response" class="response-area"></div>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
<div id="swaig-curl" class="tab-content">
|
|
1015
|
+
<div class="curl-panel">
|
|
1016
|
+
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
|
1017
|
+
<pre><span class="comment"># Call a SWAIG function</span>
|
|
1018
|
+
curl -X POST <span class="base-url"></span>/swml/swaig/ \\
|
|
1019
|
+
-u <span class="auth-creds"></span> \\
|
|
1020
|
+
-H "Content-Type: application/json" \\
|
|
1021
|
+
-d '{{"function": "get_info", "argument": {{"parsed": [{{"topic": "SignalWire"}}]}}}}'</pre>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
|
|
1026
|
+
<div class="endpoint">
|
|
1027
|
+
<h3><span class="method get">GET</span> <span class="path">/health</span></h3>
|
|
1028
|
+
<p>Health check endpoint for load balancers and monitoring.</p>
|
|
1029
|
+
</div>
|
|
1030
|
+
|
|
1031
|
+
<div class="footer">
|
|
1032
|
+
Powered by <a href="https://signalwire.com">SignalWire</a> and the
|
|
1033
|
+
<a href="https://github.com/signalwire/signalwire-agents">SignalWire Agents SDK</a>
|
|
1034
|
+
</div>
|
|
1035
|
+
|
|
1036
|
+
<script src="https://cdn.signalwire.com/@signalwire/client"></script>
|
|
1037
|
+
<script>
|
|
1038
|
+
let authCreds = null;
|
|
1039
|
+
|
|
1040
|
+
function switchTab(tabEl, contentId) {{
|
|
1041
|
+
const endpoint = tabEl.closest('.endpoint');
|
|
1042
|
+
endpoint.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
1043
|
+
endpoint.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
1044
|
+
tabEl.classList.add('active');
|
|
1045
|
+
document.getElementById(contentId).classList.add('active');
|
|
1046
|
+
}}
|
|
1047
|
+
|
|
1048
|
+
function copyCode(btn) {{
|
|
1049
|
+
const pre = btn.parentElement.querySelector('pre');
|
|
1050
|
+
const text = pre.textContent;
|
|
1051
|
+
navigator.clipboard.writeText(text).then(() => {{
|
|
1052
|
+
const orig = btn.textContent;
|
|
1053
|
+
btn.textContent = 'Copied!';
|
|
1054
|
+
setTimeout(() => btn.textContent = orig, 1500);
|
|
1055
|
+
}});
|
|
1056
|
+
}}
|
|
1057
|
+
|
|
1058
|
+
async function tryEndpoint(method, path, body, responseId, requiresAuth) {{
|
|
1059
|
+
const responseArea = document.getElementById(responseId);
|
|
1060
|
+
responseArea.classList.add('visible');
|
|
1061
|
+
responseArea.innerHTML = '<div class="response-status">Loading...</div>';
|
|
1062
|
+
|
|
1063
|
+
const startTime = performance.now();
|
|
1064
|
+
try {{
|
|
1065
|
+
const options = {{
|
|
1066
|
+
method: method,
|
|
1067
|
+
headers: {{}}
|
|
1068
|
+
}};
|
|
1069
|
+
if (body) {{
|
|
1070
|
+
options.headers['Content-Type'] = 'application/json';
|
|
1071
|
+
options.body = JSON.stringify(body);
|
|
1072
|
+
}}
|
|
1073
|
+
if (requiresAuth && authCreds) {{
|
|
1074
|
+
options.headers['Authorization'] = 'Basic ' + btoa(authCreds.user + ':' + authCreds.password);
|
|
1075
|
+
}}
|
|
1076
|
+
|
|
1077
|
+
const resp = await fetch(path, options);
|
|
1078
|
+
const endTime = performance.now();
|
|
1079
|
+
const duration = Math.round(endTime - startTime);
|
|
1080
|
+
|
|
1081
|
+
let data;
|
|
1082
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
1083
|
+
if (contentType.includes('json')) {{
|
|
1084
|
+
data = await resp.json();
|
|
1085
|
+
data = JSON.stringify(data, null, 2);
|
|
1086
|
+
}} else {{
|
|
1087
|
+
data = await resp.text();
|
|
1088
|
+
}}
|
|
1089
|
+
|
|
1090
|
+
const statusClass = resp.ok ? 'success' : 'error';
|
|
1091
|
+
responseArea.innerHTML = `
|
|
1092
|
+
<div class="response-header">
|
|
1093
|
+
<span class="response-status ${{statusClass}}">${{resp.status}} ${{resp.statusText}}</span>
|
|
1094
|
+
<span class="response-time">${{duration}}ms</span>
|
|
1095
|
+
</div>
|
|
1096
|
+
<div class="response-body">${{escapeHtml(data)}}</div>
|
|
1097
|
+
`;
|
|
1098
|
+
}} catch (err) {{
|
|
1099
|
+
responseArea.innerHTML = `
|
|
1100
|
+
<div class="response-header">
|
|
1101
|
+
<span class="response-status error">Error</span>
|
|
1102
|
+
</div>
|
|
1103
|
+
<div class="response-body">${{escapeHtml(err.message)}}</div>
|
|
1104
|
+
`;
|
|
1105
|
+
}}
|
|
1106
|
+
}}
|
|
1107
|
+
|
|
1108
|
+
function escapeHtml(text) {{
|
|
1109
|
+
const div = document.createElement('div');
|
|
1110
|
+
div.textContent = text;
|
|
1111
|
+
return div.innerHTML;
|
|
1112
|
+
}}
|
|
1113
|
+
|
|
1114
|
+
// WebRTC calling
|
|
1115
|
+
let client = null;
|
|
1116
|
+
let roomSession = null;
|
|
1117
|
+
|
|
1118
|
+
const connectBtn = document.getElementById('connectBtn');
|
|
1119
|
+
const disconnectBtn = document.getElementById('disconnectBtn');
|
|
1120
|
+
const destinationInput = document.getElementById('destination');
|
|
1121
|
+
const callStatus = document.getElementById('callStatus');
|
|
1122
|
+
|
|
1123
|
+
function updateCallStatus(message) {{
|
|
1124
|
+
callStatus.textContent = message;
|
|
1125
|
+
}}
|
|
1126
|
+
|
|
1127
|
+
async function connect() {{
|
|
1128
|
+
try {{
|
|
1129
|
+
connectBtn.disabled = true;
|
|
1130
|
+
updateCallStatus('Getting token...');
|
|
1131
|
+
|
|
1132
|
+
const tokenResp = await fetch('/get_token');
|
|
1133
|
+
const tokenData = await tokenResp.json();
|
|
1134
|
+
|
|
1135
|
+
if (tokenData.error) {{
|
|
1136
|
+
throw new Error(tokenData.error);
|
|
1137
|
+
}}
|
|
1138
|
+
|
|
1139
|
+
if (tokenData.address) {{
|
|
1140
|
+
destinationInput.value = tokenData.address;
|
|
1141
|
+
}}
|
|
1142
|
+
|
|
1143
|
+
updateCallStatus('Connecting...');
|
|
1144
|
+
|
|
1145
|
+
client = await window.SignalWire.SignalWire({{
|
|
1146
|
+
token: tokenData.token
|
|
1147
|
+
}});
|
|
1148
|
+
|
|
1149
|
+
const destination = tokenData.address || destinationInput.value;
|
|
1150
|
+
roomSession = await client.dial({{
|
|
1151
|
+
to: destination,
|
|
1152
|
+
audio: {{
|
|
1153
|
+
echoCancellation: document.getElementById('echoCancellation').checked,
|
|
1154
|
+
noiseSuppression: document.getElementById('noiseSuppression').checked,
|
|
1155
|
+
autoGainControl: document.getElementById('autoGainControl').checked
|
|
1156
|
+
}},
|
|
1157
|
+
video: false
|
|
1158
|
+
}});
|
|
1159
|
+
|
|
1160
|
+
roomSession.on('call.joined', () => {{
|
|
1161
|
+
updateCallStatus('Connected');
|
|
1162
|
+
disconnectBtn.disabled = false;
|
|
1163
|
+
}});
|
|
1164
|
+
|
|
1165
|
+
roomSession.on('call.left', () => {{
|
|
1166
|
+
updateCallStatus('Call ended');
|
|
1167
|
+
cleanup();
|
|
1168
|
+
}});
|
|
1169
|
+
|
|
1170
|
+
roomSession.on('destroy', () => {{
|
|
1171
|
+
updateCallStatus('Call ended');
|
|
1172
|
+
cleanup();
|
|
1173
|
+
}});
|
|
1174
|
+
|
|
1175
|
+
await roomSession.start();
|
|
1176
|
+
|
|
1177
|
+
}} catch (err) {{
|
|
1178
|
+
console.error('Connection error:', err);
|
|
1179
|
+
updateCallStatus('Error: ' + err.message);
|
|
1180
|
+
cleanup();
|
|
1181
|
+
}}
|
|
1182
|
+
}}
|
|
1183
|
+
|
|
1184
|
+
async function disconnect() {{
|
|
1185
|
+
try {{
|
|
1186
|
+
if (roomSession) {{
|
|
1187
|
+
await roomSession.hangup();
|
|
1188
|
+
}}
|
|
1189
|
+
}} catch (err) {{
|
|
1190
|
+
console.error('Disconnect error:', err);
|
|
1191
|
+
}}
|
|
1192
|
+
cleanup();
|
|
1193
|
+
}}
|
|
1194
|
+
|
|
1195
|
+
function cleanup() {{
|
|
1196
|
+
connectBtn.disabled = false;
|
|
1197
|
+
disconnectBtn.disabled = true;
|
|
1198
|
+
roomSession = null;
|
|
1199
|
+
client = null;
|
|
1200
|
+
}}
|
|
1201
|
+
|
|
1202
|
+
connectBtn.addEventListener('click', connect);
|
|
1203
|
+
disconnectBtn.addEventListener('click', disconnect);
|
|
1204
|
+
|
|
1205
|
+
// Initialize on load
|
|
1206
|
+
document.addEventListener('DOMContentLoaded', async function() {{
|
|
1207
|
+
const baseUrl = window.location.origin;
|
|
1208
|
+
document.querySelectorAll('.base-url').forEach(function(el) {{
|
|
1209
|
+
el.textContent = baseUrl;
|
|
1210
|
+
}});
|
|
1211
|
+
|
|
1212
|
+
// Fetch auth credentials
|
|
1213
|
+
try {{
|
|
1214
|
+
const credsResp = await fetch('/get_credentials');
|
|
1215
|
+
if (credsResp.ok) {{
|
|
1216
|
+
authCreds = await credsResp.json();
|
|
1217
|
+
document.querySelectorAll('.auth-creds').forEach(function(el) {{
|
|
1218
|
+
el.textContent = authCreds.user + ':' + authCreds.password;
|
|
1219
|
+
}});
|
|
1220
|
+
}}
|
|
1221
|
+
}} catch (e) {{
|
|
1222
|
+
document.querySelectorAll('.auth-creds').forEach(function(el) {{
|
|
1223
|
+
el.textContent = 'user:password';
|
|
1224
|
+
}});
|
|
1225
|
+
}}
|
|
1226
|
+
|
|
1227
|
+
// Fetch resource info for dashboard link
|
|
1228
|
+
try {{
|
|
1229
|
+
const resourceResp = await fetch('/get_resource_info');
|
|
1230
|
+
if (resourceResp.ok) {{
|
|
1231
|
+
const resourceInfo = await resourceResp.json();
|
|
1232
|
+
if (resourceInfo.dashboard_url) {{
|
|
1233
|
+
document.getElementById('dashboardLink').href = resourceInfo.dashboard_url;
|
|
1234
|
+
document.getElementById('phoneInfo').classList.remove('hidden');
|
|
1235
|
+
}}
|
|
1236
|
+
}}
|
|
1237
|
+
}} catch (e) {{
|
|
1238
|
+
console.log('Could not fetch resource info:', e);
|
|
1239
|
+
}}
|
|
1240
|
+
}});
|
|
1241
|
+
</script>
|
|
530
1242
|
</body>
|
|
531
1243
|
</html>
|
|
532
1244
|
'''
|
|
533
1245
|
|
|
534
1246
|
APP_JSON_TEMPLATE = '''{{
|
|
535
1247
|
"name": "{app_name}",
|
|
536
|
-
"description": "SignalWire AI Agent",
|
|
537
|
-
"keywords": ["signalwire", "ai", "agent", "python"],
|
|
1248
|
+
"description": "SignalWire AI Agent with WebRTC calling support",
|
|
1249
|
+
"keywords": ["signalwire", "ai", "agent", "python", "webrtc"],
|
|
538
1250
|
"env": {{
|
|
539
1251
|
"APP_ENV": {{
|
|
540
1252
|
"description": "Application environment",
|
|
541
1253
|
"value": "production"
|
|
542
1254
|
}},
|
|
1255
|
+
"AGENT_NAME": {{
|
|
1256
|
+
"description": "Name for the SWML handler resource",
|
|
1257
|
+
"value": "{app_name}"
|
|
1258
|
+
}},
|
|
1259
|
+
"SIGNALWIRE_SPACE_NAME": {{
|
|
1260
|
+
"description": "SignalWire space name (e.g., 'myspace' or 'myspace.signalwire.com')",
|
|
1261
|
+
"required": true
|
|
1262
|
+
}},
|
|
1263
|
+
"SIGNALWIRE_PROJECT_ID": {{
|
|
1264
|
+
"description": "SignalWire project ID",
|
|
1265
|
+
"required": true
|
|
1266
|
+
}},
|
|
1267
|
+
"SIGNALWIRE_TOKEN": {{
|
|
1268
|
+
"description": "SignalWire API token",
|
|
1269
|
+
"required": true
|
|
1270
|
+
}},
|
|
1271
|
+
"SWML_PROXY_URL_BASE": {{
|
|
1272
|
+
"description": "Public URL base for SWML callbacks (e.g., 'https://myapp.example.com')",
|
|
1273
|
+
"required": true
|
|
1274
|
+
}},
|
|
543
1275
|
"SWML_BASIC_AUTH_USER": {{
|
|
544
1276
|
"description": "Basic auth username for SWML endpoints",
|
|
545
1277
|
"required": true
|
|
@@ -769,7 +1501,7 @@ jobs:
|
|
|
769
1501
|
APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
|
|
770
1502
|
ssh dokku apps:unlock $APP_NAME 2>/dev/null || true
|
|
771
1503
|
|
|
772
|
-
- name: Configure
|
|
1504
|
+
- name: Configure env
|
|
773
1505
|
run: |
|
|
774
1506
|
APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
|
|
775
1507
|
DOMAIN="${{APP_NAME}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
@@ -778,8 +1510,6 @@ jobs:
|
|
|
778
1510
|
APP_URL="https://${{DOMAIN}}" \\
|
|
779
1511
|
SWML_BASIC_AUTH_USER="${{{{ secrets.SWML_BASIC_AUTH_USER }}}}" \\
|
|
780
1512
|
SWML_BASIC_AUTH_PASSWORD="${{{{ secrets.SWML_BASIC_AUTH_PASSWORD }}}}"
|
|
781
|
-
ssh dokku domains:clear $APP_NAME 2>/dev/null || true
|
|
782
|
-
ssh dokku domains:add $APP_NAME $DOMAIN
|
|
783
1513
|
|
|
784
1514
|
- name: Deploy
|
|
785
1515
|
run: |
|
|
@@ -787,6 +1517,13 @@ jobs:
|
|
|
787
1517
|
git remote add dokku dokku@${{{{ secrets.DOKKU_HOST }}}}:$APP_NAME 2>/dev/null || true
|
|
788
1518
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/key" git push dokku HEAD:main -f
|
|
789
1519
|
|
|
1520
|
+
- name: Configure domain
|
|
1521
|
+
run: |
|
|
1522
|
+
APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
|
|
1523
|
+
DOMAIN="${{APP_NAME}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
1524
|
+
ssh dokku domains:clear $APP_NAME 2>/dev/null || true
|
|
1525
|
+
ssh dokku domains:add $APP_NAME $DOMAIN
|
|
1526
|
+
|
|
790
1527
|
- name: SSL
|
|
791
1528
|
run: |
|
|
792
1529
|
APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
|
|
@@ -801,11 +1538,53 @@ jobs:
|
|
|
801
1538
|
fi
|
|
802
1539
|
|
|
803
1540
|
- name: Verify
|
|
1541
|
+
id: verify
|
|
804
1542
|
run: |
|
|
805
1543
|
APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
|
|
806
1544
|
DOMAIN="${{APP_NAME}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
807
1545
|
sleep 10
|
|
808
|
-
curl -sf "https://${{DOMAIN}}/health"
|
|
1546
|
+
if curl -sf "https://${{DOMAIN}}/health"; then
|
|
1547
|
+
echo "HTTPS OK: https://${{DOMAIN}}"
|
|
1548
|
+
echo "status=success" >> $GITHUB_OUTPUT
|
|
1549
|
+
echo "url=https://${{DOMAIN}}" >> $GITHUB_OUTPUT
|
|
1550
|
+
elif curl -sf "http://${{DOMAIN}}/health"; then
|
|
1551
|
+
echo "HTTP only: http://${{DOMAIN}}"
|
|
1552
|
+
echo "status=success" >> $GITHUB_OUTPUT
|
|
1553
|
+
echo "url=http://${{DOMAIN}}" >> $GITHUB_OUTPUT
|
|
1554
|
+
else
|
|
1555
|
+
echo "Check logs"
|
|
1556
|
+
echo "status=failed" >> $GITHUB_OUTPUT
|
|
1557
|
+
fi
|
|
1558
|
+
|
|
1559
|
+
- name: Notify Slack
|
|
1560
|
+
if: always()
|
|
1561
|
+
env:
|
|
1562
|
+
SLACK_WEBHOOK_URL: ${{{{ secrets.SLACK_WEBHOOK_URL }}}}
|
|
1563
|
+
run: |
|
|
1564
|
+
[ -z "$SLACK_WEBHOOK_URL" ] && exit 0
|
|
1565
|
+
APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
|
|
1566
|
+
DOMAIN="${{APP_NAME}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
1567
|
+
if [ "${{{{ steps.verify.outputs.status }}}}" == "success" ]; then
|
|
1568
|
+
COLOR="good"
|
|
1569
|
+
STATUS="✅ Deployed"
|
|
1570
|
+
else
|
|
1571
|
+
COLOR="danger"
|
|
1572
|
+
STATUS="❌ Deploy failed"
|
|
1573
|
+
fi
|
|
1574
|
+
curl -X POST -H 'Content-type: application/json' \\
|
|
1575
|
+
--data "{{
|
|
1576
|
+
\\"attachments\\": [{{
|
|
1577
|
+
\\"color\\": \\"$COLOR\\",
|
|
1578
|
+
\\"title\\": \\"$STATUS: $APP_NAME\\",
|
|
1579
|
+
\\"fields\\": [
|
|
1580
|
+
{{\\"title\\": \\"Environment\\", \\"value\\": \\"${{{{ steps.vars.outputs.environment }}}}\\", \\"short\\": true}},
|
|
1581
|
+
{{\\"title\\": \\"Branch\\", \\"value\\": \\"${{{{ github.ref_name }}}}\\", \\"short\\": true}},
|
|
1582
|
+
{{\\"title\\": \\"URL\\", \\"value\\": \\"https://$DOMAIN\\", \\"short\\": false}}
|
|
1583
|
+
],
|
|
1584
|
+
\\"footer\\": \\"<${{{{ github.server_url }}}}/${{{{ github.repository }}}}/actions/runs/${{{{ github.run_id }}}}|View Workflow>\\"
|
|
1585
|
+
}}]
|
|
1586
|
+
}}" \\
|
|
1587
|
+
"$SLACK_WEBHOOK_URL" || true
|
|
809
1588
|
'''
|
|
810
1589
|
|
|
811
1590
|
PREVIEW_WORKFLOW_TEMPLATE = '''# Preview environments for pull requests
|
|
@@ -837,17 +1616,32 @@ jobs:
|
|
|
837
1616
|
echo "${{{{ secrets.DOKKU_SSH_PRIVATE_KEY }}}}" > ~/.ssh/key && chmod 600 ~/.ssh/key
|
|
838
1617
|
ssh-keyscan -H ${{{{ secrets.DOKKU_HOST }}}} >> ~/.ssh/known_hosts
|
|
839
1618
|
|
|
840
|
-
- name:
|
|
1619
|
+
- name: Create app
|
|
841
1620
|
run: |
|
|
842
|
-
DOMAIN="${{{{ env.APP_NAME }}}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
843
1621
|
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} apps:exists $APP_NAME 2>/dev/null || \\
|
|
844
1622
|
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} apps:create $APP_NAME
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1623
|
+
|
|
1624
|
+
- name: Configure env
|
|
1625
|
+
run: |
|
|
1626
|
+
DOMAIN="${{{{ env.APP_NAME }}}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
1627
|
+
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} config:set --no-restart $APP_NAME \\
|
|
1628
|
+
APP_ENV=preview \\
|
|
1629
|
+
APP_URL="https://$DOMAIN"
|
|
848
1630
|
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} resource:limit $APP_NAME --memory 256m || true
|
|
1631
|
+
|
|
1632
|
+
- name: Deploy
|
|
1633
|
+
run: |
|
|
849
1634
|
git remote add dokku dokku@${{{{ secrets.DOKKU_HOST }}}}:$APP_NAME 2>/dev/null || true
|
|
850
1635
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/key" git push dokku HEAD:main -f
|
|
1636
|
+
|
|
1637
|
+
- name: Configure domain
|
|
1638
|
+
run: |
|
|
1639
|
+
DOMAIN="${{{{ env.APP_NAME }}}}.${{{{ secrets.BASE_DOMAIN }}}}"
|
|
1640
|
+
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} domains:clear $APP_NAME 2>/dev/null || true
|
|
1641
|
+
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} domains:add $APP_NAME $DOMAIN
|
|
1642
|
+
|
|
1643
|
+
- name: SSL
|
|
1644
|
+
run: |
|
|
851
1645
|
ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} letsencrypt:enable $APP_NAME || true
|
|
852
1646
|
|
|
853
1647
|
- name: Comment URL
|