From 33f48647b6b47e94dafe7ead04e79c04ca9e4a27 Mon Sep 17 00:00:00 2001 From: Ramon Gutierrez Date: Wed, 23 Oct 2024 20:46:01 +0800 Subject: [PATCH] Create new FCM sender service to replace outdated library #799 --- composer.json | 1 + composer.lock | 65 ++++++++++- src/Service/FCMSender.php | 230 ++++++++++++++++++++++++++++++++------ 3 files changed, 260 insertions(+), 36 deletions(-) diff --git a/composer.json b/composer.json index 562145d8..0b99215d 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "doctrine/doctrine-migrations-bundle": "^2", "doctrine/orm": "^2", "edwinhoksberg/php-fcm": "dev-notif-priority-hotfix", + "firebase/php-jwt": "^6.10", "guzzlehttp/guzzle": "^6.3", "hashids/hashids": "^4.1", "jankstudio/catalyst-api-bundle": "dev-master", diff --git a/composer.lock b/composer.lock index 91636cc2..8a7f5d56 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "653f8558c75614dd65421cb3eb48c29b", + "content-hash": "4676209ee947dbcf3cfcf937c838c6f2", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1827,6 +1827,69 @@ }, "time": "2023-07-19T09:04:27+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.10.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "500501c2ce893c824c801da135d02661199f60c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5", + "reference": "500501c2ce893c824c801da135d02661199f60c5", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.10.1" + }, + "time": "2024-05-18T18:05:11+00:00" + }, { "name": "friendsofphp/proxy-manager-lts", "version": "v1.0.5", diff --git a/src/Service/FCMSender.php b/src/Service/FCMSender.php index a9b7dbd7..87526f87 100644 --- a/src/Service/FCMSender.php +++ b/src/Service/FCMSender.php @@ -5,55 +5,178 @@ namespace App\Service; use App\Entity\Customer; use App\Ramcar\FirebaseNotificationType; use Symfony\Contracts\Translation\TranslatorInterface; -use Fcm\FcmClient; -use Fcm\Push\Notification; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; +use Firebase\JWT\JWT; use App\Entity\JobOrder; +use App\Entity\Subscription; +use Exception; +use RuntimeException; class FCMSender { protected $client; protected $translator; + protected $project_id; + protected $base_uri; + protected $credentials; - public function __construct(TranslatorInterface $translator, $server_key, $sender_id) + public function __construct(TranslatorInterface $translator, string $creds_file, string $project_id, string $base_uri) { - $this->client = new FcmClient($server_key, $sender_id); $this->translator = $translator; + $this->project_id = $project_id; + $this->base_uri = $base_uri; + + // check credentials file + if (!file_exists($creds_file)) { + throw new RuntimeException("Service account JSON file not found: $creds_file"); + } + + // set credentials from file + $this->credentials = json_decode(file_get_contents($creds_file), true); + + // instantiate client + $this->client = new Client([ + 'base_uri' => $base_uri . 'v1/', + 'timeout' => 5.0, + ]); } - public function send($recipients, $title, $body, $data = [], $color = null, $sound = null, $badge = null) + private function generateAccessToken() { - $notification = new Notification(); - $notification->setTitle($title) - ->setBody($body); + // build the access token parts + $private_key = $this->credentials['private_key']; + $now = time(); - foreach ($recipients as $recipient) { - $notification->addRecipient($recipient); - } + $jwt_payload = [ + 'iss' => $this->credentials['client_email'], + 'sub' => $this->credentials['client_email'], + 'aud' => $this->base_uri, + 'iat' => $now, + 'exp' => $now + 3600, + ]; - if (!empty($color)) { - $notification->setColor($color); - } - - if (!empty($sound)) { - $notification->setSound($sound); - } - - if (!empty($color)) { - $notification->setColor($color); - } - - if (!empty($badge)) { - $notification->setBadge($badge); - } - - if (!empty($data)) { - $notification->addDataArray($data); - } - - return $this->client->send($notification); + // encode into JWT + return JWT::encode($jwt_payload, $private_key, 'RS256'); } - public function sendJoEvent(JobOrder $job_order, $title, $body, $data = [], $title_params = [], $body_params = []) + public function send($recipients, $title, $body, $data = [], $color = null, $sound = null, $image = null) + { + // set URL for sending + $url = "projects/{$this->project_id}/messages:send"; + $access_token = $this->generateAccessToken(); + + // build payload structure + $payload = [ + 'message' => [ + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + 'android' => [ + 'priority' => 'high', + 'notification' => [ + 'sound' => $sound ?? 'default', + ], + ], + 'apns' => [ + 'headers' => [ + 'apns-priority' => '10', + ], + 'payload' => [ + 'aps' => [ + 'alert' => [ + 'title' => $title, + 'body' => $body, + ], + 'sound' => $sound ?? 'default', + ], + ], + ], + ], + ]; + + // if image is provided, apply params + if (!empty($image)) { + $payload['message']['notification']['image'] = $image; + $payload['message']['android']['notification']['image'] = $image; + $payload['message']['apns']['payload']['aps']['mutable-content'] = 1; + } + + // if data is provided, attach it + if (!empty($data)) { + $payload['message']['data'] = $data; + } + + // build headers for request + $headers = [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ]; + + // send the message to each recipient + foreach ($recipients as $recipient) { + $payload['message']['token'] = $recipient; + + try { + $response = $this->client->post($url, [ + 'headers' => $headers, + 'json' => $payload, + ]); + + $result = $response->getBody(); + $json = json_decode($result, true); + + // log response + $this->log( + $title, + $body, + 'Success', + 'success', + json_encode($payload), + $result, + ); + + // message sent! + return [ + 'success' => true, + 'response' => $json, + ]; + } catch (RequestException $e) { + $error_msg = $e->hasResponse() ? $e->getResponse()->getBody()->getContents() : $e->getMessage(); + + // something went wrong + $this->log( + $title, + $body, + $error_msg, + 'error', + json_encode($payload), + ); + + return [ + 'success' => false, + 'error' => "Request error: " . $error_msg, + ]; + } catch (Exception $e) { + // something went wrong + $this->log( + $title, + $body, + $e->getMessage(), + 'error', + json_encode($payload), + ); + + return [ + 'success' => false, + 'error' => "Unexpected error: " . $e->getMessage(), + ]; + } + } + } + + public function sendJoEvent(JobOrder $job_order, $title, $body, $data = []) { // get customer object $cust = $job_order->getCustomer(); @@ -64,7 +187,7 @@ class FCMSender $data['notification_type'] = FirebaseNotificationType::JOB_ORDER; // send the event - return $this->sendEvent($cust, $title, $body, $data, $title_params, $body_params); + return $this->sendEvent($cust, $title, $body, $data); } public function sendLoyaltyEvent(Customer $cust, $title, $body, $data = [], $title_params = [], $body_params = []) @@ -76,6 +199,18 @@ class FCMSender return $this->sendEvent($cust, $title, $body, $data, $title_params, $body_params); } + public function sendSubscriptionEvent(Subscription $sub, $title, $body, $data = [], $title_params = [], $body_params = []) + { + // get customer object + $cust = $sub->getCustomer(); + + // attach type info + $data['notification_type'] = FirebaseNotificationType::SUBSCRIPTION; + + // send the event + return $this->sendEvent($cust, $title, $body, $data, $title_params, $body_params); + } + public function sendEvent(Customer $cust, $title, $body, $data = [], $title_params = [], $body_params = []) { // get all v2 devices @@ -86,7 +221,12 @@ class FCMSender } // send fcm notification - $result = $this->send(array_keys($devices), $this->translator->trans($title), $this->translator->trans($body), $data); + $result = $this->send( + array_keys($devices), + $this->translator->trans($title, $title_params), + $this->translator->trans($body, $body_params), + $data, + ); return $result; } @@ -123,4 +263,24 @@ class FCMSender return $device_ids; } + + // TODO: make this more elegant + public function log($title, $body, $message, $type, $data = "[]", $result = "[]") + { + $filename = '/../../var/log/fcm_' . $type . '.log'; + $date = date("Y-m-d H:i:s"); + + // build log entry + $entry = implode("\r\n", array_filter([ + $date, + $title, + $body, + $message, + "DATA:\r\n" . $data, + "RESULT:\r\n" . $result, + "\r\n----------------------------------------\r\n\r\n", + ])); + + file_put_contents(__DIR__ . $filename, $entry, FILE_APPEND); + } }