false`). * That turns HTTPS into decoration: anyone positioned between the site and the * provider could read the credentials and every one-time code sent through * them. Verification is on here, without an option to disable it. * 3. **No timeout at all.** A provider having a bad day would hold the PHP * worker until the request itself timed out, with the customer watching a * spinner. Every call here is bounded. * * Requests go through the WordPress HTTP API rather than raw cURL, so site-level * proxy settings and HTTP filters apply the way they do everywhere else. * * Only the gateway's own settings live here: the API key and the sender line. * Which approved pattern a feature sends with is that feature's setting — the * login code's pattern belongs to the login module, an order notification's to * the events screen — and arrives either on the message itself or through the * `rahkar_wp_sms_pattern` filter. */ final class IPPanel implements VoiceOtpDriver, CheckableDriver, PatternListDriver, ReportingDriver { public const ID = 'ippanel'; /** * Edge API root. Per the documentation at https://docs.ippanel.com. */ private const BASE_URL = 'https://edge.ippanel.com/v1'; /** * Seconds to wait on the provider. * * Short on purpose. For a login code the customer is watching the screen, and * a failure that moves on to the next gateway after eight seconds is a better * experience than a page that hangs for thirty. */ private const TIMEOUT = 8; /** * What a pattern code may look like, before it is put into a URL. */ private const PATTERN_CODE = '/^[A-Za-z0-9_-]{1,64}$/'; /** * {@inheritDoc} */ public function id(): string { return self::ID; } /** * {@inheritDoc} */ public function name(): string { return 'IPPanel'; } /** * {@inheritDoc} * * @param string $mode One of the MODE_* constants. */ public function supports( string $mode ): bool { return in_array( $mode, [ self::MODE_TEXT, self::MODE_PATTERN ], true ); } /** * {@inheritDoc} * * @param string $event Event name, e.g. `otp.login`. */ public function patternFor( string $event ): string { /** * Filters the approved pattern code used for an event. * * The per-event notification editor supplies codes through this filter, so * the driver never has to know that an events system exists. * * @param string $pattern Pattern code; empty sends the message body as free text. * @param string $event Event name, e.g. `order.processing.customer`. * @param string $driverId Driver id. */ return (string) apply_filters( 'rahkar_wp_sms_pattern', '', $event, $this->id() ); } /** * {@inheritDoc} */ public function isConfigured(): bool { return '' !== $this->apiKey() && '' !== $this->originator(); } /** * {@inheritDoc} * * @param Message $message Message to deliver. */ public function send( Message $message ): Result { if ( ! $this->isConfigured() ) { return Result::permanent( 'not_configured', __( 'تنظیمات درگاه IPPanel کامل نیست. کلید API و شماره فرستنده لازم است.', 'rahkar-toolkit' ) ); } $phone = PhoneNumber::parse( $message->to ); if ( null === $phone ) { return Result::permanent( 'invalid_recipient', __( 'شماره موبایل معتبر نیست.', 'rahkar-toolkit' ) ); } // A pattern named on the message wins: the caller chose it for this // gateway specifically. Otherwise the event's configured pattern, and with // neither, the body goes out as free text. $pattern = '' !== $message->pattern ? $message->pattern : $this->patternFor( $message->event ); return '' !== $pattern ? $this->sendPattern( $phone, $pattern, $message->variables ) : $this->sendText( $phone, $message->body ); } /** * {@inheritDoc} * * @param PhoneNumber $phone Recipient. * @param string $code Digits only, fewer than fifteen. */ public function sendVoiceOtp( PhoneNumber $phone, string $code ): Result { if ( ! $this->isConfigured() ) { return Result::permanent( 'not_configured', __( 'تنظیمات درگاه IPPanel کامل نیست.', 'rahkar-toolkit' ) ); } // The provider speaks whatever it is given; anything but a short run of // digits would be read out as nonsense, or refused after the call is billed. if ( 1 !== preg_match( '/^\d{1,14}$/', $code ) ) { return Result::permanent( 'invalid_code', __( 'کد تماس صوتی باید فقط رقم باشد.', 'rahkar-toolkit' ) ); } return $this->sendRequest( [ 'sending_type' => 'votp', 'message' => $code, 'params' => [ 'recipients' => [ $phone->e164() ] ], ] ); } /** * Blacklisted recipients of one send, from the recipient report. * * @param string $providerMessageId Outbox id the send returned. * * @return list|null Canonical numbers; null while the operator has not answered. */ public function blacklisted( string $providerMessageId ): ?array { if ( ! $this->isConfigured() || 1 !== preg_match( '/^\d{1,20}$/', $providerMessageId ) ) { return []; } // One recipient per send for every message this plugin queues; a page of a // hundred is room to spare. $response = $this->call( 'GET', '/api/report/recipients?page=1&per_page=100&bulk_id=' . $providerMessageId ); if ( $response instanceof Result ) { return $response->retryable ? null : []; } $rows = is_array( $response['data'] ?? null ) ? $response['data'] : []; $listed = []; $pending = false; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } // 0 sent to the operator, 1 operator received, 2 delivered, 3 not // delivered, 4 blacklisted. Until it moves past 0 the outcome is unknown. $status = (string) ( $row['message_status'] ?? '' ); if ( '0' === $status || '' === $status ) { $pending = true; } elseif ( '4' === $status ) { $phone = PhoneNumber::parse( (string) ( $row['recipient'] ?? '' ) ); if ( null !== $phone ) { $listed[] = $phone->canonical(); } } } return $pending && [] === $listed ? null : $listed; } /** * Successful sends between two moments, from the outbox report's total. * * @param int $from Unix time, inclusive. * @param int $to Unix time, inclusive. * * @return int|string The count, or the reason it could not be read. */ public function sentCount( int $from, int $to ): int|string { if ( ! $this->isConfigured() ) { return __( 'درگاه IPPanel تنظیم نشده است.', 'rahkar-toolkit' ); } $response = $this->call( 'POST', '/api/report/new_list', [ 'page' => 1, 'limit' => 1, 'filters' => [ 'send_from_date' => (string) $from, 'send_to_date' => (string) $to, // 6: sent successfully. 'state_id' => '6', ], ] ); if ( $response instanceof Result ) { return $response->error; } $total = $response['meta']['total'] ?? null; return is_numeric( $total ) ? (int) $total : __( 'پاسخ گزارش درگاه شمار کل را نداشت.', 'rahkar-toolkit' ); } /** * {@inheritDoc} */ public function checkConnection(): ConnectionCheck { if ( ! $this->isConfigured() ) { return ConnectionCheck::failed( __( 'اول کلید API و شماره فرستنده را وارد و ذخیره کنید.', 'rahkar-toolkit' ) ); } $response = $this->call( 'GET', '/api/payment/credit/mine' ); if ( $response instanceof Result ) { return ConnectionCheck::failed( $response->error ); } $credit = $response['data']['credit'] ?? null; if ( ! is_numeric( $credit ) ) { return ConnectionCheck::failed( __( 'اتصال برقرار شد ولی پاسخ سرویس‌دهنده اعتبار حساب را نداشت.', 'rahkar-toolkit' ) ); } return ConnectionCheck::ok( sprintf( /* translators: %s: remaining account credit */ __( 'اتصال برقرار است. اعتبار باقی‌مانده: %s', 'rahkar-toolkit' ), number_format_i18n( (float) $credit ) ) ); } /** * {@inheritDoc} * * @param string $code Pattern code. */ public function lookupPattern( string $code ): PatternInfo|string { $code = trim( $code ); if ( 1 !== preg_match( self::PATTERN_CODE, $code ) ) { return __( 'کد الگو فقط می‌تواند حرف انگلیسی، رقم، خط تیره و زیرخط داشته باشد.', 'rahkar-toolkit' ); } if ( ! $this->isConfigured() ) { return __( 'اول کلید API و شماره فرستنده را وارد و ذخیره کنید.', 'rahkar-toolkit' ); } $response = $this->call( 'GET', '/api/patterns/' . rawurlencode( $code ) ); // An unknown code, or one that belongs to another account, comes back as // a bare 403 with no message of its own; "rejected (code 403)" tells the // store owner nothing they can act on. if ( $response instanceof Result && 'rejected' === $response->errorCode ) { return __( 'الگویی با این کد در این پنل پیدا نشد. کد را از فهرست الگوهای پنل انتخاب کنید یا دوباره بررسی کنید.', 'rahkar-toolkit' ); } if ( $response instanceof Result ) { return $response->error; } return self::toPattern( is_array( $response['data'] ?? null ) ? $response['data'] : [], $code ); } /** * {@inheritDoc} * * Paged at the API's maximum of 100 and capped at ten pages: an account with * more than a thousand patterns is not one anyone picks from a list, and the * cap keeps a misbehaving `last_page` from looping forever. */ public function listPatterns(): array|string { if ( ! $this->isConfigured() ) { return __( 'اول کلید API و شماره فرستنده را وارد و ذخیره کنید.', 'rahkar-toolkit' ); } $active = []; $inactive = []; for ( $page = 1; $page <= 10; $page++ ) { $response = $this->call( 'GET', '/api/patterns?page=' . $page . '&per_page=100' ); if ( $response instanceof Result ) { return $response->error; } foreach ( (array) ( $response['data'] ?? [] ) as $row ) { // Voice and other kinds cannot carry an SMS; offering them would // only produce a rejected send later. if ( ! is_array( $row ) || ! in_array( (string) ( $row['type'] ?? 'sms' ), [ '', 'sms' ], true ) ) { continue; } $pattern = self::toPattern( $row, '' ); if ( '' === $pattern->code ) { continue; } if ( $pattern->isActive() ) { $active[] = $pattern; } else { $inactive[] = $pattern; } } $meta = is_array( $response['meta'] ?? null ) ? $response['meta'] : []; if ( $page >= (int) ( $meta['last_page'] ?? 1 ) ) { break; } } return array_merge( $active, $inactive ); } /** * One pattern row from the API. * * @param array $data The row. * @param string $fallback Code to use when the row has none. */ private static function toPattern( array $data, string $fallback ): PatternInfo { $variables = []; foreach ( (array) ( $data['variable'] ?? [] ) as $variable ) { if ( is_array( $variable ) && '' !== (string) ( $variable['name'] ?? '' ) ) { $variables[] = (string) $variable['name']; } } return new PatternInfo( (string) ( $data['pattern_code'] ?? $fallback ), (string) ( $data['pattern_message'] ?? '' ), $variables, (string) ( $data['pattern_status'] ?? '' ), (string) ( $data['title'] ?? '' ), (string) ( $data['pattern_status_fa'] ?? '' ) ); } /** * Send against an operator-approved template. * * The only mode that reliably delivers a one-time password: free text goes * over an advertising line, which operators block for every number on the * do-not-disturb list. * * @param PhoneNumber $phone Recipient. * @param string $pattern Approved pattern code. * @param array $variables Template variables, named as the pattern names them. */ private function sendPattern( PhoneNumber $phone, string $pattern, array $variables ): Result { $result = $this->sendRequest( [ 'sending_type' => 'pattern', 'from_number' => $this->originator(), 'code' => $pattern, 'recipients' => [ $phone->e164() ], 'params' => (object) array_map( 'strval', $variables ), ] ); // A pattern code the account does not own comes back as a bare 403 with no // message of its own, which `errorMessage()` can only report as "rejected // (code 403)". The same answer is already translated on the pattern-check // path; a failed send is where a store owner actually meets it, so it is // translated here too — with the offending code, because the log is read // long after the event and "which pattern?" is the first question. if ( ! $result->ok && 'rejected' === $result->errorCode && $this->isBareRejection( $result ) ) { return Result::permanent( 'unknown_pattern', sprintf( /* translators: %s: pattern code */ __( 'کد الگوی «%s» در این پنل پیامکی معتبر نیست. ممکن است پاک شده باشد، هنوز تأیید نشده باشد، یا متعلق به حساب دیگری باشد. الگو را از فهرست الگوهای همین رویداد دوباره انتخاب کنید.', 'rahkar-toolkit' ), $pattern ), $result->raw ); } return $result; } /** * Did the provider refuse without saying why? * * Only then is the guess above worth making. When the API does explain * itself — no credit, a blocked recipient, a bad parameter — its own Persian * wording is more accurate than anything inferred from a status code. * * @param Result $result Failed result. */ private function isBareRejection( Result $result ): bool { $meta = $result->raw['meta'] ?? []; return is_array( $meta ) && '' === trim( (string) ( $meta['message'] ?? '' ) ) && [] === (array) ( $meta['errors'] ?? [] ); } /** * Send free text over an advertising line. * * @param PhoneNumber $phone Recipient. * @param string $body Message text. */ private function sendText( PhoneNumber $phone, string $body ): Result { if ( '' === trim( $body ) ) { return Result::permanent( 'empty_body', __( 'متن پیامک خالی است.', 'rahkar-toolkit' ) ); } return $this->sendRequest( [ 'sending_type' => 'webservice', 'from_number' => $this->originator(), 'message' => $body, // Recipients sit *inside* `params` for a webservice send but at the // top level for a pattern send. That asymmetry is the API's, not a // mistake here — moving it would make the call fail validation. 'params' => [ 'recipients' => [ $phone->e164() ] ], ] ); } /** * One send call, reduced to a delivery result. * * @param array $payload Request body. */ private function sendRequest( array $payload ): Result { $response = $this->call( 'POST', '/api/send', $payload ); if ( $response instanceof Result ) { return $response; } return Result::success( $this->messageId( $response ), 0.0, $response ); } /** * Perform one API call. * * Returns the decoded body when the provider reports success, and a failed * {@see Result} otherwise — already sorted into worth-retrying and not, so * every caller classifies failures the same way. * * @param string $method HTTP method. * @param string $path Path under the API root. * @param array|null $payload JSON body, for POST. * * @return array|Result */ private function call( string $method, string $path, ?array $payload = null ): array|Result { $args = [ 'method' => $method, 'timeout' => self::TIMEOUT, // Never disabled. Turning this off would expose credentials and // every one-time code to anyone on the path. 'sslverify' => true, 'headers' => [ // The API key is the whole header value — no `Bearer`, no // `AccessKey` prefix. It travels in a header rather than the // URL so it stays out of access logs and proxy logs. 'Authorization' => $this->apiKey(), 'Accept' => 'application/json', ], ]; if ( null !== $payload ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = (string) wp_json_encode( $payload, JSON_UNESCAPED_UNICODE ); } $response = wp_remote_request( self::BASE_URL . $path, $args ); if ( is_wp_error( $response ) ) { // Transport failures are worth another attempt: DNS blips, dropped // connections, and provider restarts all land here. return Result::retryable( 'transport', $response->get_error_message() ); } $status = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $decoded = is_array( $decoded ) ? $decoded : []; // Everything meaningful lives under `meta`, including the real verdict: // the API can answer HTTP 200 with `meta.status = false`, so the status // code alone is not enough to know whether the call worked. $meta = is_array( $decoded['meta'] ?? null ) ? $decoded['meta'] : []; if ( $status >= 500 ) { return Result::retryable( 'provider_error', sprintf( /* translators: %d: HTTP status code */ __( 'خطای سرور سرویس‌دهنده (کد %d).', 'rahkar-toolkit' ), $status ), $decoded ); } if ( 429 === $status ) { return Result::retryable( 'rate_limited', __( 'محدودیت نرخ ارسال سرویس‌دهنده.', 'rahkar-toolkit' ), $decoded ); } if ( $status >= 400 || true !== ( $meta['status'] ?? null ) ) { // A bad token, an unknown pattern, an unapproved sender, or no credit. // None of these get better by trying again, so they are parked rather // than retried five times over the next half hour. return Result::permanent( $this->errorCode( $meta, $status ), $this->errorMessage( $meta, $status ), $decoded ); } return $decoded; } /** * The provider's outbox id, which makes a delivery traceable in their panel. * * @param array $decoded Decoded response. */ private function messageId( array $decoded ): string { $ids = $decoded['data']['message_outbox_ids'] ?? null; if ( is_array( $ids ) && [] !== $ids ) { return (string) reset( $ids ); } return ''; } /** * Machine-readable failure code. * * The API's own `message_code` — `400-1` for bad credentials, `400-2` for a * validation failure — is far more useful in a log than a generic label. * * @param array $meta Response `meta` block. * @param int $status HTTP status. */ private function errorCode( array $meta, int $status ): string { $code = (string) ( $meta['message_code'] ?? '' ); if ( '' !== $code ) { return $code; } return 401 === $status ? 'unauthorized' : 'rejected'; } /** * Best available error text from a failed response. * * The API answers in Persian, so its wording is passed straight through to * the store owner rather than being replaced with something vaguer. Field * level errors are appended, since those name the offending parameter. * * @param array $meta Response `meta` block. * @param int $status HTTP status. */ private function errorMessage( array $meta, int $status ): string { $message = trim( (string) ( $meta['message'] ?? '' ) ); $details = []; foreach ( (array) ( $meta['errors'] ?? [] ) as $field => $problems ) { foreach ( (array) $problems as $problem ) { $details[] = $field . ': ' . $problem; } } if ( [] !== $details ) { $message = trim( $message . ' (' . implode( '؛ ', $details ) . ')' ); } if ( '' !== $message ) { return $message; } return sprintf( /* translators: %d: HTTP status code */ __( 'درخواست توسط سرویس‌دهنده رد شد (کد %d).', 'rahkar-toolkit' ), $status ); } /** * Configured API key. */ private function apiKey(): string { return trim( (string) Settings::get( 'notifications.ippanel_api_key', '' ) ); } /** * Sender line, in the `+98…` form the API expects. * * Store owners copy the number out of their panel as `3000505` or `+983000505` * depending on where they read it. Both are the same line, and the API only * accepts one of them, so the shape is settled here rather than being a thing * to get wrong in a settings field. */ private function originator(): string { $raw = trim( (string) Settings::get( 'notifications.ippanel_from', '' ) ); if ( '' === $raw ) { return ''; } $digits = (string) preg_replace( '/\D+/', '', Normaliser::digits( $raw ) ); if ( '' === $digits ) { return ''; } if ( str_starts_with( $digits, '0098' ) ) { $digits = substr( $digits, 4 ); } elseif ( str_starts_with( $digits, '98' ) && strlen( $digits ) > 8 ) { $digits = substr( $digits, 2 ); } return '+98' . ltrim( $digits, '0' ); } /** * {@inheritDoc} */ public function settingsSchema(): array { return [ 'ippanel_api_key' => [ 'type' => 'password', 'label' => __( 'کلید API', 'rahkar-toolkit' ), 'help' => __( 'از پنل IPPanel، بخش کلید وب‌سرویس. کلید در هدر درخواست ارسال می‌شود و در آدرس قرار نمی‌گیرد.', 'rahkar-toolkit' ), 'default' => '', ], 'ippanel_from' => [ 'type' => 'text', 'label' => __( 'شماره فرستنده', 'rahkar-toolkit' ), 'help' => __( 'برای ارسال کد ورود باید خط خدماتی باشد، نه تبلیغاتی.', 'rahkar-toolkit' ), 'default' => '', ], ]; } }