From 7df60905543e191cf6aba409dea0c0ec4bb60fc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 18:05:54 -0800 Subject: [PATCH] Fix mobile app: AccessToken for voice, Agent Manager for status, caller ID support - Voice token: use AccessToken + VoiceGrant instead of browser-only ClientToken - Agent status: delegate to TWP_Agent_Manager matching browser phone behavior - Queue loading: add missing require_once for TWP_User_Queue_Manager - Add /phone-numbers endpoint for caller ID selection - Webhook: support CallerId param from mobile extraOptions - Flutter: caller ID dropdown in dialer, error logging in all catch blocks Co-Authored-By: Claude Opus 4.6 --- includes/class-twp-mobile-api.php | 99 +++++++++----- includes/class-twp-webhooks.php | 8 +- mobile/lib/providers/agent_provider.dart | 33 ++++- mobile/lib/providers/auth_provider.dart | 12 +- mobile/lib/providers/call_provider.dart | 4 +- mobile/lib/screens/dashboard_screen.dart | 161 ++++++++++++++--------- mobile/lib/services/voice_service.dart | 20 ++- 7 files changed, 224 insertions(+), 113 deletions(-) diff --git a/includes/class-twp-mobile-api.php b/includes/class-twp-mobile-api.php index 902db17..276ae3c 100644 --- a/includes/class-twp-mobile-api.php +++ b/includes/class-twp-mobile-api.php @@ -106,6 +106,13 @@ class TWP_Mobile_API { 'callback' => array($this, 'get_voice_token'), 'permission_callback' => array($this->auth, 'verify_token') )); + + // Phone numbers for caller ID + register_rest_route('twilio-mobile/v1', '/phone-numbers', array( + 'methods' => 'GET', + 'callback' => array($this, 'get_phone_numbers'), + 'permission_callback' => array($this->auth, 'verify_token') + )); }); } @@ -162,39 +169,16 @@ class TWP_Mobile_API { return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400)); } - global $wpdb; - $table = $wpdb->prefix . 'twp_agent_status'; - - // Check if status exists - $exists = $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM $table WHERE user_id = %d", - $user_id - )); - - $data = array( - 'status' => $new_status, - 'last_activity' => current_time('mysql') - ); + require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php'; + require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php'; + // Handle login status change first (matches browser phone behavior) if ($is_logged_in !== null) { - $data['is_logged_in'] = $is_logged_in ? 1 : 0; - if ($is_logged_in) { - $data['logged_in_at'] = current_time('mysql'); - } + TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in); } - if ($exists) { - $wpdb->update( - $table, - $data, - array('user_id' => $user_id), - array('%s', '%s'), - array('%d') - ); - } else { - $data['user_id'] = $user_id; - $wpdb->insert($table, $data); - } + // Set agent status (handles auto_busy_at and all status fields) + TWP_Agent_Manager::set_agent_status($user_id, $new_status); return new WP_REST_Response(array( 'success' => true, @@ -221,6 +205,7 @@ class TWP_Mobile_API { )); if (!$existing_extension) { + require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php'; TWP_User_Queue_Manager::create_user_queues($user_id); } @@ -693,18 +678,29 @@ class TWP_Mobile_API { $identity = 'agent' . $user_id . $clean_name; try { + // Ensure Twilio SDK autoloader is loaded require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php'; - $twilio = new TWP_Twilio_API(); - $result = $twilio->generate_capability_token($identity); + new TWP_Twilio_API(); - if (!$result['success']) { - return new WP_Error('token_error', $result['error'], array('status' => 500)); + $account_sid = get_option('twp_twilio_account_sid'); + $auth_token = get_option('twp_twilio_auth_token'); + $twiml_app_sid = get_option('twp_twiml_app_sid'); + + if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) { + return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500)); } + // AccessToken for mobile Voice SDK (not ClientToken which is browser-only) + $token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity); + $voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant(); + $voiceGrant->setOutgoingApplicationSid($twiml_app_sid); + $voiceGrant->setIncomingAllow(true); + $token->addGrant($voiceGrant); + return new WP_REST_Response(array( - 'token' => $result['data']['token'], - 'identity' => $result['data']['client_name'], - 'expires_in' => $result['data']['expires_in'] + 'token' => $token->toJWT(), + 'identity' => $identity, + 'expires_in' => 3600 ), 200); } catch (Exception $e) { @@ -712,6 +708,37 @@ class TWP_Mobile_API { } } + /** + * Get available Twilio phone numbers for caller ID + */ + public function get_phone_numbers($request) { + try { + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php'; + $twilio = new TWP_Twilio_API(); + $result = $twilio->get_phone_numbers(); + + if (!$result['success']) { + return new WP_Error('twilio_error', $result['error'], array('status' => 500)); + } + + $phone_numbers = array(); + foreach ($result['data']['incoming_phone_numbers'] as $number) { + $phone_numbers[] = array( + 'phone_number' => $number['phone_number'], + 'friendly_name' => $number['friendly_name'], + ); + } + + return new WP_REST_Response(array( + 'success' => true, + 'phone_numbers' => $phone_numbers + ), 200); + + } catch (Exception $e) { + return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500)); + } + } + /** * Check if user has access to a queue */ diff --git a/includes/class-twp-webhooks.php b/includes/class-twp-webhooks.php index 346ddfe..811d293 100644 --- a/includes/class-twp-webhooks.php +++ b/includes/class-twp-webhooks.php @@ -371,7 +371,13 @@ class TWP_Webhooks { if (isset($params['To']) && !empty($params['To'])) { $to_number = $params['To']; - $from_number = isset($params['From']) ? $params['From'] : ''; + // Mobile SDK sends CallerId via extraOptions; browser sends From as phone number + $from_number = ''; + if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) { + $from_number = $params['CallerId']; + } elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) { + $from_number = $params['From']; + } // If it's an outgoing call to a phone number if (strpos($to_number, 'client:') !== 0) { diff --git a/mobile/lib/providers/agent_provider.dart b/mobile/lib/providers/agent_provider.dart index 11ce715..f6068c9 100644 --- a/mobile/lib/providers/agent_provider.dart +++ b/mobile/lib/providers/agent_provider.dart @@ -5,6 +5,16 @@ import '../models/queue_state.dart'; import '../services/api_client.dart'; import '../services/sse_service.dart'; +class PhoneNumber { + final String phoneNumber; + final String friendlyName; + PhoneNumber({required this.phoneNumber, required this.friendlyName}); + factory PhoneNumber.fromJson(Map json) => PhoneNumber( + phoneNumber: json['phone_number'] as String, + friendlyName: json['friendly_name'] as String, + ); +} + class AgentProvider extends ChangeNotifier { final ApiClient _api; final SseService _sse; @@ -12,12 +22,14 @@ class AgentProvider extends ChangeNotifier { AgentStatus? _status; List _queues = []; bool _sseConnected = false; + List _phoneNumbers = []; StreamSubscription? _sseSub; StreamSubscription? _connSub; AgentStatus? get status => _status; List get queues => _queues; bool get sseConnected => _sseConnected; + List get phoneNumbers => _phoneNumbers; AgentProvider(this._api, this._sse) { _connSub = _sse.connectionState.listen((connected) { @@ -33,7 +45,7 @@ class AgentProvider extends ChangeNotifier { final response = await _api.dio.get('/agent/status'); _status = AgentStatus.fromJson(response.data); notifyListeners(); - } catch (_) {} + } catch (e) { debugPrint('AgentProvider.fetchStatus error: $e'); } } Future updateStatus(AgentStatusValue newStatus) async { @@ -49,7 +61,7 @@ class AgentProvider extends ChangeNotifier { currentCallSid: _status?.currentCallSid, ); notifyListeners(); - } catch (_) {} + } catch (e) { debugPrint('AgentProvider.updateStatus error: $e'); } } Future fetchQueues() async { @@ -60,11 +72,24 @@ class AgentProvider extends ChangeNotifier { .map((q) => QueueInfo.fromJson(q as Map)) .toList(); notifyListeners(); - } catch (_) {} + } catch (e) { debugPrint('AgentProvider.fetchQueues error: $e'); } + } + + Future fetchPhoneNumbers() async { + try { + final response = await _api.dio.get('/phone-numbers'); + final data = response.data; + _phoneNumbers = (data['phone_numbers'] as List) + .map((p) => PhoneNumber.fromJson(p as Map)) + .toList(); + notifyListeners(); + } catch (e) { + debugPrint('AgentProvider.fetchPhoneNumbers error: $e'); + } } Future refresh() async { - await Future.wait([fetchStatus(), fetchQueues()]); + await Future.wait([fetchStatus(), fetchQueues(), fetchPhoneNumbers()]); } void _handleSseEvent(SseEvent event) { diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 10c965f..2ab4545 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -63,13 +63,19 @@ class AuthProvider extends ChangeNotifier { Future _initializeServices() async { try { await _pushService.initialize(); - } catch (_) {} + } catch (e) { + debugPrint('AuthProvider: push service init error: $e'); + } try { await _voiceService.initialize(); - } catch (_) {} + } catch (e) { + debugPrint('AuthProvider: voice service init error: $e'); + } try { await _sseService.connect(); - } catch (_) {} + } catch (e) { + debugPrint('AuthProvider: SSE connect error: $e'); + } } Future logout() async { diff --git a/mobile/lib/providers/call_provider.dart b/mobile/lib/providers/call_provider.dart index 92f755f..2d36dfe 100644 --- a/mobile/lib/providers/call_provider.dart +++ b/mobile/lib/providers/call_provider.dart @@ -103,13 +103,13 @@ class CallProvider extends ChangeNotifier { Future sendDigits(String digits) => _voiceService.sendDigits(digits); - Future makeCall(String number) async { + Future makeCall(String number, {String? callerId}) async { _callInfo = _callInfo.copyWith( state: CallState.connecting, callerNumber: number, ); notifyListeners(); - await _voiceService.makeCall(number); + await _voiceService.makeCall(number, callerId: callerId); } Future holdCall() async { diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index a83ad30..92f1771 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -26,6 +26,7 @@ class _DashboardScreenState extends State { void _showDialer(BuildContext context) { final numberController = TextEditingController(); + String? selectedCallerId; showModalBottomSheet( context: context, @@ -34,75 +35,107 @@ class _DashboardScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom, - top: 16, - left: 16, - right: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Number display - TextField( - controller: numberController, - keyboardType: TextInputType.phone, - autofillHints: const [AutofillHints.telephoneNumber], - textAlign: TextAlign.center, - style: Theme.of(ctx).textTheme.headlineSmall, - decoration: InputDecoration( - hintText: 'Enter phone number', - suffixIcon: IconButton( - icon: const Icon(Icons.backspace_outlined), + final phoneNumbers = context.read().phoneNumbers; + return StatefulBuilder( + builder: (ctx, setSheetState) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom, + top: 16, + left: 16, + right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Number display + TextField( + controller: numberController, + keyboardType: TextInputType.phone, + autofillHints: const [AutofillHints.telephoneNumber], + textAlign: TextAlign.center, + style: Theme.of(ctx).textTheme.headlineSmall, + decoration: InputDecoration( + hintText: 'Enter phone number', + suffixIcon: IconButton( + icon: const Icon(Icons.backspace_outlined), + onPressed: () { + final text = numberController.text; + if (text.isNotEmpty) { + numberController.text = + text.substring(0, text.length - 1); + numberController.selection = TextSelection.fromPosition( + TextPosition(offset: numberController.text.length), + ); + } + }, + ), + ), + ), + // Caller ID selector + if (phoneNumbers.isNotEmpty) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedCallerId, + decoration: const InputDecoration( + labelText: 'Caller ID', + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Default'), + ), + ...phoneNumbers.map((p) => DropdownMenuItem( + value: p.phoneNumber, + child: Text('${p.friendlyName} (${p.phoneNumber})'), + )), + ], + onChanged: (value) { + setSheetState(() { + selectedCallerId = value; + }); + }, + ), + ], + const SizedBox(height: 16), + // Dialpad + Dialpad( + onDigit: (digit) { + numberController.text += digit; + numberController.selection = TextSelection.fromPosition( + TextPosition(offset: numberController.text.length), + ); + }, + onClose: () => Navigator.pop(ctx), + ), + const SizedBox(height: 8), + // Call button + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + icon: const Icon(Icons.call), + label: const Text('Call'), onPressed: () { - final text = numberController.text; - if (text.isNotEmpty) { - numberController.text = - text.substring(0, text.length - 1); - numberController.selection = TextSelection.fromPosition( - TextPosition(offset: numberController.text.length), - ); + final number = numberController.text.trim(); + if (number.isNotEmpty) { + context.read().makeCall(number, callerId: selectedCallerId); + Navigator.pop(ctx); } }, ), - ), + const SizedBox(height: 16), + ], ), - const SizedBox(height: 16), - // Dialpad - Dialpad( - onDigit: (digit) { - numberController.text += digit; - numberController.selection = TextSelection.fromPosition( - TextPosition(offset: numberController.text.length), - ); - }, - onClose: () => Navigator.pop(ctx), - ), - const SizedBox(height: 8), - // Call button - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - icon: const Icon(Icons.call), - label: const Text('Call'), - onPressed: () { - final number = numberController.text.trim(); - if (number.isNotEmpty) { - context.read().makeCall(number); - Navigator.pop(ctx); - } - }, - ), - const SizedBox(height: 16), - ], - ), + ); + }, ); }, ); diff --git a/mobile/lib/services/voice_service.dart b/mobile/lib/services/voice_service.dart index d6a353b..46cd22f 100644 --- a/mobile/lib/services/voice_service.dart +++ b/mobile/lib/services/voice_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:twilio_voice/twilio_voice.dart'; import 'api_client.dart'; @@ -36,7 +37,7 @@ class VoiceService { _identity = data['identity'] as String; await TwilioVoice.instance.setTokens(accessToken: token); } catch (e) { - // Token fetch failed - will retry on next interval + debugPrint('VoiceService._fetchAndRegisterToken error: $e'); } } @@ -62,8 +63,21 @@ class VoiceService { await TwilioVoice.instance.call.toggleSpeaker(speaker); } - Future makeCall(String to) async { - return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false; + Future makeCall(String to, {String? callerId}) async { + try { + final extraOptions = {}; + if (callerId != null && callerId.isNotEmpty) { + extraOptions['CallerId'] = callerId; + } + return await TwilioVoice.instance.call.place( + to: to, + from: _identity ?? '', + extraOptions: extraOptions, + ) ?? false; + } catch (e) { + debugPrint('VoiceService.makeCall error: $e'); + return false; + } } Future sendDigits(String digits) async {