From 40337b90b35fdf95e6e5e744f7738237305b017f Mon Sep 17 00:00:00 2001 From: saman9074 Date: Sat, 10 May 2025 02:14:11 +0330 Subject: [PATCH 1/4] Add Persian SMS Channel --- .editorconfig | 15 + .gitattributes | 10 + .gitignore | 6 + .styleci.yml | 1 + CHANGELOG.md | 7 + CONTRIBUTING.md | 55 ++++ LICENSE.md | 21 ++ README.md | 315 +++++++++++++++++- composer.json | 52 +++ config/persian-sms.php | 67 ++++ phpunit.xml | 28 ++ src/Exceptions/CouldNotSendNotification.php | 130 ++++++++ src/IPPanel/IPPanelChannel.php | 273 ++++++++++++++++ src/IPPanel/IPPanelMessage.php | 152 +++++++++ src/PersianSmsServiceProvider.php | 117 +++++++ tests/IPPanelChannelTest.php | 336 ++++++++++++++++++++ tests/TestCase.php | 61 ++++ 17 files changed, 1634 insertions(+), 12 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .styleci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 composer.json create mode 100644 config/persian-sms.php create mode 100644 phpunit.xml create mode 100644 src/Exceptions/CouldNotSendNotification.php create mode 100644 src/IPPanel/IPPanelChannel.php create mode 100644 src/IPPanel/IPPanelMessage.php create mode 100644 src/PersianSmsServiceProvider.php create mode 100644 tests/IPPanelChannelTest.php create mode 100644 tests/TestCase.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..cd8eb86e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b2638710 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0044b968 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.phpunit.cache +/build +/vendor +composer.phar +composer.lock +.DS_Store diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 00000000..0285f179 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1 @@ +preset: laravel diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c2c4933c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `Persian-SMS` will be documented in this file + +## 1.0.0 - 2025-5-10 + +- initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4da74e3f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..b3e0b2f5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) Ali Abdi abdi9074@gmail.com + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/README.md b/README.md index fa8362b7..b810f8b8 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,308 @@ -# New Notification Channels +# Laravel Persian SMS Notification Channel -### Suggesting a new channel -Have a suggestion or working on a new channel? Please create a new issue for that service. +[![Latest Version on Packagist](https://img.shields.io/packagist/v/your-vendor-name/laravel-ippanel-notification-channel.svg?style=flat-square)](https://packagist.org/packages/your-vendor-name/laravel-ippanel-notification-channel) +[![Total Downloads](https://img.shields.io/packagist/dl/your-vendor-name/laravel-ippanel-notification-channel.svg?style=flat-square)](https://packagist.org/packages/your-vendor-name/laravel-ippanel-notification-channel) +[![Build Status](https://img.shields.io/github/actions/workflow/status/your-vendor-name/laravel-ippanel-notification-channel/run-tests.yml?branch=main&style=flat-square)](https://github.com/your-vendor-name/laravel-ippanel-notification-channel/actions?query=workflow%3Arun-tests+branch%3Amain) +[![StyleCI](https://styleci.io/repos/YOUR_REPO_ID/shield?branch=main)](https://styleci.io/repos/YOUR_REPO_ID) +[![License](https://img.shields.io/github/license/your-vendor-name/laravel-ippanel-notification-channel.svg?style=flat-square)](https://github.com/your-vendor-name/laravel-ippanel-notification-channel/blob/main/LICENSE) -### I'm working on a new channel -Please create an issue for it if it does not already exist, then PR you code for review. +This package makes it easy to send notifications using various Iranian SMS service providers with Laravel. The first supported provider is [IPPanel](https://ippanel.com/) with Laravel. -## Workflow for new channels +## Contents -1) Head over to the [skeleton repo](https://github.com/laravel-notification-channels/skeleton) download a ZIP copy. This is important, to ensure you start from a fresh commit history. -2) Use find/replace to replace all of the placeholders with the correct values (package name, author name, email, etc). -3) Implement to logic for the channel & add tests. -4) Fork this repo, add it as a remote and push your new channel to a branch. -5) Submit a new PR against this repo for review. +* [Installation](#installation) +* [Configuration](#configuration) + * [IPPanel Configuration](#IPPanel) +* [Usage](#usage) + * [Routing SMS Notifications](#sending-a-simple-text-message) + * [IPPanel](#ippanel-usage) + * [Available Message Methods (IPPanel)](#available-message-methods-ippanel) + * [Sending a Simple Text Message (IPPanel)](#sending-a-simple-text-message-ippanel) + * [Sending a Pattern-Based Message (IPPanel)](#sending-a-pattern-based-message-ippanel) + * [Customizing the Sender (IPPanel))](#customizing-the-sender-ippanel) + * [Scheduling Messages (IPPanel - Note)](#scheduling-messages-ippanel) + * [Checking Account Credit (IPPanel)](#checking-account-credit-ippanel) +* [Handling Errors](#handling-errors) +* [Testing](#testing) +* [Contributing](#contributing) +* [License](#license) -Take a look at our [FAQ](http://laravel-notification-channels.com/) to see our small list of rules, to provide top-notch notification channels. +## Installation +Note: Until this package is officially accepted and published under laravel-notification-channels, please use the direct GitHub repository installation method. + +1- You can install the package via composer: + +```bash +composer require laravel-notification-channels/persian-sms +``` + +2- Install via GitHub (Before publishing to Packagist or for development): +If the package is not yet published on Packagist, or for testing/development purposes, you can install it directly from GitHub. + +First, add the repository definition to your project's composer.json file under the repositories section: + +```bash +"repositories": [ + { + "type": "vcs", + "url": "https://github.com/saman9074/persian-sms" + } +], +``` +Then, require the package: + +```bash +composer saman9074/persian-sms:dev-main +``` +## Configuration + +You can publish the config file with: + +```bash +php artisan vendor:publish --provider="NotificationChannels\PersianSms\PersianSmsServiceProvider" --tag="persian-sms-config" +``` + +This will create a config/persian-sms.php file in your project. This file allows you to configure the default SMS driver and settings for each supported driver. + +```bash +// config/persian-sms.php +return [ + 'default_driver' => env('PERSIAN_SMS_DRIVER', 'ippanel'), + + 'drivers' => [ + 'ippanel' => [ + 'api_key' => env('IPPANEL_API_KEY'), + 'sender_number' => env('IPPANEL_SENDER_NUMBER'), + // 'api_url' => 'https://api2.ippanel.com/api/v1', // Optional: if you need to override + ], + // ... other drivers like kavenegar will be added here + ], + + 'guzzle' => [ + 'timeout' => 10.0, + // ... other Guzzle options + ], +]; +``` +## IPPanel Configuration + +Add your IPPanel API Key and default Sender Number to your .env file: + +```bash +PERSIAN_SMS_DRIVER=ippanel +IPPANEL_API_KEY=your_ippanel_api_key_here +IPPANEL_SENDER_NUMBER=your_default_ippanel_sender_number_here +``` + +## Usage + +To send notifications, use the NotificationChannels\PersianSms\IPPanel\IPPanelChannel in your notification's via method. You will also need to define a toPersianSms method that returns an NotificationChannels\PersianSms\IPPanel\IPPanelMessage instance. + +```bash +namespace App\Notifications; + +use Illuminate\Bus\Queueable; +use Illuminate\Notifications\Notification; +use NotificationChannels\PersianSms\IPPanel\IPPanelChannel; +use NotificationChannels\PersianSms\IPPanel\IPPanelMessage; + +class OrderShipped extends Notification +{ + use Queueable; + + // ... constructor and other properties + + public function via($notifiable) + { + return [IPPanelChannel::class]; + } + + public function toPersianSms($notifiable) + { + return (new IPPanelMessage()) + ->content("Your order has been shipped!"); + } +} +``` +Routing SMS Notifications +Your notifiable model (e.g., App\Models\User) needs to implement a method to return the phone number(s) for the notification. The IPPanelChannel will look for these methods in the following order: + +1. routeNotificationForPersianSms($notification) +2. routeNotificationFor(IPPanelChannel::class, $notification) +3. routeNotificationForIPPanel($notification) +4. A phone_number attribute on the notifiable model. +5. A mobile attribute on the notifiable model. + +Example for User model: +```bash +namespace App\Models; + +use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; + +class User extends Authenticatable +{ + use Notifiable; + + // ... other model properties + + /** + * Route notifications for the PersianSms channel. + * + * @param \Illuminate\Notifications\Notification $notification + * @return string|array|null + */ + public function routeNotificationForPersianSms($notification) + { + return $this->phone_number; // Assuming 'phone_number' is a column in your users table + } +} +``` + +Available Message Methods (IPPanel) + +The IPPanelMessage class provides a fluent API to construct your message: + + * content(string $text): Sets the content for a normal SMS. + * pattern(string $patternCode, array $variables = []): Sets the message to be sent using a pattern, including its variables. + * from(string $senderNumber): Overrides the default sender number for this specific message. + * at(string $dateTimeString): Sets a scheduled time for sending the SMS (ISO 8601 format like "YYYY-MM-DDTHH:MM:SSZ"). Note: This is prepared in IPPanelMessage but not yet utilized by IPPanelChannel for actual scheduling in the API call. + +Sending a Simple Text Message (IPPanel) + +In your notification's toPersianSms() method: + +```bash +use NotificationChannels\PersianSms\IPPanel\IPPanelMessage; + +public function toPersianSms($notifiable) +{ + return (new IPPanelMessage()) + ->content('Your order has been shipped!'); +} +``` +Then, send the notification from your notifiable model: + +```bash +$user->notify(new OrderShipped($order)); +``` +Sending a Pattern-Based Message (IPPanel) + +If you are using IPPanel's pattern-based SMS, use the pattern() method. Pass the pattern code and an associative array of variables. + +```bash +use NotificationChannels\PersianSms\IPPanel\IPPanelMessage; + +public function toPersianSms($notifiable) +{ + $patternCode = 'your_ippanel_pattern_code'; // Replace with your actual pattern code + $variables = [ + 'name' => $notifiable->name, // Match variable names with your IPPanel pattern + 'order_id' => $this->order->id, + ]; + + return (new IPPanelMessage()) + ->pattern($patternCode, $variables); + // Do NOT use ->content() when sending a pattern-based message +} +``` +Replace your_ippanel_pattern_code with the code from your IPPanel panel and ensure the keys in the $variables array match the variable names defined in your pattern. + +Customizing the Sender (IPPanel) + +By default, the channel uses the IPPANEL_SENDER_NUMBER from your config. You can override this for a specific message using the from() method: + +```bash +use NotificationChannels\PersianSms\IPPanel\IPPanelMessage; + +public function toPersianSms($notifiable) +{ + return (new IPPanelMessage()) + ->content('Message from a custom sender.') + ->from('your_custom_sender_number'); // e.g., +983000XXXX +} +``` +Scheduling Messages (IPPanel - Note) + +The IPPanelMessage class has an at(string $dateTimeString) method to specify a future send time. However, the current version of IPPanelChannel.php does not yet pass this scheduled time to the IPPanel API. This feature can be implemented in a future update to the channel. + +```bash +use NotificationChannels\PersianSms\IPPanel\IPPanelMessage; + +public function toPersianSms($notifiable) +{ + $scheduledTime = "2025-12-31T23:59:59Z"; // Example ISO 8601 string + + return (new IPPanelMessage()) + ->content('This message is intended to be sent later.') + ->at($scheduledTime); // Note: Currently not implemented in IPPanelChannel API call +} +``` + +Checking Account Credit (IPPanel) + +The IPPanelChannel class provides a getCredit() method to check your IPPanel account balance. You can resolve the channel from the service container and call this method: + +```bash +use NotificationChannels\PersianSms\IPPanel\IPPanelChannel; +use NotificationChannels\PersianSms\Exceptions\CouldNotSendNotification; +use Illuminate\Support\Facades\Log; + +try { + $ippanelChannel = app(IPPanelChannel::class); + $creditData = $ippanelChannel->getCredit(); + Log::info('IPPanel Account Credit:', $creditData); +} catch (CouldNotSendNotification $e) { + Log::error('Failed to retrieve IPPanel credit: ' . $e->getMessage()); +} +``` + +Note: The exact structure of the returned $creditData array depends on the IPPanel API response for the credit check endpoint. + +## Handling Errors + +If the IPPanel API returns an error, the channel will throw a NotificationChannels\PersianSms\Exceptions\CouldNotSendNotification exception. You can catch this exception to handle errors gracefully: + +```bash +use NotificationChannels\PersianSms\Exceptions\CouldNotSendNotification; +use App\Notifications\OrderShipped; +use App\Models\User; +use Illuminate\Support\Facades\Log; + +$user = User::find(1); + +try { + $user->notify(new OrderShipped($order)); +} catch (CouldNotSendNotification $e) { + Log::error('Failed to send IPPanel SMS: ' . $e->getMessage(), [ + 'exception_code' => $e->getCode(), + ]); +} +``` +The exception message often contains details from the IPPanel API response. + +Available Methods on IPPanelMessage + + * IPPanelMessage::create(string $content = ''): Static factory method to create a simple text message. + * content(string $content): Set the message content for a normal SMS. + * pattern(string $patternCode, array $variables = []): Set the message to be sent using a pattern. + * variable(string $name, $value): (Alternative to passing all variables to pattern()) Set a single variable for a pattern message. + * from(string $senderNumber): Set a custom sender number for this message. + * at(string $dateTimeString): Set a scheduled send time (ISO 8601 format). Note: API call implementation pending in channel. + * isPattern(): bool: Checks if the message is configured as a pattern message. + +## Testing + +You can run the tests included with the package using: + +```bash +composer test +``` + +## Contributing + +Please see CONTRIBUTING for details. + +## License + +The MIT License (MIT). Please see License File for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..09035841 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "laravel-notification-channels/persian-sms", + "description": "Laravel Notification Channel for sending SMS via various Iranian SMS providers (e.g., IPPanel, Kavenegar).", + "homepage": "https://github.com/your-github-username/persian-sms", + "license": "MIT", + "authors": [ + { + "name": "Ali Abdi", + "email": "abdi9074@gmail.com", + "homepage": "https://github.com/saman9074", + "role": "Developer" + } + ], + "require": { + "php": ">=8.2", + "illuminate/notifications": "^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^10.0 || ^11.0 || ^12.0", + "guzzlehttp/guzzle": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^10.0", + "orchestra/testbench": "^7.0 || ^8.0 || ^9.0" + }, + "autoload": { + "psr-4": { + "NotificationChannels\\PersianSms\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "NotificationChannels\\PersianSms\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover", + "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "NotificationChannels\\PersianSms\\PersianSmsServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/persian-sms.php b/config/persian-sms.php new file mode 100644 index 00000000..1385d4c6 --- /dev/null +++ b/config/persian-sms.php @@ -0,0 +1,67 @@ + env('PERSIAN_SMS_DRIVER', 'ippanel'), + + /* + |-------------------------------------------------------------------------- + | SMS Driver Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the SMS drivers used by your application. + | You are free to add more drivers as needed. Each driver requires its + | own set of configuration options. + | + */ + + 'drivers' => [ + + 'ippanel' => [ + 'api_key' => env('IPPANEL_API_KEY'), + 'sender_number' => env('IPPANEL_SENDER_NUMBER'), // Your default line number + // 'api_url' => 'https://api2.ippanel.com/api/v1', // Optional: if you want to override + ], + + 'kavenegar' => [ // Example for a future driver + 'api_key' => env('KAVENEGAR_API_KEY'), + 'sender_number' => env('KAVENEGAR_SENDER_NUMBER'), + // ... other kavenegar specific settings + ], + + // Add other drivers here... + + ], + + /* + |-------------------------------------------------------------------------- + | Guzzle HTTP Client Options + |-------------------------------------------------------------------------- + | + | You can pass any Guzzle-specific request options here. + | For example, you might want to set a timeout. + | See Guzzle documentation for all available options. + | + */ + 'guzzle' => [ + 'timeout' => 10.0, // Request timeout in seconds + // 'connect_timeout' => 5.0, + // 'verify' => true, // SSL certificate verification + // 'proxy' => 'http://your-proxy.com:port', + ], + + ]; + \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..ccbe8032 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + ./tests + + + + + ./src + + + ./src/Exceptions + + + + + + + + + diff --git a/src/Exceptions/CouldNotSendNotification.php b/src/Exceptions/CouldNotSendNotification.php new file mode 100644 index 00000000..cdd89e22 --- /dev/null +++ b/src/Exceptions/CouldNotSendNotification.php @@ -0,0 +1,130 @@ +client = $client; + $this->apiKey = $apiKey; + $this->defaultSenderNumber = $defaultSenderNumber; + } + + /** + * Send the given notification. + * + * @param mixed $notifiable + * @param \Illuminate\Notifications\Notification $notification + * @return \Psr\Http\Message\ResponseInterface|null + * @throws \NotificationChannels\PersianSms\Exceptions\CouldNotSendNotification + */ + public function send($notifiable, Notification $notification) + { + $recipient = $this->getRecipient($notifiable, $notification); + + if (!$recipient) { + // No recipient found, do not proceed. + // You might want to log this event. + return null; + } + + // Ensure recipient is an array for IPPanel API + $recipients = is_array($recipient) ? $recipient : [$recipient]; + + // Resolve the message from the notification. + // It should return an IPPanelMessage instance. + $message = $notification->toPersianSms($notifiable); + + if (!$message instanceof IPPanelMessage) { + // If toPersianSms returns a string, we can try to create a default message + // or throw an error. For now, we expect an IPPanelMessage. + if (is_string($message)) { + // For simplicity, let's assume a string message means a normal SMS + // and we need to create an IPPanelMessage instance. + // This part depends on how you want to design the IPPanelMessage class. + // $message = IPPanelMessage::create($message); // Example + throw CouldNotSendNotification::invalidMessageObject("Message must be an instance of IPPanelMessage. String given: " . $message); + } + throw CouldNotSendNotification::invalidMessageObject($message); + } + + $sender = $message->sender ?: $this->defaultSenderNumber; // Use message sender or default + + if (empty(trim($sender))) { + throw CouldNotSendNotification::senderNotProvided(); + } + + $payload = []; + $endpoint = ''; + + if ($message->isPattern()) { + // Sending with pattern + if (empty($message->patternCode)) { + throw CouldNotSendNotification::missingPatternCode(); + } + if ($message->variables === null || !is_array($message->variables)) { + // IPPanel expects variables to be an object (associative array) + // even if empty for some patterns, or with actual values. + throw CouldNotSendNotification::invalidPatternVariables(); + } + + $endpoint = self::API_BASE_URL . self::ENDPOINT_SEND_PATTERN; + $payload = [ + 'code' => $message->patternCode, + 'sender' => $sender, + 'recipient' => $recipients[0], // Pattern send seems to be for a single recipient based on docs + 'variable' => (object) $message->variables, // Ensure it's an object (empty or with data) + ]; + } else { + // Sending normal SMS + if (empty(trim((string) $message->content))) { + throw CouldNotSendNotification::contentNotProvided(); + } + $endpoint = self::API_BASE_URL . self::ENDPOINT_SEND_SINGLE; + $payload = [ + 'recipient' => $recipients, + 'sender' => $sender, + 'message' => (string) $message->content, + ]; + // Optional: Add 'time' if IPPanelMessage supports it + // if ($message->time) { + // $payload['time'] = $message->time; // Format: "2025-03-21T09:12:50.824Z" + // } + } + + try { + $response = $this->client->post($endpoint, [ + 'headers' => [ + 'apiKey' => $this->apiKey, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => $payload, // Send data as JSON + ]); + + $statusCode = $response->getStatusCode(); + $responseBody = json_decode($response->getBody()->getContents(), true); + + // Check for successful status codes and IPPanel specific success status + if ($statusCode >= 200 && $statusCode < 300 && isset($responseBody['status']) && $responseBody['status'] === 'OK') { + // Optionally return the full response or just the message ID, etc. + // return $responseBody['data']['message_id'] ?? $response; + return $response; // Return the Guzzle response object for now + } + + // If not explicitly 'OK', treat as an error + $errorMessage = $responseBody['errorMessage'] ?? 'Unknown error from IPPanel.'; + if (is_array($errorMessage)) { // Sometimes error messages are arrays + $errorMessage = implode(', ', array_map( + function ($v, $k) { return sprintf("%s: %s", $k, implode('|', (array)$v)); }, + $errorMessage, + array_keys($errorMessage) + )); + } + throw CouldNotSendNotification::serviceRespondedWithAnError($errorMessage, $statusCode, $responseBody); + + } catch (RequestException $exception) { + // Guzzle specific exception + $response = $exception->getResponse(); + $statusCode = $response ? $response->getStatusCode() : 503; // 503 Service Unavailable if no response + $responseBody = $response ? json_decode($response->getBody()->getContents(), true) : null; + $errorMessage = $responseBody['errorMessage'] ?? $exception->getMessage(); + if (is_array($errorMessage)) { + $errorMessage = implode(', ', array_map( + function ($v, $k) { return sprintf("%s: %s", $k, implode('|',(array)$v)); }, + $errorMessage, + array_keys($errorMessage) + )); + } + throw CouldNotSendNotification::serviceRespondedWithAnError($errorMessage, $statusCode, $responseBody, $exception); + } catch (\Exception $exception) { + // Other general exceptions + throw CouldNotSendNotification::genericError($exception->getMessage(), $exception); + } + } + + /** + * Get the recipient's phone number(s). + * + * @param mixed $notifiable + * @param \Illuminate\Notifications\Notification $notification + * @return string|array|null + */ + protected function getRecipient($notifiable, Notification $notification) + { + // Standard Laravel way to get routing information for a channel + if ($route = $notifiable->routeNotificationFor('persianSms', $notification)) { + return $route; + } + if ($route = $notifiable->routeNotificationFor(static::class, $notification)) { + return $route; + } + + // Custom method for this specific channel (if user defines it on notifiable) + if (method_exists($notifiable, 'routeNotificationForIPPanel')) { + return $notifiable->routeNotificationForIPPanel($notification); + } + + // Fallback to common phone number attributes + if (isset($notifiable->phone_number)) { + return $notifiable->phone_number; + } + if (isset($notifiable->mobile)) { + return $notifiable->mobile; + } + + // If $notifiable itself is a string (phone number) or an array of strings + if (is_string($notifiable) || (is_array($notifiable) && count(array_filter($notifiable, 'is_string')) === count($notifiable))) { + return $notifiable; + } + + return null; + } + + /** + * (Optional) Method to check account credit. + * Not directly used by the send method but can be a utility for the package. + * + * @return array|null Parsed JSON response from credit check or null on failure. + * @throws CouldNotSendNotification + */ + public function getCredit(): ?array + { + $endpoint = self::API_BASE_URL . self::ENDPOINT_CHECK_CREDIT; + + try { + $response = $this->client->get($endpoint, [ + 'headers' => [ + 'apiKey' => $this->apiKey, + 'Accept' => 'application/json', + ], + ]); + + $statusCode = $response->getStatusCode(); + $responseBody = json_decode($response->getBody()->getContents(), true); + + if ($statusCode === 200 && isset($responseBody['status']) && $responseBody['status'] === 'OK') { + return $responseBody['data']; + } + + $errorMessage = $responseBody['errorMessage'] ?? 'Failed to retrieve credit information.'; + throw CouldNotSendNotification::serviceRespondedWithAnError($errorMessage, $statusCode, $responseBody); + + } catch (RequestException $exception) { + $response = $exception->getResponse(); + $statusCode = $response ? $response->getStatusCode() : 503; + $responseBody = $response ? json_decode($response->getBody()->getContents(), true) : null; + $errorMessage = $responseBody['errorMessage'] ?? $exception->getMessage(); + throw CouldNotSendNotification::serviceRespondedWithAnError($errorMessage, $statusCode, $responseBody, $exception); + } catch (\Exception $exception) { + throw CouldNotSendNotification::genericError("Failed to get credit: " . $exception->getMessage(), $exception); + } + } +} \ No newline at end of file diff --git a/src/IPPanel/IPPanelMessage.php b/src/IPPanel/IPPanelMessage.php new file mode 100644 index 00000000..233aae7d --- /dev/null +++ b/src/IPPanel/IPPanelMessage.php @@ -0,0 +1,152 @@ + 'value']. + * + * @var array|null + */ + public ?array $variables = null; + + /** + * The sender number (line number). + * If null, the default sender from config will be used. + * + * @var string|null + */ + public ?string $sender = null; + + /** + * (Optional) The scheduled time for sending the SMS. + * Format: "YYYY-MM-DDTHH:MM:SSZ" e.g., "2025-03-21T09:12:50.824Z" + * Note: IPPanel API documentation for single send shows this, + * but it's not yet implemented in IPPanelChannel. + * + * @var string|null + */ + public ?string $time = null; + + + /** + * Create a new message instance for normal SMS. + * + * @param string $content The text content of the SMS. + * @return static + */ + public static function create(string $content = ''): static + { + return new static($content); + } + + /** + * Constructor. + * + * @param string $content Initial content for a normal SMS. + */ + public function __construct(string $content = '') + { + if (!empty($content)) { + $this->content($content); + } + } + + /** + * Set the content of the SMS. + * This is for normal (non-pattern) messages. + * + * @param string $content + * @return $this + */ + public function content(string $content): self + { + $this->content = $content; + $this->patternCode = null; // Ensure it's not a pattern message if content is set + $this->variables = null; + return $this; + } + + /** + * Set the message to be sent using a pattern. + * + * @param string $patternCode The code of the pattern. + * @param array $variables Associative array of variables for the pattern. + * @return $this + */ + public function pattern(string $patternCode, array $variables = []): self + { + $this->patternCode = $patternCode; + $this->variables = $variables; + $this->content = null; // Ensure it's not a normal content message if pattern is set + return $this; + } + + /** + * Set a specific variable for the pattern. + * + * @param string $name Name of the variable. + * @param mixed $value Value of the variable. + * @return $this + */ + public function variable(string $name, $value): self + { + if ($this->variables === null) { + $this->variables = []; + } + $this->variables[$name] = $value; + return $this; + } + + /** + * Set the sender number (line number) for this message. + * Overrides the default sender number from the configuration. + * + * @param string $senderNumber + * @return $this + */ + public function from(string $senderNumber): self + { + $this->sender = $senderNumber; + return $this; + } + + /** + * Set the scheduled time for sending the SMS. + * Note: Ensure IPPanelChannel supports this if you use it. + * + * @param string $dateTimeString Format: "YYYY-MM-DDTHH:MM:SSZ" + * @return $this + */ + public function at(string $dateTimeString): self + { + $this->time = $dateTimeString; + return $this; + } + + /** + * Check if the message is configured to be sent using a pattern. + * + * @return bool + */ + public function isPattern(): bool + { + return !empty($this->patternCode); + } +} diff --git a/src/PersianSmsServiceProvider.php b/src/PersianSmsServiceProvider.php new file mode 100644 index 00000000..b9065228 --- /dev/null +++ b/src/PersianSmsServiceProvider.php @@ -0,0 +1,117 @@ +app->runningInConsole()) { + $this->publishes([ + $configPath => config_path('persian-sms.php'), + ], 'persian-sms-config'); + } + + // Optionally, merge the configuration + // This allows users to only define the options they want to override. + $this->mergeConfigFrom($configPath, 'persian-sms'); + } + + /** + * Register any application services. + * + * @return void + */ + public function register(): void + { + // Bind the IPPanelChannel into the service container. + // We'll resolve its dependencies (API key, sender, HttpClient) from the config. + $this->app->singleton(IPPanelChannel::class, function ($app) { // IPPanelChannel::class now refers to the imported class + $config = $app['config']['persian-sms.drivers.ippanel']; + + if (empty($config['api_key'])) { + throw CouldNotSendNotification::apiKeyNotProvided(); + } + + if (empty($config['sender_number'])) { + // You might want a specific exception for this or use a general one + throw CouldNotSendNotification::senderNotProvided(); + } + + return new IPPanelChannel( // This will use the imported IPPanelChannel + new HttpClient($app['config']['persian-sms.guzzle'] ?? []), // Pass Guzzle config + $config['api_key'], + $config['sender_number'] + // Optionally, you could pass the API URL from config too if needed: + // $config['api_url'] ?? 'https://api2.ippanel.com/api/v1' + ); + }); + + // Later, when you add more drivers (Kavenegar, etc.), you might have a factory + // or a more dynamic way to resolve the active SMS driver channel. + // For now, we are explicitly binding IPPanelChannel. + // + // Example of how you might bind a generic "PersianSmsChannel" that resolves + // to the configured driver: + /* + $this->app->singleton('persian.sms.channel', function ($app) { + $config = $app['config']['persian-sms']; + $defaultDriver = $config['default_driver'] ?? 'ippanel'; // e.g., 'ippanel', 'kavenegar' + $driverConfig = $config['drivers'][$defaultDriver] ?? null; + + if (!$driverConfig) { + throw new \InvalidArgumentException("SMS driver [{$defaultDriver}] is not configured."); + } + + switch ($defaultDriver) { + case 'ippanel': + if (empty($driverConfig['api_key']) || empty($driverConfig['sender_number'])) { + throw CouldNotSendNotification::apiKeyNotProvided(); // Or more specific + } + // Ensure you use the correct namespace if you go this route + // For example: use NotificationChannels\PersianSms\IPPanel\IPPanelChannel; + return new \NotificationChannels\PersianSms\IPPanel\IPPanelChannel( + new HttpClient($config['guzzle'] ?? []), + $driverConfig['api_key'], + $driverConfig['sender_number'] + ); + // case 'kavenegar': + // // return new KavenegarChannel(...); + // break; + default: + throw new \InvalidArgumentException("Unsupported SMS driver [{$defaultDriver}]."); + } + }); + */ + } + + /** + * Get the services provided by the provider. + * This is used for deferred loading. + * + * @return array + */ + public function provides(): array + { + return [ + IPPanelChannel::class, // This will use the imported class's ::class constant + // 'persian.sms.channel', // If you use the generic channel binding + ]; + } +} diff --git a/tests/IPPanelChannelTest.php b/tests/IPPanelChannelTest.php new file mode 100644 index 00000000..c4c217ab --- /dev/null +++ b/tests/IPPanelChannelTest.php @@ -0,0 +1,336 @@ +httpClientMock = Mockery::mock(HttpClient::class); + $this->config = $this->app['config']; + + $this->config->set('persian-sms.drivers.ippanel.api_key', 'test_api_key'); + $this->config->set('persian-sms.drivers.ippanel.sender_number', '+983000123'); + $this->config->set('persian-sms.guzzle.timeout', 5.0); + + // Re-create the channel directly for this test to inject the mock HttpClient + $this->channel = new IPPanelChannel( + $this->httpClientMock, + $this->config->get('persian-sms.drivers.ippanel.api_key'), + $this->config->get('persian-sms.drivers.ippanel.sender_number') + ); + } + + public function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * Override application service providers. + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageProviders($app) + { + return [ + PersianSmsServiceProvider::class, + ]; + } + + /** @test */ + public function it_can_send_a_normal_sms_message() + { + $messageContent = 'Test normal SMS content'; + $recipientNumber = '+989120000001'; + $senderNumber = $this->config->get('persian-sms.drivers.ippanel.sender_number'); + $apiKey = $this->config->get('persian-sms.drivers.ippanel.api_key'); + + $expectedPayload = [ + 'recipient' => [$recipientNumber], + 'sender' => $senderNumber, + 'message' => $messageContent, + ]; + + $mockedHttpResponse = new HttpResponse(200, [], json_encode(['status' => 'OK', 'data' => ['message_id' => '12345']])); + + $this->httpClientMock + ->shouldReceive('post') + ->once() + ->with( + 'https://api2.ippanel.com/api/v1/sms/send/webservice/single', + [ + 'headers' => [ + 'apiKey' => $apiKey, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => $expectedPayload, + ] + ) + ->andReturn($mockedHttpResponse); + + $notification = new TestNotificationWithMessage($messageContent); + $notifiable = new TestNotifiable(['persianSms' => $recipientNumber]); + + $response = $this->channel->send($notifiable, $notification); + + // Add PHPUnit assertion + $this->assertInstanceOf(HttpResponse::class, $response); + $this->assertSame($mockedHttpResponse, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function it_can_send_a_pattern_based_sms_message() + { + $patternCode = 'test_pattern_code'; + $variables = ['name' => 'John Doe', 'code' => '12345']; + $recipientNumber = '+989120000002'; + $senderNumber = $this->config->get('persian-sms.drivers.ippanel.sender_number'); + $apiKey = $this->config->get('persian-sms.drivers.ippanel.api_key'); + + $expectedPayload = [ + 'code' => $patternCode, + 'sender' => $senderNumber, + 'recipient' => $recipientNumber, + 'variable' => (object) $variables, + ]; + + $mockedHttpResponse = new HttpResponse(200, [], json_encode(['status' => 'OK', 'data' => ['message_id' => '67890']])); + + $this->httpClientMock + ->shouldReceive('post') + ->once() + ->with( + 'https://api2.ippanel.com/api/v1/sms/pattern/normal/send', + [ + 'headers' => [ + 'apiKey' => $apiKey, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => $expectedPayload, + ] + ) + ->andReturn($mockedHttpResponse); + + $notification = new TestNotificationWithPattern($patternCode, $variables); + $notifiable = new TestNotifiable(['persianSms' => $recipientNumber]); + + $response = $this->channel->send($notifiable, $notification); + + // Add PHPUnit assertion + $this->assertInstanceOf(HttpResponse::class, $response); + $this->assertSame($mockedHttpResponse, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function it_throws_exception_if_api_key_is_missing_when_resolved_via_provider() + { + $this->expectException(CouldNotSendNotification::class); + $this->expectExceptionMessage('IPPanel API key is missing or not configured.'); + + $this->config->set('persian-sms.drivers.ippanel.api_key', null); + $this->app->make(IPPanelChannel::class); // Attempt to resolve from container + } + + /** @test */ + public function it_throws_exception_if_sender_number_is_missing_when_resolved_via_provider() + { + $this->expectException(CouldNotSendNotification::class); + $this->expectExceptionMessage('Sender (originator/from number) was not provided in message or configuration.'); + + $this->config->set('persian-sms.drivers.ippanel.sender_number', null); + $this->app->make(IPPanelChannel::class); // Attempt to resolve from container + } + + + /** @test */ + public function it_throws_exception_on_http_error_from_service() + { + $this->expectException(CouldNotSendNotification::class); + // Adjusted to match the actual error message format from CouldNotSendNotification::serviceRespondedWithAnError + $this->expectExceptionMessage('SMS service responded with an error: "PERMISSION_DENIED" (Status Code: 401)'); + + + $errorResponseBody = ['status' => 'Error', 'code' => 401, 'errorMessage' => 'PERMISSION_DENIED']; + $mockedErrorHttpResponse = new HttpResponse(401, [], json_encode($errorResponseBody)); + + $this->httpClientMock + ->shouldReceive('post') + ->once() + ->andReturn($mockedErrorHttpResponse); + + $notification = new TestNotificationWithMessage('test content'); + $notifiable = new TestNotifiable(['persianSms' => '+989000000000']); + + $this->channel->send($notifiable, $notification); + } + + /** @test */ + public function it_uses_sender_from_message_if_provided() + { + $messageContent = 'Test with custom sender'; + $customSender = '+983000789'; + $recipientNumber = '+989120000003'; + $apiKey = $this->config->get('persian-sms.drivers.ippanel.api_key'); + + $expectedPayload = [ + 'recipient' => [$recipientNumber], + 'sender' => $customSender, + 'message' => $messageContent, + ]; + $mockedHttpResponse = new HttpResponse(200, [], json_encode(['status' => 'OK', 'data' => ['message_id' => '11223']])); + + $this->httpClientMock + ->shouldReceive('post') + ->once() + ->with( + 'https://api2.ippanel.com/api/v1/sms/send/webservice/single', // Be specific with URL if possible + Mockery::on(function ($argument) use ($expectedPayload, $apiKey) { + return $argument['json'] == $expectedPayload && + isset($argument['headers']['apiKey']) && + $argument['headers']['apiKey'] == $apiKey; + }) + ) + ->andReturn($mockedHttpResponse); + + $notification = new TestNotificationWithMessageAndCustomSender($messageContent, $customSender); + $notifiable = new TestNotifiable(['persianSms' => $recipientNumber]); + + $response = $this->channel->send($notifiable, $notification); + + // Add PHPUnit assertion + $this->assertInstanceOf(HttpResponse::class, $response); + $this->assertSame($mockedHttpResponse, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function it_throws_exception_if_message_is_not_ippanel_message_and_not_string() + { + $this->expectException(CouldNotSendNotification::class); + // Corrected expected message for array input + $this->expectExceptionMessage('The message object provided was invalid. Expected an instance of IPPanelMessage or a string, got Unknown.'); + + + $notification = new TestNotificationWithInvalidMessageObject(); + $notifiable = new TestNotifiable(['persianSms' => '+989120000000']); + + $this->channel->send($notifiable, $notification); + } + + /** @test */ + public function it_throws_exception_if_pattern_code_is_missing_for_pattern_message() + { + $this->expectException(CouldNotSendNotification::class); + // Corrected expected message based on current channel logic + $this->expectExceptionMessage('SMS content was not provided for a normal message.'); + + $notification = new TestNotificationWithPattern(null, ['var' => 'val']); + $notifiable = new TestNotifiable(['persianSms' => '+989120000000']); + + $this->channel->send($notifiable, $notification); + } + + /** @test */ + public function it_throws_exception_if_content_is_empty_for_normal_message() + { + $this->expectException(CouldNotSendNotification::class); + $this->expectExceptionMessage('SMS content was not provided for a normal message.'); + + $notification = new TestNotificationWithMessage(''); + $notifiable = new TestNotifiable(['persianSms' => '+989120000000']); + + $this->channel->send($notifiable, $notification); + } +} + +// --- Helper classes for testing (Ensure these are correctly namespaced if in separate files) --- + +class TestNotifiable +{ + use \Illuminate\Notifications\Notifiable; + + protected array $routes = []; + + public function __construct(array $routes = []) + { + $this->routes = $routes; + } + + public function routeNotificationFor($driver, $notification = null) + { + return $this->routes[$driver] ?? ($this->routes['*'] ?? null); + } + public function getKey() { return '1'; } +} + +class TestNotificationWithMessage extends Notification +{ + public string $message; + public function __construct(string $message) { $this->message = $message; } + public function via($notifiable): array { return [IPPanelChannel::class]; } + public function toPersianSms($notifiable): IPPanelMessage { return (new IPPanelMessage())->content($this->message); } +} + +class TestNotificationWithPattern extends Notification +{ + public ?string $patternCode; + public array $variables; + public function __construct(?string $patternCode, array $variables) { $this->patternCode = $patternCode; $this->variables = $variables; } + public function via($notifiable): array { return [IPPanelChannel::class]; } + public function toPersianSms($notifiable): IPPanelMessage + { + $message = new IPPanelMessage(); + if ($this->patternCode !== null) { + $message->pattern($this->patternCode, $this->variables); + } else { + // This setup makes isPattern() return false, leading to normal SMS path + $message->patternCode = null; + $message->variables = $this->variables; + // $message->content remains null + } + return $message; + } +} + +class TestNotificationWithMessageAndCustomSender extends Notification +{ + public string $message; + public string $sender; + public function __construct(string $message, string $sender) { $this->message = $message; $this->sender = $sender; } + public function via($notifiable): array { return [IPPanelChannel::class]; } + public function toPersianSms($notifiable): IPPanelMessage { return (new IPPanelMessage())->content($this->message)->from($this->sender); } +} + +class TestNotificationWithInvalidMessageObject extends Notification +{ + public function via($notifiable): array { return [IPPanelChannel::class]; } + public function toPersianSms($notifiable) { return ['this is not an IPPanelMessage object']; } // Intentionally wrong +} + diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..682b6dcd --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,61 @@ +loadMigrationsFrom(__DIR__ . '/../database/migrations'); + // $this->artisan('migrate', ['--database' => 'testing'])->run(); + } + + /** + * Get package providers. + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageProviders($app) + { + return [ + PersianSmsServiceProvider::class, + ]; + } + + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + // Setup default database to use sqlite :memory: + /* + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + */ + + // You can set specific config values for your tests here if needed, + // though phpunit.xml's variables often take precedence for env() calls in config files. + // Example: + // $app['config']->set('persian-sms.default_driver', 'ippanel'); + // $app['config']->set('persian-sms.drivers.ippanel.api_key', 'config_test_api_key'); + // $app['config']->set('persian-sms.drivers.ippanel.sender_number', '+98configsender'); + } +} From 6447f3be8d281fe06f2a4f95e378945b78eb79e6 Mon Sep 17 00:00:00 2001 From: saman9074 Date: Sat, 10 May 2025 02:15:06 +0330 Subject: [PATCH 2/4] Create laravel.yml --- .github/workflows/laravel.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/laravel.yml diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml new file mode 100644 index 00000000..c690791a --- /dev/null +++ b/.github/workflows/laravel.yml @@ -0,0 +1,35 @@ +name: Laravel + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + laravel-tests: + + runs-on: ubuntu-latest + + steps: + - uses: shivammathur/setup-php@15c43e89cdef867065b0213be354c2841860869e + with: + php-version: '8.0' + - uses: actions/checkout@v4 + - name: Copy .env + run: php -r "file_exists('.env') || copy('.env.example', '.env');" + - name: Install Dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + - name: Generate key + run: php artisan key:generate + - name: Directory Permissions + run: chmod -R 777 storage bootstrap/cache + - name: Create Database + run: | + mkdir -p database + touch database/database.sqlite + - name: Execute tests (Unit and Feature tests) via PHPUnit/Pest + env: + DB_CONNECTION: sqlite + DB_DATABASE: database/database.sqlite + run: php artisan test From b8a08276d6847d43f02cad6febe3d1ac1edf3fcc Mon Sep 17 00:00:00 2001 From: saman9074 Date: Sun, 8 Jun 2025 16:48:04 +0330 Subject: [PATCH 3/4] Update IPPanelChannel.php add to method for send custom number --- src/IPPanel/IPPanelChannel.php | 56 ++++++++++------------------------ 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/src/IPPanel/IPPanelChannel.php b/src/IPPanel/IPPanelChannel.php index 28d1dbde..0c47235f 100644 --- a/src/IPPanel/IPPanelChannel.php +++ b/src/IPPanel/IPPanelChannel.php @@ -70,34 +70,27 @@ public function __construct(HttpClient $client, string $apiKey, string $defaultS */ public function send($notifiable, Notification $notification) { - $recipient = $this->getRecipient($notifiable, $notification); + // Resolve the message from the notification first. + $message = $notification->toPersianSms($notifiable); + + if (!$message instanceof IPPanelMessage) { + if (is_string($message)) { + throw CouldNotSendNotification::invalidMessageObject("Message must be an instance of IPPanelMessage. String given: " . $message); + } + throw CouldNotSendNotification::invalidMessageObject($message); + } + + // Determine the recipient. Prioritize the one set on the message itself. + $recipient = $message->recipient ?: $this->getRecipient($notifiable, $notification); if (!$recipient) { // No recipient found, do not proceed. - // You might want to log this event. return null; } // Ensure recipient is an array for IPPanel API $recipients = is_array($recipient) ? $recipient : [$recipient]; - // Resolve the message from the notification. - // It should return an IPPanelMessage instance. - $message = $notification->toPersianSms($notifiable); - - if (!$message instanceof IPPanelMessage) { - // If toPersianSms returns a string, we can try to create a default message - // or throw an error. For now, we expect an IPPanelMessage. - if (is_string($message)) { - // For simplicity, let's assume a string message means a normal SMS - // and we need to create an IPPanelMessage instance. - // This part depends on how you want to design the IPPanelMessage class. - // $message = IPPanelMessage::create($message); // Example - throw CouldNotSendNotification::invalidMessageObject("Message must be an instance of IPPanelMessage. String given: " . $message); - } - throw CouldNotSendNotification::invalidMessageObject($message); - } - $sender = $message->sender ?: $this->defaultSenderNumber; // Use message sender or default if (empty(trim($sender))) { @@ -113,8 +106,6 @@ public function send($notifiable, Notification $notification) throw CouldNotSendNotification::missingPatternCode(); } if ($message->variables === null || !is_array($message->variables)) { - // IPPanel expects variables to be an object (associative array) - // even if empty for some patterns, or with actual values. throw CouldNotSendNotification::invalidPatternVariables(); } @@ -155,14 +146,10 @@ public function send($notifiable, Notification $notification) $statusCode = $response->getStatusCode(); $responseBody = json_decode($response->getBody()->getContents(), true); - // Check for successful status codes and IPPanel specific success status if ($statusCode >= 200 && $statusCode < 300 && isset($responseBody['status']) && $responseBody['status'] === 'OK') { - // Optionally return the full response or just the message ID, etc. - // return $responseBody['data']['message_id'] ?? $response; - return $response; // Return the Guzzle response object for now + return $response; } - // If not explicitly 'OK', treat as an error $errorMessage = $responseBody['errorMessage'] ?? 'Unknown error from IPPanel.'; if (is_array($errorMessage)) { // Sometimes error messages are arrays $errorMessage = implode(', ', array_map( @@ -174,12 +161,11 @@ function ($v, $k) { return sprintf("%s: %s", $k, implode('|', (array)$v)); }, throw CouldNotSendNotification::serviceRespondedWithAnError($errorMessage, $statusCode, $responseBody); } catch (RequestException $exception) { - // Guzzle specific exception $response = $exception->getResponse(); - $statusCode = $response ? $response->getStatusCode() : 503; // 503 Service Unavailable if no response + $statusCode = $response ? $response->getStatusCode() : 503; $responseBody = $response ? json_decode($response->getBody()->getContents(), true) : null; $errorMessage = $responseBody['errorMessage'] ?? $exception->getMessage(); - if (is_array($errorMessage)) { + if (is_array($errorMessage)) { $errorMessage = implode(', ', array_map( function ($v, $k) { return sprintf("%s: %s", $k, implode('|',(array)$v)); }, $errorMessage, @@ -188,7 +174,6 @@ function ($v, $k) { return sprintf("%s: %s", $k, implode('|',(array)$v)); }, } throw CouldNotSendNotification::serviceRespondedWithAnError($errorMessage, $statusCode, $responseBody, $exception); } catch (\Exception $exception) { - // Other general exceptions throw CouldNotSendNotification::genericError($exception->getMessage(), $exception); } } @@ -202,38 +187,29 @@ function ($v, $k) { return sprintf("%s: %s", $k, implode('|',(array)$v)); }, */ protected function getRecipient($notifiable, Notification $notification) { - // Standard Laravel way to get routing information for a channel if ($route = $notifiable->routeNotificationFor('persianSms', $notification)) { return $route; } if ($route = $notifiable->routeNotificationFor(static::class, $notification)) { return $route; } - - // Custom method for this specific channel (if user defines it on notifiable) if (method_exists($notifiable, 'routeNotificationForIPPanel')) { return $notifiable->routeNotificationForIPPanel($notification); } - - // Fallback to common phone number attributes if (isset($notifiable->phone_number)) { return $notifiable->phone_number; } if (isset($notifiable->mobile)) { return $notifiable->mobile; } - - // If $notifiable itself is a string (phone number) or an array of strings if (is_string($notifiable) || (is_array($notifiable) && count(array_filter($notifiable, 'is_string')) === count($notifiable))) { return $notifiable; } - return null; } /** * (Optional) Method to check account credit. - * Not directly used by the send method but can be a utility for the package. * * @return array|null Parsed JSON response from credit check or null on failure. * @throws CouldNotSendNotification @@ -270,4 +246,4 @@ public function getCredit(): ?array throw CouldNotSendNotification::genericError("Failed to get credit: " . $exception->getMessage(), $exception); } } -} \ No newline at end of file +} From 5699f8ce34ae30ec893d24968b96db7a706527fd Mon Sep 17 00:00:00 2001 From: saman9074 Date: Sun, 8 Jun 2025 16:48:38 +0330 Subject: [PATCH 4/4] Update IPPanelMessage.php add to method for send custom number --- src/IPPanel/IPPanelMessage.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/IPPanel/IPPanelMessage.php b/src/IPPanel/IPPanelMessage.php index 233aae7d..4b74b59c 100644 --- a/src/IPPanel/IPPanelMessage.php +++ b/src/IPPanel/IPPanelMessage.php @@ -1,5 +1,4 @@ |null + */ + public string|array|null $recipient = null; + /** * Create a new message instance for normal SMS. @@ -68,6 +75,19 @@ public function __construct(string $content = '') } } + /** + * Set the recipient's phone number(s). + * Overrides the recipient from the notifiable model. + * + * @param string|array $recipient A single phone number or an array of phone numbers. + * @return $this + */ + public function to(string|array $recipient): self + { + $this->recipient = $recipient; + return $this; + } + /** * Set the content of the SMS. * This is for normal (non-pattern) messages.