(string) ( $state['secret'] ?? '' ), 'token' => (string) ( $state['token'] ?? '' ), 'url' => (string) ( $state['url'] ?? '' ), ); } /** * Whether Telegram calls this site, so messages can carry buttons. * * @return bool */ public static function active() { $state = self::state(); return Notifier::telegram_ready() && Ai::enabled() && '' !== $state['url'] && hash_equals( $state['token'], self::token_hash() ); } /** * Fingerprint of the bot token, to notice when it changed. * * @return string */ private static function token_hash() { return hash( 'sha256', (string) Settings::get( 'telegram_token' ) ); } /** * Connects again when the connection is missing, e.g. after the setup read the updates of the * bot, at most once an hour. */ public static function maybe_connect() { if ( self::active() || get_transient( 'dealroot_telegram_bot_tried' ) ) { return; } set_transient( 'dealroot_telegram_bot_tried', 1, HOUR_IN_SECONDS ); self::connect(); } /** * Tells Telegram where to send button presses, or stops it, after the settings changed. * Telegram only calls addresses with HTTPS. */ public static function connect() { /** * Filters the address Telegram sends button presses to, e.g. for a site behind a proxy. * * @param string $url REST address of the site. */ $url = (string) apply_filters( 'dealroot_telegram_webhook_url', rest_url( 'dealroot/v1' . self::ROUTE ) ); $wanted = Notifier::telegram_ready() && Ai::enabled() && 0 === strpos( $url, 'https://' ); $state = self::state(); if ( ! $wanted ) { if ( '' !== $state['url'] ) { self::disconnect(); } return; } if ( $state['url'] === $url && hash_equals( $state['token'], self::token_hash() ) ) { return; } $secret = '' !== $state['secret'] ? $state['secret'] : wp_generate_password( 48, false ); $result = self::api( 'setWebhook', array( 'url' => $url, 'secret_token' => $secret, 'allowed_updates' => wp_json_encode( array( 'callback_query', 'message' ) ), ) ); update_option( self::OPTION, array( 'secret' => $secret, 'token' => is_wp_error( $result ) ? '' : self::token_hash(), 'url' => is_wp_error( $result ) ? '' : $url, ), false ); } /** * Stops Telegram from calling this site, e.g. before the updates of the bot are read in the * setup, which Telegram only allows without it. */ public static function disconnect() { if ( preg_match( '/^\d+:[A-Za-z0-9_-]+$/', (string) Settings::get( 'telegram_token' ) ) ) { self::api( 'deleteWebhook', array() ); } delete_option( self::OPTION ); } /** * Buttons under the message about a new lead. * * @param object $lead Lead. * @return array|null Inline keyboard. */ public static function lead_buttons( $lead ) { if ( ! self::active() ) { return null; } $row = array(); // No answer is written to insults; the owner decides. if ( empty( $lead->ai_abusive ) && ( is_email( $lead->email ) || 0 === strpos( (string) $lead->phone, '+' ) ) ) { $row[] = array( 'text' => __( 'Write the answer', 'dealroot' ), 'callback_data' => 'draft:' . (int) $lead->id, ); } if ( Ai::is_foreign( $lead, self::language() ) ) { $row[] = array( 'text' => __( 'Translate', 'dealroot' ), 'callback_data' => 'translate:' . (int) $lead->id, ); } return $row ? array( 'inline_keyboard' => array( $row ) ) : null; } /** * Language of the site, which the messages in Telegram are written in. * * @return string */ private static function language() { return strtolower( substr( Ai::team_locale(), 0, 2 ) ); } /** * Route Telegram calls. */ public function routes() { register_rest_route( 'dealroot/v1', self::ROUTE, array( 'methods' => 'POST', 'callback' => array( $this, 'receive' ), 'permission_callback' => array( $this, 'from_telegram' ), ) ); } /** * Permission of the route: Telegram sends the secret this site set for its webhook with every * update, and requests without it are refused. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function from_telegram( $request ) { $secret = self::state()['secret']; $given = (string) $request->get_header( 'x_telegram_bot_api_secret_token' ); if ( '' === $secret || '' === $given || ! hash_equals( $secret, $given ) ) { return new \WP_Error( 'dealroot_forbidden', __( 'Only Telegram may send this.', 'dealroot' ), array( 'status' => 403 ) ); } return true; } /** * An update from Telegram: a button was pressed, or a draft was answered with a new text. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function receive( $request ) { $update = $request->get_json_params(); $update = is_array( $update ) ? $update : array(); Ai::in_locale( Ai::team_locale(), fn() => $this->handle( $update ) ); // Telegram sends an update again until it gets an answer. return new \WP_REST_Response( array( 'ok' => true ), 200 ); } /** * Acts on an update from the chat, in the language of the team. * * @param array $update Update. */ private function handle( array $update ) { if ( isset( $update['callback_query'] ) && is_array( $update['callback_query'] ) ) { $this->pressed( $update['callback_query'] ); } elseif ( isset( $update['message']['reply_to_message']['message_id'] ) && get_transient( 'dealroot_tg_message_' . (int) $update['message']['reply_to_message']['message_id'] ) ) { $this->replied( $update['message'] ); } elseif ( isset( $update['message'] ) && is_array( $update['message'] ) && self::own_chat( $update['message']['chat']['id'] ?? null ) ) { Telegram_Assistant::received( $update['message'] ); } } /** * Whether an update comes from the chat of the settings. * * @param mixed $chat_id Chat ID. * @return bool */ private static function own_chat( $chat_id ) { $own = (string) Settings::get( 'telegram_chat_id' ); return is_scalar( $chat_id ) && '' !== $own && (string) $chat_id === $own; } /** * A button was pressed. * * @param array $query Callback query. */ private function pressed( array $query ) { $id = (string) ( $query['id'] ?? '' ); if ( ! self::own_chat( $query['message']['chat']['id'] ?? null ) || ! preg_match( '/^(draft|send|translate|unlink):(\d+)$/', (string) ( $query['data'] ?? '' ), $match ) ) { self::api( 'answerCallbackQuery', array( 'callback_query_id' => $id ) ); return; } $lead = Leads::get( (int) $match[2] ); if ( ! $lead ) { self::api( 'answerCallbackQuery', array( 'callback_query_id' => $id, 'text' => __( 'This lead was deleted.', 'dealroot' ), ) ); return; } if ( 'unlink' === $match[1] ) { Telegram_Assistant::unlink_click( (int) $lead->id ); self::api( 'answerCallbackQuery', array( 'callback_query_id' => $id, 'text' => __( 'Done. The lead counts as a phone call without a source.', 'dealroot' ), ) ); if ( isset( $query['message']['message_id'] ) ) { self::api( 'editMessageReplyMarkup', array( 'chat_id' => Settings::get( 'telegram_chat_id' ), 'message_id' => (int) $query['message']['message_id'], 'reply_markup' => wp_json_encode( array( 'inline_keyboard' => array( array( self::open_button( $lead, __( 'Open lead', 'dealroot' ) ) ) ) ) ), ) ); } return; } if ( 'translate' === $match[1] ) { self::api( 'answerCallbackQuery', array( 'callback_query_id' => $id, 'text' => __( 'The AI assistant is translating. It comes in a moment.', 'dealroot' ), ) ); wp_schedule_single_event( time(), self::TRANSLATE_HOOK, array( (int) $lead->id ) ); spawn_cron(); return; } if ( 'draft' === $match[1] ) { self::api( 'answerCallbackQuery', array( 'callback_query_id' => $id, 'text' => __( 'The AI assistant is writing. The draft comes in a moment.', 'dealroot' ), ) ); // Written after this answer to Telegram, which does not wait for the model. wp_schedule_single_event( time(), self::DRAFT_HOOK, array( (int) $lead->id ) ); spawn_cron(); return; } $sent = self::send_draft( $lead ); self::api( 'answerCallbackQuery', array( 'callback_query_id' => $id, 'text' => is_wp_error( $sent ) ? $sent->get_error_message() : __( 'Sent.', 'dealroot' ), 'show_alert' => is_wp_error( $sent ) ? 'true' : 'false', ) ); if ( ! is_wp_error( $sent ) && isset( $query['message']['message_id'] ) ) { self::api( 'editMessageReplyMarkup', array( 'chat_id' => Settings::get( 'telegram_chat_id' ), 'message_id' => (int) $query['message']['message_id'], 'reply_markup' => wp_json_encode( array( 'inline_keyboard' => array( array( self::open_button( $lead, '✓ ' . __( 'Sent – open lead', 'dealroot' ) ) ) ) ) ), ) ); } } /** * The owner answered a draft with their own text: it becomes the draft. * * @param array $message Message. */ private function replied( array $message ) { if ( ! self::own_chat( $message['chat']['id'] ?? null ) ) { return; } $lead_id = (int) get_transient( 'dealroot_tg_message_' . (int) $message['reply_to_message']['message_id'] ); $lead = $lead_id ? Leads::get( $lead_id ) : null; $text = trim( sanitize_textarea_field( (string) ( $message['text'] ?? '' ) ) ); if ( ! $lead || mb_strlen( $text ) < 2 ) { return; } self::post_draft( $lead, $text ); } /** * Has the assistant write the draft and posts it to the chat. * * @param int $lead_id Lead ID. * @param string $hint What the owner wants to say, optional. */ public static function write_draft( $lead_id, $hint = '' ) { $lead = Leads::get( (int) $lead_id ); if ( ! $lead || ! self::active() ) { return; } $draft = Ai::draft( $lead, (string) $hint, self::sender() ); if ( is_wp_error( $draft ) ) { Notifier::send_telegram( Settings::get( 'telegram_token' ), Settings::get( 'telegram_chat_id' ), esc_html( $draft->get_error_message() ) ); return; } self::post_draft( $lead, $draft ); } /** * Has the assistant translate the enquiry into the language of the site and posts it. * * @param int $lead_id Lead ID. */ public static function write_translation( $lead_id ) { $lead = Leads::get( (int) $lead_id ); if ( ! $lead || ! self::active() ) { return; } $text = Ai::translate( $lead, self::language() ); if ( is_wp_error( $text ) ) { Notifier::send_telegram( Settings::get( 'telegram_token' ), Settings::get( 'telegram_chat_id' ), esc_html( $text->get_error_message() ) ); return; } $esc = static function ( $value ) { return htmlspecialchars( (string) $value, ENT_NOQUOTES, 'UTF-8' ); }; Notifier::send_telegram( Settings::get( 'telegram_token' ), Settings::get( 'telegram_chat_id' ), '' . $esc( sprintf( /* translators: 1: name of the person, 2: name of a language */ __( 'Enquiry of %1$s, translated from %2$s', 'dealroot' ), '' !== trim( (string) $lead->name ) ? $lead->name : $lead->email, Ai::language_name( (string) $lead->ai_lang ) ) ) . "\n\n" . $esc( $text ), self::lead_buttons_after_translation( $lead ) ); } /** * The answer button under a translation. * * @param object $lead Lead. * @return array|null */ private static function lead_buttons_after_translation( $lead ) { $buttons = self::lead_buttons( $lead ); if ( ! $buttons ) { return null; } $buttons['inline_keyboard'][0] = array_values( array_filter( $buttons['inline_keyboard'][0], static function ( $button ) { return 0 !== strpos( (string) ( $button['callback_data'] ?? '' ), 'translate:' ); } ) ); return $buttons['inline_keyboard'][0] ? $buttons : null; } /** * Posts a draft with the buttons to send it. * * @param object $lead Lead. * @param string $draft Text. */ private static function post_draft( $lead, $draft ) { set_transient( 'dealroot_tg_draft_' . (int) $lead->id, $draft, self::KEEP_DAYS * DAY_IN_SECONDS ); $esc = static function ( $text ) { return htmlspecialchars( (string) $text, ENT_NOQUOTES, 'UTF-8' ); }; $buttons = array(); if ( is_email( $lead->email ) ) { $buttons[] = array( array( /* translators: %s: email address */ 'text' => sprintf( __( 'Send to %s', 'dealroot' ), $lead->email ), 'callback_data' => 'send:' . (int) $lead->id, ), ); } if ( 0 === strpos( (string) $lead->phone, '+' ) ) { $buttons[] = array( array( 'text' => __( 'Send by WhatsApp', 'dealroot' ), 'url' => add_query_arg( 'text', rawurlencode( $draft ), Phone::whatsapp_url( (string) preg_replace( '/[^\d+]/', '', $lead->phone ) ) ), ), ); } $buttons[] = array( self::open_button( $lead, __( 'Open lead', 'dealroot' ) ) ); $text = '' . $esc( /* translators: %s: name of the person, or "the enquiry" */ sprintf( __( 'Answer to %s', 'dealroot' ), '' !== trim( (string) $lead->name ) ? $lead->name : $lead->email ) ) . "\n\n" . $esc( $draft ) . "\n\n" . $esc( __( 'Reply to this message with your own text to change it.', 'dealroot' ) ) . ''; $result = self::api( 'sendMessage', array( 'chat_id' => Settings::get( 'telegram_chat_id' ), 'text' => $text, 'parse_mode' => 'HTML', 'disable_web_page_preview' => 'true', 'reply_markup' => wp_json_encode( array( 'inline_keyboard' => $buttons ) ), ) ); if ( ! is_wp_error( $result ) && isset( $result['message_id'] ) ) { set_transient( 'dealroot_tg_message_' . (int) $result['message_id'], (int) $lead->id, self::KEEP_DAYS * DAY_IN_SECONDS ); } } /** * Sends the last draft of a lead to the person by email. * * @param object $lead Lead. * @return true|\WP_Error */ public static function send_draft( $lead ) { $draft = get_transient( 'dealroot_tg_draft_' . (int) $lead->id ); if ( ! is_string( $draft ) || '' === $draft ) { return new \WP_Error( 'dealroot_no_draft', __( 'This draft is too old. Write a new one.', 'dealroot' ) ); } if ( ! is_email( $lead->email ) ) { return new \WP_Error( 'dealroot_no_email', __( 'This lead has no email address.', 'dealroot' ) ); } $replies = Replies::all( $lead ); $subject = '' !== (string) ( $replies[0]['subject'] ?? '' ) ? Replies::fill( $replies[0]['subject'], $lead ) : __( 'Your enquiry', 'dealroot' ); if ( ! Ai::mail( $lead->email, $subject, $draft ) ) { return new \WP_Error( 'dealroot_mail', __( 'The email could not be sent. Check the email settings of your site.', 'dealroot' ) ); } delete_transient( 'dealroot_tg_draft_' . (int) $lead->id ); Messages::add( (int) $lead->id, 'out', 'telegram', $draft, array( 'subject' => $subject ) ); Impact::answered( (int) $lead->id, 'telegram' ); if ( 'new' === $lead->status ) { Leads::update( (int) $lead->id, array( 'status' => 'contacted' ) ); } /** * Fires after an answer was sent from Telegram. * * @param int $lead_id Lead ID. * @param string $draft Text sent. */ do_action( 'dealroot_telegram_answer_sent', (int) $lead->id, $draft ); return true; } /** * Calls the Telegram Bot API for other parts of Dealroot. * * @param string $method API method. * @param array $fields Fields. * @return array|\WP_Error Result. */ public static function call( $method, array $fields ) { return self::api( $method, $fields ); } /** * Fetches a file someone sent to the bot, e.g. a voice message. * * @param string $file_id Telegram file ID. * @param int $max_bytes Largest file accepted. * @return string|\WP_Error Bytes of the file. */ public static function download( $file_id, $max_bytes ) { $file = self::api( 'getFile', array( 'file_id' => (string) $file_id ) ); if ( is_wp_error( $file ) ) { return $file; } if ( empty( $file['file_path'] ) || (int) ( $file['file_size'] ?? 0 ) > $max_bytes ) { return new \WP_Error( 'dealroot_telegram_file', __( 'This file is too large.', 'dealroot' ) ); } $response = wp_remote_get( 'https://api.telegram.org/file/bot' . Settings::get( 'telegram_token' ) . '/' . ltrim( (string) $file['file_path'], '/' ), array( 'timeout' => 20, 'limit_response_size' => $max_bytes + 1, ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return new \WP_Error( 'dealroot_telegram_file', __( 'The file could not be fetched from Telegram. Please send it again.', 'dealroot' ) ); } $body = (string) wp_remote_retrieve_body( $response ); return strlen( $body ) > $max_bytes ? new \WP_Error( 'dealroot_telegram_file', __( 'This file is too large.', 'dealroot' ) ) : $body; } /** * Button that opens the lead in WordPress. * * @param object $lead Lead. * @param string $text Label. * @return array */ public static function open_button( $lead, $text ) { return array( 'text' => $text, 'url' => admin_url( 'admin.php?page=dealroot&lead=' . (int) $lead->id ), ); } /** * The person the answers are written for: the first administrator. * * @return \WP_User|null */ public static function sender() { $users = get_users( array( 'role' => 'administrator', 'number' => 1, 'orderby' => 'ID', ) ); return $users ? $users[0] : null; } /** * Calls the Telegram Bot API. * * @param string $method API method. * @param array $fields Fields. * @return array|\WP_Error Result. */ private static function api( $method, array $fields ) { $token = (string) Settings::get( 'telegram_token' ); if ( ! preg_match( '/^\d+:[A-Za-z0-9_-]+$/', $token ) ) { return new \WP_Error( 'dealroot_telegram', __( 'The bot token format is not valid.', 'dealroot' ) ); } $response = wp_remote_post( 'https://api.telegram.org/bot' . $token . '/' . $method, array( 'timeout' => 10, 'body' => $fields, ) ); if ( is_wp_error( $response ) ) { return $response; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['ok'] ) ) { return new \WP_Error( 'dealroot_telegram', (string) ( $body['description'] ?? __( 'Unknown Telegram error.', 'dealroot' ) ) ); } return is_array( $body['result'] ?? null ) ? $body['result'] : array(); } }