project_id = get_option('twp_fcm_project_id', ''); $sa_json = get_option('twp_fcm_service_account_json', ''); if (!empty($sa_json)) { $this->service_account = json_decode($sa_json, true); } } /** * Send push notification to user's devices */ public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) { if (empty($this->project_id) || empty($this->service_account)) { error_log('TWP FCM: Project ID or service account not configured'); return false; } // Get user's FCM tokens $tokens = $this->get_user_tokens($user_id); if (empty($tokens)) { error_log("TWP FCM: No tokens found for user $user_id"); return false; } $success_count = 0; $failed_tokens = array(); foreach ($tokens as $token) { $result = $this->send_to_token($token, $title, $body, $data, $data_only); if ($result['success']) { $success_count++; } else { $failed_tokens[] = $token; // If token is invalid, remove it from database if ($result['error'] === 'invalid_token') { $this->remove_invalid_token($token); } } } error_log("TWP FCM: Sent notification to $success_count/" . count($tokens) . " devices for user $user_id"); return $success_count > 0; } /** * Send notification to specific token via FCM HTTP v2 API */ private function send_to_token($token, $title, $body, $data = array(), $data_only = false) { $access_token = $this->get_access_token(); if (!$access_token) { return array('success' => false, 'error' => 'auth_failed'); } // FCM v2 requires all data values to be strings $string_data = array(); foreach ($data as $key => $value) { $string_data[$key] = is_string($value) ? $value : (string)$value; } $string_data['title'] = $title; $string_data['body'] = $body; $string_data['timestamp'] = (string)time(); // Build the v2 message payload $message = array( 'token' => $token, 'data' => $string_data, 'android' => array( 'priority' => 'high', ), ); if (!$data_only) { $message['notification'] = array( 'title' => $title, 'body' => $body, ); $message['android']['notification'] = array( 'sound' => 'default', 'click_action' => 'FLUTTER_NOTIFICATION_CLICK', ); } $payload = array('message' => $message); $url = sprintf($this->fcm_url_template, $this->project_id); $headers = array( 'Authorization: Bearer ' . $access_token, 'Content-Type: application/json' ); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code !== 200) { error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response"); $response_data = json_decode($response, true); $error_code = isset($response_data['error']['details'][0]['errorCode']) ? $response_data['error']['details'][0]['errorCode'] : ''; $error_status = isset($response_data['error']['status']) ? $response_data['error']['status'] : ''; if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) || $error_status === 'NOT_FOUND') { return array('success' => false, 'error' => 'invalid_token'); } return array('success' => false, 'error' => 'http_error'); } return array('success' => true); } /** * Get OAuth2 access token from service account credentials. * Caches the token in a transient until near expiry. */ private function get_access_token() { $cached = get_transient('twp_fcm_access_token'); if ($cached) { return $cached; } if (empty($this->service_account)) { error_log('TWP FCM: Service account not configured'); return false; } $jwt = $this->create_jwt(); if (!$jwt) { return false; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->service_account['token_uri']); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array( 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ))); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code !== 200) { error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response"); return false; } $token_data = json_decode($response, true); $access_token = $token_data['access_token']; $expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600; // Cache token for 5 minutes less than actual expiry set_transient('twp_fcm_access_token', $access_token, max(60, $expires_in - 300)); return $access_token; } /** * Create a signed JWT for the service account OAuth2 flow */ private function create_jwt() { $sa = $this->service_account; if (empty($sa['client_email']) || empty($sa['private_key']) || empty($sa['token_uri'])) { error_log('TWP FCM: Service account JSON missing required fields'); return false; } $now = time(); $header = array('alg' => 'RS256', 'typ' => 'JWT'); $claims = array( 'iss' => $sa['client_email'], 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => $sa['token_uri'], 'iat' => $now, 'exp' => $now + 3600, ); $segments = array( $this->base64url_encode(json_encode($header)), $this->base64url_encode(json_encode($claims)), ); $signing_input = implode('.', $segments); $private_key = openssl_pkey_get_private($sa['private_key']); if (!$private_key) { error_log('TWP FCM: Failed to parse service account private key'); return false; } $signature = ''; if (!openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) { error_log('TWP FCM: Failed to sign JWT'); return false; } $segments[] = $this->base64url_encode($signature); return implode('.', $segments); } /** * Base64url encode (RFC 4648) */ private function base64url_encode($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** * Get all active FCM tokens for a user */ private function get_user_tokens($user_id) { global $wpdb; $table = $wpdb->prefix . 'twp_mobile_sessions'; return $wpdb->get_col($wpdb->prepare( "SELECT fcm_token FROM $table WHERE user_id = %d AND is_active = 1 AND fcm_token IS NOT NULL AND fcm_token != '' AND expires_at > NOW()", $user_id )); } /** * Remove invalid FCM token from database */ private function remove_invalid_token($token) { global $wpdb; $table = $wpdb->prefix . 'twp_mobile_sessions'; $wpdb->update( $table, array('fcm_token' => null), array('fcm_token' => $token), array('%s'), array('%s') ); error_log("TWP FCM: Removed invalid token from database"); } /** * Send incoming call notification */ public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) { $title = 'Incoming Call'; $body = "Call from $from_number in $queue_name queue"; $data = array( 'type' => 'incoming_call', 'call_sid' => $call_sid, 'from_number' => $from_number, 'queue_name' => $queue_name ); return $this->send_notification($user_id, $title, $body, $data, true); } /** * Send queue timeout notification */ public function notify_queue_timeout($user_id, $queue_name, $waiting_count) { $title = 'Queue Alert'; $body = "$queue_name has $waiting_count waiting call" . ($waiting_count > 1 ? 's' : ''); $data = array( 'type' => 'queue_timeout', 'queue_name' => $queue_name, 'waiting_count' => $waiting_count ); return $this->send_notification($user_id, $title, $body, $data); } /** * Send agent status change notification */ public function notify_status_change($user_id, $old_status, $new_status) { $title = 'Status Changed'; $body = "Your status changed from $old_status to $new_status"; $data = array( 'type' => 'status_change', 'old_status' => $old_status, 'new_status' => $new_status ); return $this->send_notification($user_id, $title, $body, $data); } /** * Test notification (for settings page) */ public function send_test_notification($user_id) { $title = 'Test Notification'; $body = 'This is a test notification from Twilio WordPress Plugin'; $data = array( 'type' => 'test', 'test' => 'true' ); return $this->send_notification($user_id, $title, $body, $data); } }