signalwire-agents 1.0.13__py3-none-any.whl → 1.0.15__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.
@@ -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
- # Create server and register agent
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 (no auth required)
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
- # Register catch-all for static files (needed for gunicorn since _run_server() isn't called)
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
- # Redirect /swml to /swml/ (trailing slash required by FastAPI router)
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
- :root {{
368
- --primary: #2563eb;
369
- --primary-dark: #1d4ed8;
370
- --bg: #f8fafc;
371
- --card: #ffffff;
372
- --text: #1e293b;
373
- --text-muted: #64748b;
374
- --border: #e2e8f0;
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
- box-sizing: border-box;
378
- margin: 0;
379
- padding: 0;
606
+ h1 {{
607
+ color: #044cf6;
608
+ border-bottom: 3px solid #044cf6;
609
+ padding-bottom: 10px;
380
610
  }}
381
- body {{
382
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
383
- background: var(--bg);
384
- color: var(--text);
385
- min-height: 100vh;
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
- .container {{
392
- max-width: 600px;
393
- width: 100%;
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
- .card {{
396
- background: var(--card);
397
- border-radius: 1rem;
398
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
399
- padding: 2rem;
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
- .logo {{
403
- width: 80px;
404
- height: 80px;
405
- background: var(--primary);
406
- border-radius: 1rem;
633
+ .call-section h2 {{
634
+ color: white;
635
+ border: none;
636
+ margin-top: 0;
637
+ }}
638
+ .call-controls {{
407
639
  display: flex;
408
- align-items: center;
640
+ gap: 15px;
409
641
  justify-content: center;
410
- margin: 0 auto 1.5rem;
642
+ align-items: center;
643
+ flex-wrap: wrap;
411
644
  }}
412
- .logo svg {{
413
- width: 48px;
414
- height: 48px;
415
- fill: white;
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
- h1 {{
418
- font-size: 1.75rem;
419
- margin-bottom: 0.5rem;
654
+ .call-btn:disabled {{
655
+ opacity: 0.5;
656
+ cursor: not-allowed;
420
657
  }}
421
- .subtitle {{
422
- color: var(--text-muted);
423
- margin-bottom: 2rem;
658
+ .call-btn.connect {{
659
+ background: #10b981;
660
+ color: white;
424
661
  }}
425
- .status {{
426
- display: inline-flex;
427
- align-items: center;
428
- gap: 0.5rem;
429
- padding: 0.5rem 1rem;
430
- background: #dcfce7;
431
- color: #166534;
432
- border-radius: 2rem;
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
- .status::before {{
437
- content: '';
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
- .endpoints {{
444
- margin-top: 2rem;
445
- text-align: left;
446
- border-top: 1px solid var(--border);
447
- padding-top: 1.5rem;
674
+ .call-status {{
675
+ margin-top: 15px;
676
+ font-size: 14px;
677
+ opacity: 0.9;
448
678
  }}
449
- .endpoints h2 {{
450
- font-size: 0.875rem;
451
- text-transform: uppercase;
452
- letter-spacing: 0.05em;
453
- color: var(--text-muted);
454
- margin-bottom: 1rem;
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
- align-items: center;
459
- padding: 0.75rem 0;
460
- border-bottom: 1px solid var(--border);
744
+ gap: 0;
745
+ margin-top: 15px;
461
746
  }}
462
- .endpoint:last-child {{
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
- .method {{
466
- font-size: 0.75rem;
467
- font-weight: 600;
468
- padding: 0.25rem 0.5rem;
469
- border-radius: 0.25rem;
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
- .method.get {{
475
- background: #dbeafe;
476
- color: #1d4ed8;
765
+ .tab-content {{
766
+ display: none;
767
+ border-radius: 0 8px 8px 8px;
477
768
  }}
478
- .method.post {{
479
- background: #dcfce7;
480
- color: #166534;
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
- .path {{
483
- font-family: monospace;
484
- color: var(--text);
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
- .desc {{
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
- color: var(--text-muted);
489
- font-size: 0.875rem;
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
- <div class="container">
495
- <div class="card">
496
- <div class="logo">
497
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
498
- <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
499
- </svg>
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
- <h1>{agent_name}</h1>
502
- <p class="subtitle">SignalWire AI Agent</p>
503
- <span class="status">Running on Dokku</span>
504
-
505
- <div class="endpoints">
506
- <h2>API Endpoints</h2>
507
- <div class="endpoint">
508
- <span class="method get">GET</span>
509
- <span class="path">/health</span>
510
- <span class="desc">Health check</span>
511
- </div>
512
- <div class="endpoint">
513
- <span class="method get">GET</span>
514
- <span class="path">/ready</span>
515
- <span class="desc">Readiness check</span>
516
- </div>
517
- <div class="endpoint">
518
- <span class="method post">POST</span>
519
- <span class="path">/swml</span>
520
- <span class="desc">SWML endpoint</span>
521
- </div>
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" && echo "HTTPS OK: https://${{DOMAIN}}" || curl -sf "http://${{DOMAIN}}/health" && echo "HTTP only: http://${{DOMAIN}}" || echo "Check logs"
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: Deploy preview
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
- ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} config:set --no-restart $APP_NAME APP_ENV=preview APP_URL="https://$DOMAIN"
846
- ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} domains:clear $APP_NAME 2>/dev/null || true
847
- ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} domains:add $APP_NAME $DOMAIN
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