diff --git a/TODO.md b/TODO.md index 246b3961b..9c9147db0 100644 --- a/TODO.md +++ b/TODO.md @@ -2,4 +2,3 @@ - [ ] Report buttons not working - [ ] Text not getting localized across several pages -- [ ] Automerge renovates \ No newline at end of file diff --git a/src/app/Http/Controllers/Api/V1/WebRtcController.php b/src/app/Http/Controllers/Api/V1/WebRtcController.php new file mode 100644 index 000000000..65de87f85 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/WebRtcController.php @@ -0,0 +1,167 @@ +settings = $settings; + } + + /** + * Generate a Twilio Access Token for WebRTC calling + * + * @OA\Get( + * path="/api/v1/webrtc/token", + * tags={"WebRTC"}, + * summary="Generate a Twilio access token for WebRTC calling", + * description="Returns a JWT token that can be used to initialize a Twilio Voice SDK client for browser-based calling", + * @OA\Response( + * response=200, + * description="Token generated successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="token", type="string", description="JWT access token for Twilio Voice SDK"), + * @OA\Property(property="identity", type="string", description="Unique identity for this caller"), + * @OA\Property(property="expires_in", type="integer", description="Token validity in seconds", example=3600) + * ) + * ), + * @OA\Response( + * response=403, + * description="WebRTC calling is not enabled", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="error", type="string", example="WebRTC calling is not enabled") + * ) + * ), + * @OA\Response( + * response=500, + * description="WebRTC is not properly configured or token generation failed", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="error", type="string", example="WebRTC is not properly configured") + * ) + * ) + * ) + * + * @param Request $request + * @return JsonResponse + */ + public function token(Request $request): JsonResponse + { + // Check if WebRTC is enabled + if (!$this->settings->has('webrtc_enabled') || !$this->settings->get('webrtc_enabled')) { + return response()->json([ + 'error' => 'WebRTC calling is not enabled' + ], 403); + } + + // Validate required Twilio settings + $accountSid = $this->settings->get('twilio_account_sid'); + $apiKey = $this->settings->get('twilio_api_key'); + $apiSecret = $this->settings->get('twilio_api_secret'); + $twimlAppSid = $this->settings->get('twilio_twiml_app_sid'); + + if (empty($accountSid) || empty($apiKey) || empty($apiSecret) || empty($twimlAppSid)) { + Log::error('WebRTC token generation failed: Missing Twilio configuration'); + return response()->json([ + 'error' => 'WebRTC is not properly configured' + ], 500); + } + + try { + // Generate a unique identity for this caller + $identity = 'webrtc_' . uniqid(); + + // Create access token + $token = new AccessToken( + $accountSid, + $apiKey, + $apiSecret, + 3600, // Token valid for 1 hour + $identity + ); + + // Create Voice grant + $voiceGrant = new VoiceGrant(); + $voiceGrant->setOutgoingApplicationSid($twimlAppSid); + + // Add grant to token + $token->addGrant($voiceGrant); + + return response()->json([ + 'token' => $token->toJWT(), + 'identity' => $identity, + 'expires_in' => 3600 + ]); + } catch (\Exception $e) { + Log::error('WebRTC token generation failed: ' . $e->getMessage()); + return response()->json([ + 'error' => 'Failed to generate access token' + ], 500); + } + } + + /** + * Get WebRTC widget configuration + * + * @OA\Get( + * path="/api/v1/webrtc/config", + * tags={"WebRTC"}, + * summary="Get WebRTC widget configuration", + * description="Returns configuration settings for the WebRTC dial widget", + * @OA\Response( + * response=200, + * description="Configuration retrieved successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="enabled", type="boolean", description="Whether WebRTC is enabled", example=true), + * @OA\Property(property="title", type="string", description="Helpline title", example="Helpline"), + * @OA\Property(property="language", type="string", description="Default language", example="en-US") + * ) + * ), + * @OA\Response( + * response=403, + * description="WebRTC calling is not enabled", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="error", type="string", example="WebRTC calling is not enabled") + * ) + * ) + * ) + * + * @param Request $request + * @return JsonResponse + */ + public function config(Request $request): JsonResponse + { + if (!$this->settings->has('webrtc_enabled') || !$this->settings->get('webrtc_enabled')) { + return response()->json([ + 'error' => 'WebRTC calling is not enabled' + ], 403); + } + + return response()->json([ + 'enabled' => true, + 'title' => $this->settings->get('title') ?: 'Helpline', + 'language' => $this->settings->get('language') ?: 'en-US', + ]); + } +} diff --git a/src/app/Http/Controllers/CallFlowController.php b/src/app/Http/Controllers/CallFlowController.php index b0918f911..98364aa58 100644 --- a/src/app/Http/Controllers/CallFlowController.php +++ b/src/app/Http/Controllers/CallFlowController.php @@ -60,20 +60,25 @@ public function __construct( public function index(Request $request) { - if ($request->has('CallSid')) { + // Check for misconfigured phone numbers, but skip for WebRTC calls + // WebRTC calls have no phoneNumberSid since they originate from a browser client + $isWebRtcCall = str_starts_with($request->get('From', ''), 'client:'); + if ($request->has('CallSid') && !$isWebRtcCall) { $phoneNumberSid = $this->twilio->client()->calls($request->get('CallSid'))->fetch()->phoneNumberSid; - $incomingPhoneNumber = $this->twilio->client()->incomingPhoneNumbers($phoneNumberSid)->fetch(); - - Log::debug(sprintf( - "Alert debugging:: phoneNumberSid:%s, incomingPhoneNumber:%s, statusCallback:%s", - $phoneNumberSid, - $incomingPhoneNumber, - $incomingPhoneNumber->statusCallback - )); - - if ($incomingPhoneNumber->statusCallback == null - || !str_contains($incomingPhoneNumber->statusCallback, "status.php")) { - $this->call->createMisconfiguredPhoneNumberAlert($incomingPhoneNumber->phoneNumber); + if ($phoneNumberSid) { + $incomingPhoneNumber = $this->twilio->client()->incomingPhoneNumbers($phoneNumberSid)->fetch(); + + Log::debug(sprintf( + "Alert debugging:: phoneNumberSid:%s, incomingPhoneNumber:%s, statusCallback:%s", + $phoneNumberSid, + $incomingPhoneNumber, + $incomingPhoneNumber->statusCallback + )); + + if ($incomingPhoneNumber->statusCallback == null + || !str_contains($incomingPhoneNumber->statusCallback, "status.php")) { + $this->call->createMisconfiguredPhoneNumberAlert($incomingPhoneNumber->phoneNumber); + } } } diff --git a/src/app/Http/Controllers/WebRtcCallController.php b/src/app/Http/Controllers/WebRtcCallController.php new file mode 100644 index 000000000..44e88c35b --- /dev/null +++ b/src/app/Http/Controllers/WebRtcCallController.php @@ -0,0 +1,143 @@ +settings = $settings; + $this->call = $call; + $this->config = $config; + } + + /** + * Handle incoming WebRTC call from browser + * This is the TwiML Application webhook endpoint + * + * @param Request $request + * @return \Illuminate\Http\Response + */ + public function handleCall(Request $request) + { + Log::info('WebRTC call received', [ + 'from' => $request->input('From'), + 'callSid' => $request->input('CallSid'), + 'serviceBodyId' => $request->input('serviceBodyId'), + 'searchType' => $request->input('searchType'), + 'location' => $request->input('location'), + ]); + + $twiml = new VoiceResponse(); + + // Check if WebRTC is enabled + if (!$this->settings->has('webrtc_enabled') || !$this->settings->get('webrtc_enabled')) { + $twiml->say('WebRTC calling is not enabled. Goodbye.') + ->setVoice($this->settings->voice()) + ->setLanguage($this->settings->get('language')); + $twiml->hangup(); + return response($twiml)->header('Content-Type', 'text/xml; charset=utf-8'); + } + + // Get parameters from the browser + $serviceBodyId = $request->input('serviceBodyId'); + $searchType = $request->input('searchType', 'helpline'); + $location = $request->input('location'); + + // Build redirect URL based on search type + $baseUrl = url('/'); + $params = []; + + // Mark this as a WebRTC call + $params['webrtc'] = '1'; + + // If service body is specified, use it + if ($serviceBodyId) { + $params['override_service_body_id'] = $serviceBodyId; + } + + // Handle different search types + switch ($searchType) { + case 'helpline': + // Direct to helpline search with location if provided + if ($location) { + // Store location in session and redirect to address lookup + session()->put('webrtc_location', $location); + $redirectUrl = $baseUrl . '/address-lookup?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } else { + // Go to normal IVR flow + $redirectUrl = $baseUrl . '/?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } + break; + + case 'meeting': + // Direct to meeting search + if ($location) { + session()->put('webrtc_location', $location); + $params['SearchType'] = '2'; // Meeting search + $redirectUrl = $baseUrl . '/address-lookup?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } else { + $params['SearchType'] = '2'; + $redirectUrl = $baseUrl . '/?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } + break; + + case 'jft': + // Direct to Just For Today + $redirectUrl = $baseUrl . '/fetch-jft?' . http_build_query($params); + $twiml->redirect($redirectUrl); + break; + + case 'spad': + // Direct to SPAD + $redirectUrl = $baseUrl . '/fetch-spad?' . http_build_query($params); + $twiml->redirect($redirectUrl); + break; + + default: + // Default: go to main IVR + $redirectUrl = $baseUrl . '/?' . http_build_query($params); + $twiml->redirect($redirectUrl); + break; + } + + return response($twiml)->header('Content-Type', 'text/xml; charset=utf-8'); + } + + /** + * Handle WebRTC call status callbacks + * + * @param Request $request + * @return \Illuminate\Http\Response + */ + public function statusCallback(Request $request) + { + Log::info('WebRTC call status callback', [ + 'callSid' => $request->input('CallSid'), + 'callStatus' => $request->input('CallStatus'), + ]); + + // Return empty TwiML response + $twiml = new VoiceResponse(); + return response($twiml)->header('Content-Type', 'text/xml; charset=utf-8'); + } +} diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php index f79ada31e..3722daa46 100644 --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -14,6 +14,7 @@ use App\Http\Middleware\SmsBlackhole; use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrustProxies; +use App\Http\Middleware\ValidateTwilioSignature; use App\Http\Middleware\VerifyCsrfToken; use Illuminate\Auth\Middleware\Authorize; use Illuminate\Auth\Middleware\EnsureEmailIsVerified; @@ -99,5 +100,6 @@ class Kernel extends HttpKernel 'signed' => ValidateSignature::class, 'throttle' => ThrottleRequests::class, 'verified' => EnsureEmailIsVerified::class, + 'twilio.signature' => ValidateTwilioSignature::class, ]; } diff --git a/src/app/Http/Middleware/TrustProxies.php b/src/app/Http/Middleware/TrustProxies.php index 0c7d3b6bb..328934207 100644 --- a/src/app/Http/Middleware/TrustProxies.php +++ b/src/app/Http/Middleware/TrustProxies.php @@ -10,9 +10,12 @@ class TrustProxies extends Middleware /** * The trusted proxies for this application. * + * Set to '*' to trust all proxies (needed for ngrok, load balancers, etc.) + * This is safe because we validate Twilio signatures on webhook endpoints. + * * @var array|string|null */ - protected $proxies; + protected $proxies = '*'; /** * The headers that should be used to detect proxies. diff --git a/src/app/Http/Middleware/ValidateTwilioSignature.php b/src/app/Http/Middleware/ValidateTwilioSignature.php new file mode 100644 index 000000000..e27bc2095 --- /dev/null +++ b/src/app/Http/Middleware/ValidateTwilioSignature.php @@ -0,0 +1,106 @@ +settings = $settings; + } + + /** + * Handle an incoming request. + * + * Validates that the request is actually from Twilio by checking + * the X-Twilio-Signature header against the request body. + * + * @param Request $request + * @param Closure $next + * @return mixed + */ + public function handle(Request $request, Closure $next): mixed + { + $authToken = $this->settings->get('twilio_auth_token'); + + // If no auth token configured, skip validation (for development) + if (empty($authToken)) { + Log::warning('Twilio signature validation skipped: no auth token configured'); + return $next($request); + } + + $validator = new RequestValidator($authToken); + $signature = $request->header('X-Twilio-Signature', ''); + + // Build the URL that Twilio used to sign the request + // When behind a proxy (ngrok, load balancer), we need to use the original URL + $url = $this->getSignatureUrl($request); + + // For POST requests, Twilio signs using POST body params only (not query params) + // $request->all() merges both, so we need to use post() for POST requests + $params = $request->isMethod('POST') ? $request->post() : []; + + Log::debug('Twilio signature validation attempt', [ + 'url' => $url, + 'method' => $request->method(), + 'params_count' => count($params), + 'has_signature' => !empty($signature), + ]); + + if (!$validator->validate($signature, $url, $params)) { + Log::warning('Invalid Twilio signature rejected', [ + 'url' => $url, + 'ip' => $request->ip(), + ]); + return response('Forbidden', 403); + } + + return $next($request); + } + + /** + * Get the URL that Twilio used to sign the request. + * + * When behind a reverse proxy (ngrok, load balancer, etc.), the URL + * Laravel sees may differ from what Twilio sent the request to. + * We reconstruct the original URL from forwarded headers. + * + * @param Request $request + * @return string + */ + protected function getSignatureUrl(Request $request): string + { + // Check for X-Original-Host (set by some proxies) or X-Forwarded-Host + $host = $request->header('X-Original-Host') + ?? $request->header('X-Forwarded-Host') + ?? $request->getHost(); + + // Check for X-Forwarded-Proto for the scheme + $scheme = $request->header('X-Forwarded-Proto') ?? $request->getScheme(); + + // Use the raw REQUEST_URI to preserve exact encoding as Twilio sent it + // Laravel's getRequestUri() may re-encode the query string differently + $requestUri = $_SERVER['REQUEST_URI'] ?? $request->getRequestUri(); + + $url = $scheme . '://' . $host . $requestUri; + + Log::debug('Twilio signature validation URL constructed', [ + 'constructed_url' => $url, + 'scheme' => $scheme, + 'host' => $host, + 'request_uri' => $requestUri, + 'x_forwarded_host' => $request->header('X-Forwarded-Host'), + 'x_forwarded_proto' => $request->header('X-Forwarded-Proto'), + ]); + + return $url; + } +} diff --git a/src/app/Providers/RouteServiceProvider.php b/src/app/Providers/RouteServiceProvider.php index 19664568b..171220dc2 100644 --- a/src/app/Providers/RouteServiceProvider.php +++ b/src/app/Providers/RouteServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; +use App\Services\SettingsService; class RouteServiceProvider extends ServiceProvider { @@ -59,5 +60,17 @@ protected function configureRateLimiting() RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60); }); + + // WebRTC token endpoint - configurable limit (default: 5/min) + RateLimiter::for('webrtc-token', function (Request $request) { + $limit = app(SettingsService::class)->get('webrtc_token_rate_limit') ?: 5; + return Limit::perMinute($limit)->by($request->ip()); + }); + + // WebRTC call endpoint - configurable limit (default: 3/min) + RateLimiter::for('webrtc-call', function (Request $request) { + $limit = app(SettingsService::class)->get('webrtc_call_rate_limit') ?: 3; + return Limit::perMinute($limit)->by($request->ip()); + }); } } diff --git a/src/app/Services/SettingsService.php b/src/app/Services/SettingsService.php index 804cb2580..374de8767 100644 --- a/src/app/Services/SettingsService.php +++ b/src/app/Services/SettingsService.php @@ -85,7 +85,14 @@ class SettingsService 'voice' => ['description' => '/general/language-options', 'default' => 'Polly.Kendra', 'overridable' => true, 'hidden' => false], 'voicemail_playback_grace_hours' => ['description' => '', 'default' => 48, 'overridable' => true, 'hidden' => false], 'volunteer_auto_answer' => ['description' => '/helpline/volunteer-auto-answer', 'default'=>false, 'overridable' => true, 'hidden' => false], - 'word_language' => ['description' => '', 'default' => 'en-US', 'overridable' => true, 'hidden' => false] + 'word_language' => ['description' => '', 'default' => 'en-US', 'overridable' => true, 'hidden' => false], + 'webrtc_enabled' => ['description' => '/webrtc/enabling-webrtc', 'default' => false, 'overridable' => false, 'hidden' => false], + 'twilio_api_key' => ['description' => '/webrtc/twilio-api-key', 'default' => '', 'overridable' => false, 'hidden' => true], + 'twilio_api_secret' => ['description' => '/webrtc/twilio-api-secret', 'default' => '', 'overridable' => false, 'hidden' => true], + 'twilio_twiml_app_sid' => ['description' => '/webrtc/twiml-app-sid', 'default' => '', 'overridable' => false, 'hidden' => true], + 'webrtc_allowed_origins' => ['description' => '/webrtc/cors-origins', 'default' => '*', 'overridable' => false, 'hidden' => false], + 'webrtc_token_rate_limit' => ['description' => '/webrtc/rate-limiting', 'default' => 5, 'overridable' => false, 'hidden' => false], + 'webrtc_call_rate_limit' => ['description' => '/webrtc/rate-limiting', 'default' => 3, 'overridable' => false, 'hidden' => false] ]; public static array $dateCalculationsMap = diff --git a/src/package-lock.json b/src/package-lock.json index e710f4d7c..c5f7521ee 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -20,6 +20,7 @@ "@mui/material": "^7.0.0", "@mui/x-date-pickers": "^8.19.0", "@toolpad/core": "^0.16.0", + "@twilio/voice-sdk": "^2.12.0", "dayjs": "^1.11.19", "leaflet": "^1.9.3", "leaflet-fullscreen": "^1.0.2", @@ -3717,6 +3718,28 @@ "node": ">=10.13.0" } }, + "node_modules/@twilio/voice-errors": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@twilio/voice-errors/-/voice-errors-1.7.0.tgz", + "integrity": "sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==", + "license": "BSD-3-Clause" + }, + "node_modules/@twilio/voice-sdk": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@twilio/voice-sdk/-/voice-sdk-2.17.0.tgz", + "integrity": "sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==", + "license": "Apache-2.0", + "dependencies": { + "@twilio/voice-errors": "1.7.0", + "@types/events": "^3.0.3", + "events": "3.3.0", + "loglevel": "1.9.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3853,6 +3876,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -6827,7 +6856,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -8664,6 +8692,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/src/package.json b/src/package.json index 23c203fed..da9edf4c8 100644 --- a/src/package.json +++ b/src/package.json @@ -18,6 +18,7 @@ "test:e2e:headed": "npx playwright test --headed" }, "dependencies": { + "@twilio/voice-sdk": "^2.12.0", "@bmlt-enabled/croutonjs": "^3.18.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json index 3aef3aba0..afc85c53f 100644 --- a/src/public/mix-manifest.json +++ b/src/public/mix-manifest.json @@ -4,6 +4,8 @@ "/images/vendor/leaflet/dist/layers-2x.png?8f2c4d11474275fbc1614b9098334eae": "/images/vendor/leaflet/dist/layers-2x.png?8f2c4d11474275fbc1614b9098334eae", "/images/vendor/leaflet/dist/marker-icon.png?2b3e1faf89f94a4835397e7a43b4f77d": "/images/vendor/leaflet/dist/marker-icon.png?2b3e1faf89f94a4835397e7a43b4f77d", "/js/index.js.map": "/js/index.js.map", + "/js/dial-widget.js": "/js/dial-widget.js", + "/js/dial-widget.js.map": "/js/dial-widget.js.map", "/css/app.css": "/css/app.css", "/css/app.css.map": "/css/app.css.map" } diff --git a/src/public/widget-demo.html b/src/public/widget-demo.html new file mode 100644 index 000000000..f1809675e --- /dev/null +++ b/src/public/widget-demo.html @@ -0,0 +1,401 @@ + + + + + + Yap WebRTC Dial Widget Demo + + + +
+

Yap WebRTC Dial Widget

+

Enable browser-based calling on your website

+ +
+ +
+

Live Demo

+
+ +
+
+ +
+ Checking WebRTC configuration... +
+ +
+

WebRTC Configured

+

WebRTC is enabled and ready. Use the widget above to test browser-based calling.

+
+ + +
+ + +
+

Simple Embed (Data Attributes)

+

The easiest way to add the widget to your site:

+ +
+<!-- Add the widget container --> +<div + data-yap-widget + data-yap-api-url="https://your-yap-server.com" + data-yap-title="NA Helpline" +></div> + +<!-- Load the widget script --> +<script src="https://your-yap-server.com/widget-loader.js"></script> +
+ +
+

Available Data Attributes

+
    +
  • data-yap-api-url - Your Yap server URL (required)
  • +
  • data-yap-service-body - Service body ID to route calls
  • +
  • data-yap-title - Custom widget title
  • +
  • data-yap-show-location - Show location input (true/false)
  • +
  • data-yap-show-search-type - Show search type selector (true/false)
  • +
  • data-yap-search-type - Default: helpline, meeting, jft, spad
  • +
+
+
+ + +
+

Programmatic Initialization

+

For more control, initialize the widget with JavaScript:

+ +
+<div id="my-widget"></div> + +<script src="https://your-yap-server.com/widget-loader.js"></script> +<script> +YapWidget.load({ + container: '#my-widget', + apiUrl: 'https://your-yap-server.com', + serviceBodyId: '123', + title: 'NA Helpline', + showLocationInput: true, + showSearchType: false, + onCallStart: function() { + console.log('Call started'); + }, + onCallEnd: function(data) { + console.log('Call ended', data); + }, + onError: function(error) { + console.error('Call error', error); + } +}); +</script> +
+
+ + +
+

Custom Styling

+

Override the default styles to match your site:

+ +
+YapWidget.load({ + container: '#my-widget', + apiUrl: 'https://your-yap-server.com', + styles: { + container: { + backgroundColor: '#f5f5f5', + borderRadius: '20px', + boxShadow: 'none' + }, + callButton: { + backgroundColor: '#1976d2' + }, + title: { + color: '#1976d2' + } + } +}); +
+
+
+ + +
+

Twilio Setup Instructions

+

To enable WebRTC calling, you need to configure a few things in Twilio:

+ +
    +
  1. + Create an API Key: + Go to Twilio Console → Account → API keys → Create API Key. + Save the SID and Secret. +
  2. +
  3. + Create a TwiML Application: + Go to Twilio Console → Voice → TwiML Apps → Create new TwiML App. +
      +
    • Set the Voice Request URL to: https://your-yap-server.com/webrtc-call
    • +
    • Set the Status Callback URL to: https://your-yap-server.com/webrtc-status
    • +
    • Save the Application SID
    • +
    +
  4. +
  5. + Add settings to your Yap config.php: +
    +$webrtc_enabled = true; +$twilio_api_key = 'SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +$twilio_api_secret = 'your_api_secret_here'; +$twilio_twiml_app_sid = 'APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +
    +
  6. +
  7. + Test the widget: + Visit this demo page and try making a call! +
  8. +
+
+ + +
+ + + + + + diff --git a/src/public/widget-loader.js b/src/public/widget-loader.js new file mode 100644 index 000000000..b439a2153 --- /dev/null +++ b/src/public/widget-loader.js @@ -0,0 +1,205 @@ +/** + * Yap WebRTC Dial Widget Loader + * + * This is a lightweight loader script that external websites can include + * to easily embed the Yap dial widget on their pages. + * + * Usage: + * + * 1. Simple embed with data attributes: + *
+ * + * + * 2. Programmatic initialization: + *
+ * + * + */ + +(function() { + 'use strict'; + + // Get the script URL to determine the base URL + var scripts = document.getElementsByTagName('script'); + var currentScript = scripts[scripts.length - 1]; + var scriptUrl = currentScript.src; + var baseUrl = scriptUrl.substring(0, scriptUrl.lastIndexOf('/')); + + // Configuration + var config = { + widgetScriptUrl: baseUrl + '/js/dial-widget.js', + cssUrl: null, // Optional CSS URL + loaded: false, + loading: false, + queue: [], + }; + + // Check WebRTC support + function isWebRTCSupported() { + return !!( + navigator.mediaDevices && + navigator.mediaDevices.getUserMedia && + window.RTCPeerConnection + ); + } + + // Load the widget script + function loadWidgetScript(callback) { + if (config.loaded) { + callback(); + return; + } + + if (config.loading) { + config.queue.push(callback); + return; + } + + config.loading = true; + + var script = document.createElement('script'); + script.src = config.widgetScriptUrl; + script.async = true; + + script.onload = function() { + config.loaded = true; + config.loading = false; + callback(); + + // Process queued callbacks + while (config.queue.length > 0) { + var queuedCallback = config.queue.shift(); + queuedCallback(); + } + }; + + script.onerror = function() { + config.loading = false; + console.error('YapWidget: Failed to load widget script'); + }; + + document.head.appendChild(script); + + // Load optional CSS + if (config.cssUrl) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = config.cssUrl; + document.head.appendChild(link); + } + } + + // Initialize a widget + function initWidget(options) { + if (!isWebRTCSupported()) { + console.warn('YapWidget: WebRTC is not supported in this browser'); + if (options.container) { + var containerEl = typeof options.container === 'string' + ? document.querySelector(options.container) + : options.container; + if (containerEl) { + containerEl.innerHTML = '
Web calling is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.
'; + } + } + return null; + } + + loadWidgetScript(function() { + if (window.YapDialWidget && window.YapDialWidget.init) { + window.YapDialWidget.init(options.container, { + apiUrl: options.apiUrl, + serviceBodyId: options.serviceBodyId, + title: options.title, + showLocationInput: options.showLocationInput, + showSearchType: options.showSearchType, + defaultSearchType: options.defaultSearchType, + styles: options.styles, + onCallStart: options.onCallStart, + onCallEnd: options.onCallEnd, + onError: options.onError, + }); + } + }); + } + + // Auto-initialize widgets with data attributes on DOMContentLoaded + function autoInit() { + var widgets = document.querySelectorAll('[data-yap-widget]'); + + widgets.forEach(function(el) { + var apiUrl = el.getAttribute('data-yap-api-url') || el.getAttribute('data-api-url'); + if (!apiUrl) { + // Try to infer from the loader script URL + apiUrl = baseUrl; + } + + if (!apiUrl) { + console.error('YapWidget: data-yap-api-url is required'); + return; + } + + initWidget({ + container: el, + apiUrl: apiUrl, + serviceBodyId: el.getAttribute('data-yap-service-body') || el.getAttribute('data-service-body-id'), + title: el.getAttribute('data-yap-title') || el.getAttribute('data-title'), + showLocationInput: el.getAttribute('data-yap-show-location') !== 'false', + showSearchType: el.getAttribute('data-yap-show-search-type') === 'true', + defaultSearchType: el.getAttribute('data-yap-search-type') || 'helpline', + }); + }); + } + + // Public API + window.YapWidget = { + /** + * Load and initialize the widget + * @param {Object} options - Configuration options + */ + load: function(options) { + if (!options.apiUrl) { + options.apiUrl = baseUrl; + } + initWidget(options); + }, + + /** + * Check if WebRTC is supported + * @returns {boolean} + */ + isSupported: isWebRTCSupported, + + /** + * Get the base URL + * @returns {string} + */ + getBaseUrl: function() { + return baseUrl; + }, + + /** + * Preload the widget script without rendering + * @param {Function} callback - Called when script is loaded + */ + preload: function(callback) { + loadWidgetScript(callback || function() {}); + } + }; + + // Auto-init on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', autoInit); + } else { + // DOM already loaded + autoInit(); + } + + console.log('YapWidget loader ready'); +})(); diff --git a/src/resources/js/widget/DialWidget.jsx b/src/resources/js/widget/DialWidget.jsx new file mode 100644 index 000000000..252155237 --- /dev/null +++ b/src/resources/js/widget/DialWidget.jsx @@ -0,0 +1,680 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Device } from '@twilio/voice-sdk'; + +// Widget states +const STATES = { + INITIALIZING: 'initializing', + READY: 'ready', + CONNECTING: 'connecting', + CONNECTED: 'connected', + ERROR: 'error', + DISABLED: 'disabled' +}; + +// Default styles that can be overridden +const defaultStyles = { + container: { + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + maxWidth: '320px', + padding: '20px', + backgroundColor: '#ffffff', + borderRadius: '12px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + border: '1px solid #e0e0e0', + }, + header: { + textAlign: 'center', + marginBottom: '16px', + }, + title: { + fontSize: '18px', + fontWeight: '600', + color: '#333', + margin: '0 0 4px 0', + }, + subtitle: { + fontSize: '14px', + color: '#666', + margin: 0, + }, + inputGroup: { + marginBottom: '16px', + }, + label: { + display: 'block', + fontSize: '14px', + fontWeight: '500', + color: '#333', + marginBottom: '6px', + }, + input: { + width: '100%', + padding: '12px', + fontSize: '14px', + border: '1px solid #ddd', + borderRadius: '8px', + boxSizing: 'border-box', + outline: 'none', + transition: 'border-color 0.2s', + }, + select: { + width: '100%', + padding: '12px', + fontSize: '14px', + border: '1px solid #ddd', + borderRadius: '8px', + boxSizing: 'border-box', + outline: 'none', + backgroundColor: '#fff', + cursor: 'pointer', + }, + button: { + width: '100%', + padding: '14px', + fontSize: '16px', + fontWeight: '600', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + transition: 'all 0.2s', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + }, + callButton: { + backgroundColor: '#22c55e', + color: '#fff', + }, + callButtonHover: { + backgroundColor: '#16a34a', + }, + hangupButton: { + backgroundColor: '#ef4444', + color: '#fff', + }, + hangupButtonHover: { + backgroundColor: '#dc2626', + }, + disabledButton: { + backgroundColor: '#ccc', + color: '#666', + cursor: 'not-allowed', + }, + status: { + textAlign: 'center', + marginTop: '12px', + fontSize: '14px', + }, + statusConnecting: { + color: '#f59e0b', + }, + statusConnected: { + color: '#22c55e', + }, + statusError: { + color: '#ef4444', + }, + controls: { + display: 'flex', + gap: '12px', + marginTop: '12px', + }, + controlButton: { + flex: 1, + padding: '10px', + fontSize: '14px', + fontWeight: '500', + border: '1px solid #ddd', + borderRadius: '8px', + backgroundColor: '#fff', + cursor: 'pointer', + transition: 'all 0.2s', + }, + controlButtonActive: { + backgroundColor: '#f3f4f6', + borderColor: '#333', + }, + timer: { + textAlign: 'center', + fontSize: '24px', + fontWeight: '600', + color: '#333', + margin: '16px 0', + }, + errorMessage: { + backgroundColor: '#fef2f2', + border: '1px solid #fecaca', + borderRadius: '8px', + padding: '12px', + marginTop: '12px', + fontSize: '14px', + color: '#991b1b', + }, + keypad: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '8px', + marginTop: '12px', + padding: '12px', + backgroundColor: '#f9fafb', + borderRadius: '8px', + }, + keypadButton: { + padding: '12px', + fontSize: '18px', + fontWeight: '600', + border: '1px solid #e5e7eb', + borderRadius: '8px', + backgroundColor: '#fff', + cursor: 'pointer', + transition: 'all 0.15s', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: '50px', + }, + keypadButtonPressed: { + backgroundColor: '#e5e7eb', + transform: 'scale(0.95)', + }, + keypadSubtext: { + fontSize: '9px', + fontWeight: '400', + color: '#6b7280', + marginTop: '2px', + letterSpacing: '1px', + }, +}; + +// Phone icon SVG +const PhoneIcon = ({ calling = false }) => ( + + + +); + +// Mute icon SVG +const MuteIcon = ({ muted }) => ( + + {muted ? ( + <> + + + + + + + ) : ( + <> + + + + + + )} + +); + +// Keypad icon SVG +const KeypadIcon = () => ( + + + + + + + + + + + +); + +// DTMF keypad data +const KEYPAD_KEYS = [ + { digit: '1', letters: '' }, + { digit: '2', letters: 'ABC' }, + { digit: '3', letters: 'DEF' }, + { digit: '4', letters: 'GHI' }, + { digit: '5', letters: 'JKL' }, + { digit: '6', letters: 'MNO' }, + { digit: '7', letters: 'PQRS' }, + { digit: '8', letters: 'TUV' }, + { digit: '9', letters: 'WXYZ' }, + { digit: '*', letters: '' }, + { digit: '0', letters: '+' }, + { digit: '#', letters: '' }, +]; + +// Format call duration +const formatDuration = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +}; + +export default function DialWidget({ + apiUrl, + serviceBodyId = null, + title = 'Helpline', + showLocationInput = true, + showSearchType = false, + defaultSearchType = 'helpline', + customStyles = {}, + onCallStart = () => {}, + onCallEnd = () => {}, + onError = () => {}, +}) { + const [state, setState] = useState(STATES.INITIALIZING); + const [device, setDevice] = useState(null); + const [call, setCall] = useState(null); + const [location, setLocation] = useState(''); + const [searchType, setSearchType] = useState(defaultSearchType); + const [isMuted, setIsMuted] = useState(false); + const [duration, setDuration] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const [isHovering, setIsHovering] = useState(false); + const [config, setConfig] = useState(null); + const [showKeypad, setShowKeypad] = useState(false); + const [pressedKey, setPressedKey] = useState(null); + + const timerRef = useRef(null); + const deviceRef = useRef(null); + + // Merge custom styles with defaults + const styles = { + ...defaultStyles, + ...customStyles, + }; + + // Initialize Twilio Device + const initializeDevice = useCallback(async () => { + try { + // First, check if WebRTC is enabled and get config + const configResponse = await fetch(`${apiUrl}/api/v1/webrtc/config`); + if (!configResponse.ok) { + const error = await configResponse.json(); + throw new Error(error.error || 'WebRTC is not available'); + } + const configData = await configResponse.json(); + setConfig(configData); + + // Get access token + const tokenResponse = await fetch(`${apiUrl}/api/v1/webrtc/token`); + if (!tokenResponse.ok) { + const error = await tokenResponse.json(); + throw new Error(error.error || 'Failed to get access token'); + } + const tokenData = await tokenResponse.json(); + + // Create and register device + const twilioDevice = new Device(tokenData.token, { + codecPreferences: ['opus', 'pcmu'], + enableRingingState: true, + }); + + twilioDevice.on('registered', () => { + setState(STATES.READY); + }); + + twilioDevice.on('error', (error) => { + console.error('Twilio Device error:', error); + setErrorMessage(error.message || 'An error occurred'); + setState(STATES.ERROR); + onError(error); + }); + + twilioDevice.on('tokenWillExpire', async () => { + // Refresh token before it expires + try { + const response = await fetch(`${apiUrl}/api/v1/webrtc/token`); + const data = await response.json(); + twilioDevice.updateToken(data.token); + } catch (e) { + console.error('Failed to refresh token:', e); + } + }); + + await twilioDevice.register(); + deviceRef.current = twilioDevice; + setDevice(twilioDevice); + } catch (error) { + console.error('Failed to initialize device:', error); + setErrorMessage(error.message || 'Failed to initialize. Please try again.'); + setState(STATES.ERROR); + onError(error); + } + }, [apiUrl, onError]); + + // Initialize on mount + useEffect(() => { + initializeDevice(); + + return () => { + if (deviceRef.current) { + deviceRef.current.destroy(); + } + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [initializeDevice]); + + // Handle making a call + const handleCall = async () => { + if (!device || state !== STATES.READY) return; + + try { + setState(STATES.CONNECTING); + setErrorMessage(''); + + const params = { + searchType: searchType, + }; + + if (serviceBodyId) { + params.serviceBodyId = serviceBodyId; + } + + if (location.trim()) { + params.location = location.trim(); + } + + const newCall = await device.connect({ params }); + + newCall.on('accept', () => { + setState(STATES.CONNECTED); + setDuration(0); + timerRef.current = setInterval(() => { + setDuration(d => d + 1); + }, 1000); + onCallStart(); + }); + + newCall.on('disconnect', () => { + setState(STATES.READY); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setCall(null); + setIsMuted(false); + setShowKeypad(false); + onCallEnd({ duration }); + }); + + newCall.on('cancel', () => { + setState(STATES.READY); + setCall(null); + }); + + newCall.on('error', (error) => { + console.error('Call error:', error); + setErrorMessage(error.message || 'Call failed'); + setState(STATES.ERROR); + onError(error); + }); + + setCall(newCall); + } catch (error) { + console.error('Failed to make call:', error); + setErrorMessage(error.message || 'Failed to connect call'); + setState(STATES.ERROR); + onError(error); + } + }; + + // Handle hanging up + const handleHangup = () => { + if (call) { + call.disconnect(); + } + }; + + // Handle mute toggle + const handleMuteToggle = () => { + if (call) { + if (isMuted) { + call.mute(false); + } else { + call.mute(true); + } + setIsMuted(!isMuted); + } + }; + + // Handle keypad toggle + const handleKeypadToggle = () => { + setShowKeypad(!showKeypad); + }; + + // Handle DTMF digit press + const handleDtmfPress = (digit) => { + if (call) { + call.sendDigits(digit); + setPressedKey(digit); + // Visual feedback - clear after 150ms + setTimeout(() => setPressedKey(null), 150); + } + }; + + // Handle retry after error + const handleRetry = () => { + setState(STATES.INITIALIZING); + setErrorMessage(''); + initializeDevice(); + }; + + // Render based on state + const renderContent = () => { + switch (state) { + case STATES.INITIALIZING: + return ( +
+

Initializing...

+
+ ); + + case STATES.DISABLED: + return ( +
+

Web calling is not available at this time.

+
+ ); + + case STATES.ERROR: + return ( + <> +
+

{errorMessage}

+
+ + + ); + + case STATES.READY: + return ( + <> + {showLocationInput && ( +
+ + setLocation(e.target.value)} + style={styles.input} + /> +
+ )} + + {showSearchType && ( +
+ + +
+ )} + + + + ); + + case STATES.CONNECTING: + return ( + <> +
+

Connecting...

+
+ + + ); + + case STATES.CONNECTED: + return ( + <> +
+ {formatDuration(duration)} +
+
+

Connected

+
+
+ + +
+ {showKeypad && ( +
+ {KEYPAD_KEYS.map(({ digit, letters }) => ( + + ))} +
+ )} + + + ); + + default: + return null; + } + }; + + return ( +
+
+

{config?.title || title}

+

Click to call from your browser

+
+ {renderContent()} +
+ ); +} diff --git a/src/resources/js/widget/index.js b/src/resources/js/widget/index.js new file mode 100644 index 000000000..271381eeb --- /dev/null +++ b/src/resources/js/widget/index.js @@ -0,0 +1,110 @@ +/** + * Yap WebRTC Dial Widget + * + * This is the entry point for the embeddable dial widget. + * It can be loaded on external websites to allow browser-based calling. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import DialWidget from './DialWidget'; + +// Export for programmatic use +export { DialWidget }; + +// Global initialization function +window.YapDialWidget = { + /** + * Initialize the dial widget in a container element + * + * @param {HTMLElement|string} container - DOM element or selector + * @param {Object} options - Configuration options + * @param {string} options.apiUrl - The base URL of your Yap installation + * @param {string} [options.serviceBodyId] - Optional service body ID to route calls + * @param {string} [options.title] - Custom title for the widget + * @param {boolean} [options.showLocationInput=true] - Show location input field + * @param {boolean} [options.showSearchType=false] - Show search type selector + * @param {string} [options.defaultSearchType='helpline'] - Default search type + * @param {Object} [options.styles] - Custom styles to override defaults + * @param {Function} [options.onCallStart] - Callback when call starts + * @param {Function} [options.onCallEnd] - Callback when call ends + * @param {Function} [options.onError] - Callback on error + */ + init: function(container, options = {}) { + // Get container element + let containerEl = container; + if (typeof container === 'string') { + containerEl = document.querySelector(container); + } + + if (!containerEl) { + console.error('YapDialWidget: Container element not found'); + return null; + } + + // Validate required options + if (!options.apiUrl) { + console.error('YapDialWidget: apiUrl is required'); + return null; + } + + // Create React root and render + const root = createRoot(containerEl); + root.render( + {})} + onCallEnd={options.onCallEnd || (() => {})} + onError={options.onError || (() => {})} + /> + ); + + return { + destroy: () => { + root.unmount(); + } + }; + }, + + /** + * Check if WebRTC is supported in this browser + */ + isSupported: function() { + return !!( + navigator.mediaDevices && + navigator.mediaDevices.getUserMedia && + window.RTCPeerConnection + ); + } +}; + +// Auto-initialize widgets with data attributes +document.addEventListener('DOMContentLoaded', function() { + const widgets = document.querySelectorAll('[data-yap-widget]'); + + widgets.forEach(function(el) { + const apiUrl = el.dataset.yapApiUrl || el.dataset.apiUrl; + if (!apiUrl) { + console.error('YapDialWidget: data-yap-api-url is required'); + return; + } + + window.YapDialWidget.init(el, { + apiUrl: apiUrl, + serviceBodyId: el.dataset.yapServiceBody || el.dataset.serviceBodyId, + title: el.dataset.yapTitle || el.dataset.title, + showLocationInput: el.dataset.yapShowLocation !== 'false', + showSearchType: el.dataset.yapShowSearchType === 'true', + defaultSearchType: el.dataset.yapSearchType || 'helpline', + }); + }); +}); + +// Log version +console.log('YapDialWidget loaded'); diff --git a/src/routes/api.php b/src/routes/api.php index d6631a611..cf72070d0 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\V1\Admin\AuthController; use App\Http\Controllers\Api\V1\Admin\SwaggerController; use App\Http\Controllers\Api\V1\Admin\VoicemailController; +use App\Http\Controllers\Api\V1\WebRtcController; use App\Http\Controllers\UpgradeAdvisorController; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Route; @@ -20,6 +21,17 @@ Route::get('upgrade', [UpgradeAdvisorController::class, 'index']); Route::get('/openapi.json', [SwaggerController::class, 'openapi'])->name('openapi'); + // WebRTC Widget endpoints (public, rate-limited) + // Token endpoint uses configurable rate limit (default: 5/min) + Route::group(['prefix' => 'webrtc'], function () { + Route::get('token', [WebRtcController::class, 'token']) + ->middleware('throttle:webrtc-token') + ->name('webrtc.token'); + Route::get('config', [WebRtcController::class, 'config']) + ->middleware('throttle:60,1') + ->name('webrtc.config'); + }); + Route::group(['middleware' => ['auth:sanctum']], function () { Route::resource('user', 'AuthController')->only(['index']); Route::resource('volunteers', 'ConfigureVolunteersController')->only(['index', 'store']); diff --git a/src/routes/web.php b/src/routes/web.php index 8a8adc250..b06d78309 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -79,4 +79,9 @@ ->where('ext', $ext); Route::match(array('GET', 'POST'), "/helpline-dialer{ext}", 'App\Http\Controllers\HelplineController@dial') ->where('ext', $ext); -//Route::get("/callWidget", 'App\Http\Controllers\CallWidgetController@index'); +// WebRTC Call Handler - TwiML Application webhook endpoint +// Protected by Twilio signature validation and rate limiting +Route::match(array('GET', 'POST'), "/webrtc-call", 'App\Http\Controllers\WebRtcCallController@handleCall') + ->middleware(['twilio.signature', 'throttle:webrtc-call']); +Route::match(array('GET', 'POST'), "/webrtc-status", 'App\Http\Controllers\WebRtcCallController@statusCallback') + ->middleware(['twilio.signature']); diff --git a/src/storage/api-docs/api-docs.json b/src/storage/api-docs/api-docs.json index 619e1a2e1..c3fb7e0bb 100644 --- a/src/storage/api-docs/api-docs.json +++ b/src/storage/api-docs/api-docs.json @@ -2300,6 +2300,130 @@ } } } + }, + "/api/v1/webrtc/token": { + "get": { + "tags": [ + "WebRTC" + ], + "summary": "Generate a Twilio access token for WebRTC calling", + "description": "Returns a JWT token that can be used to initialize a Twilio Voice SDK client for browser-based calling", + "operationId": "084176bd51bccd434ac83fda2c67fd20", + "responses": { + "200": { + "description": "Token generated successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "token": { + "description": "JWT access token for Twilio Voice SDK", + "type": "string" + }, + "identity": { + "description": "Unique identity for this caller", + "type": "string" + }, + "expires_in": { + "description": "Token validity in seconds", + "type": "integer", + "example": 3600 + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "WebRTC calling is not enabled", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "WebRTC calling is not enabled" + } + }, + "type": "object" + } + } + } + }, + "500": { + "description": "WebRTC is not properly configured or token generation failed", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "WebRTC is not properly configured" + } + }, + "type": "object" + } + } + } + } + } + } + }, + "/api/v1/webrtc/config": { + "get": { + "tags": [ + "WebRTC" + ], + "summary": "Get WebRTC widget configuration", + "description": "Returns configuration settings for the WebRTC dial widget", + "operationId": "ad01183f92677a7d17226bcb72456576", + "responses": { + "200": { + "description": "Configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "enabled": { + "description": "Whether WebRTC is enabled", + "type": "boolean", + "example": true + }, + "title": { + "description": "Helpline title", + "type": "string", + "example": "Helpline" + }, + "language": { + "description": "Default language", + "type": "string", + "example": "en-US" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "WebRTC calling is not enabled", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "WebRTC calling is not enabled" + } + }, + "type": "object" + } + } + } + } + } + } } }, "components": { @@ -2348,6 +2472,10 @@ "name": "Voicemails", "description": "API Endpoints for managing voicemails" }, + { + "name": "WebRTC", + "description": "WebRTC calling endpoints for browser-based voice calls" + }, { "name": "Authentication", "description": "Authentication" diff --git a/src/webpack.mix.js b/src/webpack.mix.js index 9845c508f..e86238186 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -11,11 +11,18 @@ const mix = require('laravel-mix'); | */ +// Main admin application mix.js('resources/js/index.js', 'public/js') .react() .sourceMaps() .css('resources/css/app.css', 'public/css'); +// WebRTC Dial Widget - standalone bundle for embedding +// The widget self-registers to window.YapDialWidget in its source +mix.js('resources/js/widget/index.js', 'public/js/dial-widget.js') + .react() + .sourceMaps(); + // Webpack 5 no longer includes Node.js polyfills by default mix.webpackConfig({ resolve: {