(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();
}
}