All checks were successful
Create Release / build (push) Successful in 3s
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>
345 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|