token = $token; $this->base = self::base_url(); } /** * API base URL. */ public static function base_url(): string { if ( defined( self::BASE_CONSTANT ) && is_string( constant( self::BASE_CONSTANT ) ) && '' !== constant( self::BASE_CONSTANT ) ) { return untrailingslashit( (string) constant( self::BASE_CONSTANT ) ); } return self::DEFAULT_BASE; } /** * GET /v1/user/info: account name, email and SMS balance. * * @return array|WP_Error */ public function account() { $response = $this->request( 'GET', '/v1/user/info', null, array(), self::READ_TIMEOUT ); if ( is_wp_error( $response ) ) { return $response; } return is_array( $response['body']['data'] ?? null ) ? $response['body']['data'] : array(); } /** * POST /v1/sms/sendBulk. * * Used for every send (one message to one recipient included) because it * is the endpoint that honours Idempotency-Key: a retry after a timeout * replays the original answer instead of sending and billing again. * * @param array $messages Messages. * @param string $sender Approved sender. * @param string $idempotency_key Stable key for this batch. * @return array{run_id: string, replayed: bool}|WP_Error */ public function send_bulk( array $messages, string $sender, string $idempotency_key ) { $response = $this->request( 'POST', '/v1/sms/sendBulk', array( 'sender' => $sender, 'messages' => array_values( $messages ), ), array( 'Idempotency-Key' => $idempotency_key ), self::SEND_TIMEOUT ); if ( is_wp_error( $response ) ) { return $response; } $run_id = $response['body']['data']['runId'] ?? ''; if ( ! is_string( $run_id ) || '' === $run_id ) { return new WP_Error( 'vibrate_sms_bad_response', __( 'Vibrate accepted the request but did not return a run id.', 'vibrate-sms' ), array( 'status' => $response['status'], 'retryable' => false, ) ); } return array( 'run_id' => $run_id, 'replayed' => 'true' === strtolower( (string) wp_remote_retrieve_header( $response['raw'], 'idempotency-replayed' ) ), ); } /** * GET /v1/sms/run/{runId}/delivery-status. * * @param string $run_id Run id. * @return array|WP_Error */ public function delivery_status( string $run_id ) { $response = $this->request( 'GET', '/v1/sms/run/' . rawurlencode( $run_id ) . '/delivery-status', null, array(), self::READ_TIMEOUT ); if ( is_wp_error( $response ) ) { return $response; } return is_array( $response['body']['data'] ?? null ) ? $response['body']['data'] : array(); } /** * Whether retrying the same request can succeed. * * @param WP_Error $error Error from this client. */ public static function is_retryable( WP_Error $error ): bool { $data = $error->get_error_data(); return is_array( $data ) && ! empty( $data['retryable'] ); } /** * Words for the shop owner, in their language, for an API error. * * @param WP_Error $error Error from this client. * @param string $sender Sender used, for the invalid sender message. */ public static function describe( WP_Error $error, string $sender = '' ): string { $code = (string) $error->get_error_code(); $data = $error->get_error_data(); $hint = is_array( $data ) && ! empty( $data['hints'] ) ? Text::plain( (string) $data['hints'] ) : ''; switch ( $code ) { case 'ACCESS_TOKEN_MISSING': case 'ACCESS_TOKEN_INVALID': return __( 'Vibrate did not recognise the access key. Copy a key from Vibrate (Developers > API Keys) and save it again.', 'vibrate-sms' ); case 'ACCESS_TOKEN_INACTIVE': return __( 'This access key was turned off in Vibrate. Create a new key in Vibrate (Developers > API Keys).', 'vibrate-sms' ); case 'USER_INACTIVE': case 'USER_DELETED': return __( 'The Vibrate account behind this key is not active. Please contact Vibrate support.', 'vibrate-sms' ); case 'EMAIL_NOT_VERIFIED': return __( 'The Vibrate account email is not verified yet. Enter the code that was emailed to you in the Vibrate app, then try again.', 'vibrate-sms' ); case 'SUB_ACCOUNT_INACTIVE': return __( 'The sub-account this key belongs to was deleted. Create a new key in Vibrate.', 'vibrate-sms' ); case 'SMS_INVALID_SENDER': return sprintf( /* translators: %s: sender name or number. */ __( 'The sender "%s" is not an approved, active sender on this Vibrate account (or it belongs to a different sub-account). Check the sender in Vibrate (SMS > Sender IDs) and in the plugin settings.', 'vibrate-sms' ), $sender ); case 'SMS_INSUFFICIENT_CREDITS': return trim( __( 'Not enough SMS credits. Top up your Vibrate balance and try again.', 'vibrate-sms' ) . ' ' . $hint ); case 'SMS_DAILY_LIMIT_EXCEEDED': return trim( __( 'The daily SMS limit of the Vibrate account was reached.', 'vibrate-sms' ) . ' ' . $hint ); case 'SMS_NO_RECIPIENTS': return __( 'There was no recipient to send to.', 'vibrate-sms' ); case 'PAYLOAD_TOO_LARGE': return __( 'The message batch is too large. Send to fewer recipients at a time.', 'vibrate-sms' ); case 'CONFLICT': return __( 'The same message is already being processed by Vibrate. It will be checked again shortly.', 'vibrate-sms' ); case 'vibrate_sms_http_error': return __( 'Could not reach Vibrate. Check that this server can make outgoing HTTPS requests.', 'vibrate-sms' ) . ( $hint ? ' (' . $hint . ')' : '' ); case 'vibrate_sms_bad_response': return $error->get_error_message(); } $message = $hint ? $hint : $error->get_error_message(); return '' !== $message ? $message : __( 'Vibrate could not send the message.', 'vibrate-sms' ); } /** * Perform a request. * * @param string $method HTTP method. * @param string $path Path starting with "/". * @param array|null $body JSON body. * @param array $headers Extra headers. * @param int $timeout Seconds. * @return array{status: int, body: array, raw: array}|WP_Error */ private function request( string $method, string $path, ?array $body, array $headers, int $timeout ) { if ( '' === $this->token ) { return new WP_Error( 'ACCESS_TOKEN_MISSING', __( 'No Vibrate access key is configured.', 'vibrate-sms' ), array( 'status' => 0, 'retryable' => false, ) ); } $args = array( 'method' => $method, 'timeout' => $timeout, // Never follow a redirect: the Authorization header would go with it. 'redirection' => 0, 'sslverify' => true, 'user-agent' => 'VibrateSMS-WordPress/' . VIBRATE_SMS_VERSION . '; WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url( '/' ), 'headers' => array_merge( array( 'Authorization' => $this->token, 'Accept' => 'application/json', ), $headers ), ); if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json; charset=utf-8'; $args['body'] = wp_json_encode( $body ); if ( false === $args['body'] ) { return new WP_Error( 'vibrate_sms_encode_error', __( 'The message could not be encoded. It may contain invalid characters.', 'vibrate-sms' ), array( 'status' => 0, 'retryable' => false, ) ); } } $raw = wp_remote_request( $this->base . $path, $args ); if ( is_wp_error( $raw ) ) { return new WP_Error( 'vibrate_sms_http_error', $raw->get_error_message(), array( 'status' => 0, 'hints' => $raw->get_error_message(), // Timeouts and connection resets: the request may or may // not have arrived. Safe to retry because sends carry an // Idempotency-Key. 'retryable' => true, ) ); } $status = (int) wp_remote_retrieve_response_code( $raw ); $decoded = json_decode( (string) wp_remote_retrieve_body( $raw ), true ); $decoded = is_array( $decoded ) ? $decoded : array(); if ( $status >= 200 && $status < 300 ) { return array( 'status' => $status, 'body' => $decoded, 'raw' => $raw, ); } $code = isset( $decoded['code'] ) && is_string( $decoded['code'] ) && preg_match( '/^[A-Z0-9_]{2,64}$/', $decoded['code'] ) ? $decoded['code'] : 'HTTP_' . $status; $hints = ''; if ( isset( $decoded['hints'] ) ) { $hints = is_string( $decoded['hints'] ) ? $decoded['hints'] : (string) wp_json_encode( $decoded['hints'] ); } return new WP_Error( $code, isset( $decoded['message'] ) && is_string( $decoded['message'] ) ? $decoded['message'] : sprintf( 'HTTP %d', $status ), array( 'status' => $status, 'hints' => Text::plain( $hints ), // 409: same Idempotency-Key still in flight. 429/5xx: transient. // 3xx: a redirect we refused to follow, not transient. 'retryable' => 409 === $status || 429 === $status || $status >= 500, ) ); } }