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/.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 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) + { + // 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. + return null; + } + + // Ensure recipient is an array for IPPanel API + $recipients = is_array($recipient) ? $recipient : [$recipient]; + + $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)) { + 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); + + if ($statusCode >= 200 && $statusCode < 300 && isset($responseBody['status']) && $responseBody['status'] === 'OK') { + return $response; + } + + $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) { + $response = $exception->getResponse(); + $statusCode = $response ? $response->getStatusCode() : 503; + $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) { + 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) + { + if ($route = $notifiable->routeNotificationFor('persianSms', $notification)) { + return $route; + } + if ($route = $notifiable->routeNotificationFor(static::class, $notification)) { + return $route; + } + if (method_exists($notifiable, 'routeNotificationForIPPanel')) { + return $notifiable->routeNotificationForIPPanel($notification); + } + if (isset($notifiable->phone_number)) { + return $notifiable->phone_number; + } + if (isset($notifiable->mobile)) { + return $notifiable->mobile; + } + 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. + * + * @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); + } + } +} diff --git a/src/IPPanel/IPPanelMessage.php b/src/IPPanel/IPPanelMessage.php new file mode 100644 index 00000000..4b74b59c --- /dev/null +++ b/src/IPPanel/IPPanelMessage.php @@ -0,0 +1,172 @@ + '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; + + /** + * The recipient's phone number(s). + * If set, this will override the number from the notifiable model. + * + * @var string|array|null + */ + public string|array|null $recipient = 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 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. + * + * @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'); + } +}