array( $to ), 'message' => $message, 'requestId' => $request_id, ); if ( null !== $expires_at ) { $payload['expiresAt'] = gmdate( 'Y-m-d\TH:i:s\Z', $expires_at ); } $body = self::request( 'POST', '/api/v1/gateway/send-sms', self::SEND_TIMEOUT, $payload ); if ( is_wp_error( $body ) ) { return $body; } $data = isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : array(); $batch_id = isset( $data['smsBatchId'] ) && is_scalar( $data['smsBatchId'] ) ? (string) $data['smsBatchId'] : ''; if ( '' === $batch_id ) { return self::fail( 'smsportal_unavailable', __( 'smsportal.app sent an unexpected answer.', '85pl-android-sms-api' ), 0 ); } return array( 'sms_batch_id' => $batch_id, 'duplicate' => ! empty( $data['duplicate'] ), ); } /** * Phone and usage state of the connection. Answers are cached for 60 seconds; so is * "smsportal.app unavailable" (other errors, e.g. a rejected key, are not). * * @return array|WP_Error */ public static function connection_info() { if ( ! Connection::is_connected() ) { return new WP_Error( 'smsportal_not_connected', __( 'This site is not connected to smsportal.app.', '85pl-android-sms-api' ) ); } $cached = get_transient( self::INFO_TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } $failed = get_transient( self::INFO_ERROR_TRANSIENT ); if ( is_string( $failed ) && '' !== $failed ) { return self::fail( 'smsportal_unavailable', $failed, 0 ); } $info = self::fetch_info(); if ( is_wp_error( $info ) ) { if ( 'smsportal_unavailable' === $info->get_error_code() ) { set_transient( self::INFO_ERROR_TRANSIENT, $info->get_error_message(), self::INFO_ERROR_TTL ); } return $info; } set_transient( self::INFO_TRANSIENT, $info, self::INFO_TTL ); return $info; } /** * Connection state from the cache only, without asking smsportal.app (for the screen header). * * @return array|null Null when nothing is cached. */ public static function cached_info(): ?array { $cached = get_transient( self::INFO_TRANSIENT ); return is_array( $cached ) ? $cached : null; } /** * Forget cached connection state (after connecting or disconnecting). * * @return void */ public static function forget_info(): void { delete_transient( self::INFO_TRANSIENT ); delete_transient( self::INFO_ERROR_TRANSIENT ); } /** * Ask smsportal.app for the connection state. * * @return array|WP_Error */ private static function fetch_info() { $body = self::request( 'GET', '/api/v1/integrations/connection', self::INFO_TIMEOUT ); if ( is_wp_error( $body ) ) { return $body; } $info = isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : $body; if ( empty( $info['connection_id'] ) && empty( $info['device_id'] ) ) { return self::fail( 'smsportal_unavailable', __( 'smsportal.app sent an unexpected answer.', '85pl-android-sms-api' ), 0 ); } return $info; } /** * Call the API with the connection key. * * @param string $method GET or POST. * @param string $path Path starting with /api. * @param int $timeout Seconds. * @param array|null $payload JSON body. * @return array|WP_Error Decoded JSON body of a 2xx answer. */ private static function request( string $method, string $path, int $timeout, ?array $payload = null ) { $api_key = Connection::api_key(); if ( null === $api_key ) { return new WP_Error( 'smsportal_not_connected', __( 'This site is not connected to smsportal.app.', '85pl-android-sms-api' ) ); } $args = array( 'method' => $method, 'timeout' => $timeout, 'redirection' => 0, 'headers' => array( 'x-api-key' => $api_key, 'Accept' => 'application/json', ), ); if ( null !== $payload ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $payload ); } $response = wp_remote_request( Connection::server_url() . $path, $args ); if ( is_wp_error( $response ) ) { return self::fail( 'smsportal_unavailable', /* translators: %s: network error, e.g. "cURL error 28: Operation timed out". */ sprintf( __( 'smsportal.app could not be reached (%s).', '85pl-android-sms-api' ), $response->get_error_message() ), 0 ); } $status = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $decoded = is_array( $decoded ) ? $decoded : array(); if ( $status >= 200 && $status < 300 ) { return $decoded; } return self::error_for_status( $status, $decoded ); } /** * Map a non-2xx answer to a WP_Error. * * @param int $status HTTP status. * @param array $decoded Decoded body. * @return WP_Error */ private static function error_for_status( int $status, array $decoded ): WP_Error { if ( 401 === $status ) { Connection::disconnect( 'server' ); self::forget_info(); ConnectionLog::add( 'auth_error', __( 'smsportal.app rejected the access key; the connection was removed.', '85pl-android-sms-api' ) ); return self::fail( 'smsportal_unauthorized', __( 'The connection to smsportal.app is no longer valid. Please connect again.', '85pl-android-sms-api' ), $status ); } if ( 429 === $status && ! empty( $decoded['hasReachedLimit'] ) ) { /* translators: %s: explanation from smsportal.app, e.g. "Your account has used its 50 messages…". */ $message = sprintf( __( 'The smsportal.app plan limit is reached: %s', '85pl-android-sms-api' ), self::reason( $decoded ) ); return self::fail( 'smsportal_limit_reached', $message, $status ); } if ( 429 === $status ) { return self::fail( 'smsportal_rate_limited', __( 'smsportal.app is receiving too many requests. Try again shortly.', '85pl-android-sms-api' ), $status ); } if ( $status >= 400 && $status < 500 && ! in_array( $status, self::RETRYABLE_4XX, true ) ) { /* translators: 1: HTTP status code, 2: reason from smsportal.app. */ $message = sprintf( __( 'smsportal.app rejected the request (HTTP %1$d): %2$s', '85pl-android-sms-api' ), $status, self::reason( $decoded ) ); ConnectionLog::add( 'send_error', $message ); return self::fail( 'smsportal_rejected', $message, $status ); } /* translators: %d: HTTP status code. */ return self::fail( 'smsportal_unavailable', sprintf( __( 'smsportal.app is temporarily unavailable (HTTP %d).', '85pl-android-sms-api' ), $status ), $status ); } /** * Reason given by smsportal.app ("error", else "message"), cleaned and shortened; "-" when none. * * @param array $decoded Decoded body. * @return string */ private static function reason( array $decoded ): string { $reason = isset( $decoded['error'] ) && is_string( $decoded['error'] ) ? $decoded['error'] : ''; if ( '' === $reason && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) { $reason = $decoded['message']; } $reason = mb_substr( sanitize_text_field( $reason ), 0, self::MAX_REASON_LENGTH ); return '' === $reason ? '-' : $reason; } /** * Build an error with its secrets removed. * * @param string $code Error code. * @param string $message Message. * @param int $status HTTP status (0 = none). * @return WP_Error */ private static function fail( string $code, string $message, int $status ): WP_Error { return new WP_Error( $code, self::redact( $message ), array( 'status' => $status ) ); } /** * Remove the API key (and anything shaped like one) from a text. * * @param string $text Text. * @return string */ private static function redact( string $text ): string { $secrets = array_filter( array( Connection::api_key(), Connection::webhook_secret() ) ); if ( $secrets ) { $text = str_replace( $secrets, '[redacted]', $text ); } return (string) preg_replace( '/txb_[A-Za-z0-9_\-]+/', '[redacted]', $text ); } }