Files
twilio-wp-plugin/includes/class-twp-fcm.php
Claude 5adfa694c1
All checks were successful
Create Release / build (push) Successful in 3s
Migrate FCM from legacy v1 API to HTTP v2 with service account auth
Replace deprecated FCM server key authentication with Google service
account OAuth2 flow. The class now creates a signed JWT from the
service account credentials, exchanges it for a short-lived access
token (cached via WordPress transients), and sends messages to the
FCM v2 endpoint (projects/{id}/messages:send).

Settings page updated: FCM Server Key field replaced with Firebase
Project ID + Service Account JSON textarea with validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:04:33 -08:00

345 lines
11 KiB
PHP

<?php
/**
* Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
*
* Handles push notifications to mobile devices via FCM using
* service account credentials and OAuth2 access tokens.
*/
class TWP_FCM {
private $project_id;
private $service_account;
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
/**
* Constructor
*/
public function __construct() {
$this->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);
}
}