prefix . self::TABLE; } /** * CREATE TABLE statement for dbDelta. * * @return string */ public static function schema(): string { global $wpdb; $table = self::table(); $charset = $wpdb->get_charset_collate(); return "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, received_at datetime NOT NULL, sender varchar(32) NOT NULL, text text NOT NULL, message_id bigint(20) unsigned DEFAULT NULL, PRIMARY KEY (id), KEY sender (sender), KEY received_at (received_at) ) {$charset};"; } /** * Handle an incoming reply: store it, block STOP senders, note it on the order and schedule the * e-mail to the owner (sent by a background job, so the webhook answers quickly). * * @param string $sender Number as smsportal sends it. * @param string $text SMS text. * @param string $received_at ISO 8601 time. * @return int Reply ID (0 when it could not be stored). */ public static function receive( string $sender, string $text, string $received_at ): int { $phone = self::sender_number( $sender ); $sent = null === $phone ? null : Messages::latest_for_recipient( $phone ); $id = self::insert( $sender, $phone, $sent, $text, $received_at ); $blocked = self::is_stop( $text ) && Blocklist::add( $sender, 'stop' ); $order = self::order_of( $sent ); if ( null !== $order ) { /* translators: %s: text of the customer's SMS. */ $note = $order->add_order_note( sprintf( __( 'SMS reply from the customer: %s', '85pl-android-sms-api' ), esc_html( $text ) ) ); if ( $note > 0 ) { add_comment_meta( (int) $note, self::NOTE_META, 1, true ); } } if ( $id > 0 && self::mail_enabled() ) { Queue::schedule_hook( self::MAIL_HOOK, array( $id, $blocked ? 1 : 0 ), time() ); } return $id; } /** * Job: e-mail the owner about a stored reply. * * @param int|string $reply_id Reply ID. * @param int|string $blocked 1 when the reply put the number on the block list. * @return void */ public static function send_mail( $reply_id, $blocked = 0 ): void { $reply = self::get( absint( $reply_id ) ); if ( null === $reply || ! self::mail_enabled() ) { return; } $sent = null === $reply['message_id'] ? null : Messages::get( (int) $reply['message_id'] ); self::mail_owner( (string) $reply['sender'], (string) $reply['text'], self::order_of( $sent ), 1 === absint( $blocked ) ); } /** * One reply. * * @param int $id Reply ID. * @return array|null */ public static function get( int $id ): ?array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}smsportal_replies WHERE id = %d", $id ), ARRAY_A ); return is_array( $row ) ? $row : null; } /** * Whether the owner wants an e-mail about each reply. * * @return bool */ private static function mail_enabled(): bool { return '0' !== (string) get_option( self::EMAIL_OPTION, '1' ); } /** * Store a reply, linked to the newest SMS this site sent to that number. * * @param string $sender Number as smsportal sends it. * @param string $text SMS text. * @param string $received_at ISO 8601 time. * @return int Reply ID (0 when the insert failed). */ public static function record( string $sender, string $text, string $received_at ): int { $phone = self::sender_number( $sender ); $sent = null === $phone ? null : Messages::latest_for_recipient( $phone ); return self::insert( $sender, $phone, $sent, $text, $received_at ); } /** * Newest replies first, with the label of the SMS they answer. * * @param int $limit Rows. * @param int $offset Rows to skip. * @return array> */ public static function recent( int $limit, int $offset = 0 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom tables, the list must be current. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT r.id, r.received_at, r.sender, r.text, r.message_id, m.event_label, m.object_type, m.object_id FROM {$wpdb->prefix}smsportal_replies r LEFT JOIN {$wpdb->prefix}smsportal_messages m ON m.id = r.message_id ORDER BY r.id DESC LIMIT %d OFFSET %d", max( 1, $limit ), max( 0, $offset ) ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Number of replies. * * @return int */ public static function count(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}smsportal_replies" ); } /** * Insert the reply row. * * @param string $sender Sender as received. * @param string|null $phone Sender in E.164, or null. * @param array|null $sent Newest SMS to that number. * @param string $text SMS text. * @param string $received_at ISO 8601 time. * @return int Reply ID (0 when the insert failed). */ private static function insert( string $sender, ?string $phone, ?array $sent, string $text, string $received_at ): int { global $wpdb; $time = strtotime( $received_at ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table. $inserted = $wpdb->insert( self::table(), array( 'received_at' => false === $time ? current_time( 'mysql', true ) : gmdate( 'Y-m-d H:i:s', $time ), 'sender' => null === $phone ? mb_substr( sanitize_text_field( $sender ), 0, self::MAX_SENDER_LENGTH ) : $phone, 'text' => mb_substr( wp_check_invalid_utf8( $text, true ), 0, self::MAX_TEXT_LENGTH ), 'message_id' => null === $sent ? null : (int) $sent['id'], ) ); return false === $inserted ? 0 : (int) $wpdb->insert_id; } /** * Whether the whole message is an opt-out word (case, diacritics and outer spaces ignored). * * @param string $text SMS text. * @return bool */ public static function is_stop( string $text ): bool { $plain = trim( (string) preg_replace( '/\s+/u', ' ', remove_accents( $text ) ) ); return in_array( strtoupper( $plain ), self::STOP_WORDS, true ); } /** * Sender in E.164, or null when it is not a phone number (e.g. a name). * * @param string $sender Sender. * @return string|null */ private static function sender_number( string $sender ): ?string { return Phone::normalize( $sender, Phone::default_country_code() ); } /** * WooCommerce order the earlier SMS was about. * * @param array|null $sent Messages row. * @return \WC_Order|null */ private static function order_of( ?array $sent ) { if ( null === $sent || 'wc_order' !== $sent['object_type'] || ! function_exists( 'wc_get_order' ) ) { return null; } $order = wc_get_order( absint( $sent['object_id'] ) ); return $order instanceof \WC_Order ? $order : null; } /** * Plain-text e-mail to the site owner. The SMS text goes only into the body, never the headers. * * @param string $sender Sender. * @param string $text SMS text. * @param \WC_Order|null $order Order the earlier SMS was about. * @param bool $blocked Whether the reply put the number on the block list. * @return void */ private static function mail_owner( string $sender, string $text, $order, bool $blocked ): void { $lines = array( /* translators: %s: phone number. */ sprintf( __( 'A customer replied by SMS from %s:', '85pl-android-sms-api' ), $sender ), '', $text, '', ); if ( $blocked ) { $lines[] = __( 'This reply is an opt-out word: the number will not get SMS from this site any more.', '85pl-android-sms-api' ); } $lines[] = null === $order /* translators: %s: address of the replies screen. */ ? sprintf( __( 'All replies: %s', '85pl-android-sms-api' ), Menu::tab_url( 'replies' ) ) /* translators: %s: address of the order screen. */ : sprintf( __( 'Order: %s', '85pl-android-sms-api' ), $order->get_edit_order_url() ); // The SMS text is not HTML: keep the mail plain even if another plugin switches mail to HTML. $plain = static function () { return 'text/plain'; }; add_filter( 'wp_mail_content_type', $plain, PHP_INT_MAX ); try { wp_mail( (string) get_option( 'admin_email' ), __( 'SMS reply from a customer', '85pl-android-sms-api' ), implode( "\n", $lines ), array( 'Content-Type: text/plain; charset=UTF-8' ) ); } finally { remove_filter( 'wp_mail_content_type', $plain, PHP_INT_MAX ); } } }