Keyed by source id. */ function abds_ln_source_defs() { return array( 'idance' => array( 'iDance Site Builder', function () { return post_type_exists( 'lead' ) || function_exists( 'isb_neborak_active' ); }, ), 'cf7' => array( 'Contact Form 7', function () { return defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7' ); }, ), 'wpforms' => array( 'WPForms', function () { return function_exists( 'wpforms' ) || defined( 'WPFORMS_VERSION' ); }, ), 'fluent' => array( 'Fluent Forms', function () { return defined( 'FLUENTFORM_VERSION' ) || function_exists( 'wpFluentForm' ); }, ), 'gravity' => array( 'Gravity Forms', function () { return class_exists( 'GFForms' ) || class_exists( 'GFCommon' ); }, ), ); } /** * Whether a form source is enabled in settings (defaults to on when the * option was never saved). * * @param string $key Source id, see abds_ln_source_defs(). * @return bool */ function abds_ln_source_on( $key ) { $s = get_option( ABDS_LN_OPT_SOURCES, null ); if ( ! is_array( $s ) ) { return true; } return ! empty( $s[ $key ] ); } /** * Turns a raw form-field key (snake_case/kebab-case) into a human-readable * title-cased label for the Telegram message. * * @param string $key Raw field key. * @return string */ function abds_ln_pretty( $key ) { $key = trim( preg_replace( '/\s+/', ' ', str_replace( array( '-', '_' ), ' ', (string) $key ) ) ); return function_exists( 'mb_convert_case' ) ? mb_convert_case( $key, MB_CASE_TITLE ) : ucfirst( $key ); } /** * Resolves a source URL to the page it points at (title when it's a known * post, or the site name for the home page). * * @param string $url Raw source URL. * @return array{url:string,title:string} */ function abds_ln_source_meta( $url ) { $url = $url ? esc_url_raw( $url ) : ''; $title = ''; if ( $url ) { $pid = url_to_postid( $url ); if ( $pid ) { $title = get_the_title( $pid ); } elseif ( trailingslashit( $url ) === trailingslashit( home_url( '/' ) ) ) { $title = get_bloginfo( 'name' ); } } return array( 'url' => $url, 'title' => $title, ); } /** * Formats and sends one lead notification to Telegram, if the feature is * enabled and a bot token + chat id are configured. A no-op otherwise. * * @param string $form_label Human label for the form (e.g. its title). * @param string $source_url Page the lead was submitted from, or ''. * @param array $fields Submitted field values, keyed by label. */ function abds_ln_notify( $form_label, $source_url, $fields ) { if ( '1' !== (string) get_option( ABDS_LN_OPT_ENABLED, '1' ) ) { return; } $token = trim( (string) get_option( ABDS_LN_OPT_TOKEN, '' ) ); $chat = trim( (string) get_option( ABDS_LN_OPT_CHAT, '' ) ); if ( '' === $token || '' === $chat || empty( $fields ) ) { return; } if ( ! $source_url ) { $source_url = wp_get_referer(); if ( ! $source_url && ! empty( $_SERVER['HTTP_REFERER'] ) ) { $source_url = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); } } $src = abds_ln_source_meta( (string) $source_url ); $lines = array(); // The bell is purely decorative — kept outside the translatable string so // a translator can't accidentally lose or mistranslate it. $lines[] = '🔔 ' . sprintf( /* translators: %s: site name. */ __( 'New lead — %s', 'abds-connect' ), get_bloginfo( 'name' ) ); if ( '' !== (string) $form_label ) { $lines[] = sprintf( /* translators: %s: the form's name/title. */ __( 'Form: %s', 'abds-connect' ), $form_label ); } if ( $src['url'] ) { $lines[] = $src['title'] ? sprintf( /* translators: 1: page title, 2: page URL. */ __( 'Page: %1$s — %2$s', 'abds-connect' ), $src['title'], $src['url'] ) : sprintf( /* translators: %s: page URL. */ __( 'Page: %s', 'abds-connect' ), $src['url'] ); } $lines[] = sprintf( /* translators: %s: date and time. */ __( 'Time: %s', 'abds-connect' ), wp_date( 'Y-m-d H:i' ) ); $lines[] = '——————————'; foreach ( $fields as $label => $val ) { $val = trim( (string) $val ); if ( '' !== $val ) { $lines[] = $label . ': ' . $val; } } $text = implode( "\n", $lines ); if ( function_exists( 'mb_strlen' ) && mb_strlen( $text ) > 4000 ) { $text = mb_substr( $text, 0, 4000 ) . '…'; } abds_ln_send( $token, $chat, $text ); } /** * Sends one message via the Telegram Bot API's sendMessage endpoint. * * @param string $token Telegram bot token. * @param string $chat Telegram chat id. * @param string $text Message text. * @return true|WP_Error True on a 2xx response, WP_Error otherwise. */ function abds_ln_send( $token, $chat, $text ) { $resp = wp_remote_post( 'https://api.telegram.org/bot' . $token . '/sendMessage', array( 'timeout' => 15, 'redirection' => 0, 'body' => array( 'chat_id' => $chat, 'text' => $text, 'disable_web_page_preview' => true, ), ) ); if ( is_wp_error( $resp ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic for a failed Telegram lead delivery. error_log( '[abds-connect][lead] ' . $resp->get_error_message() ); } return $resp; } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code < 200 || $code >= 300 ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic for a non-2xx Telegram API response. error_log( '[abds-connect][lead] Telegram HTTP ' . $code . ': ' . wp_remote_retrieve_body( $resp ) ); } return new WP_Error( 'tg_http', 'HTTP ' . $code ); } return true; } /* -------- Стан підключення бота (getMe) -------- */ /** * Pure parser for a Telegram `getMe` HTTP response into a normalized status. * Mirrors ABDS_License_Client_V2::parse_response()'s (transport_error, code, * body) shape so it stays testable without a WordPress bootstrap. * * @param bool $transport_error Whether the HTTP request itself failed (network/DNS/etc). * @param int $code HTTP status code (0 if unknown). * @param string $body Raw response body. * @return array{ok:bool,username:?string,error:?string} */ function abds_ln_parse_telegram_getme( $transport_error, $code, $body ) { if ( $transport_error ) { return array( 'ok' => false, 'username' => null, 'error' => 'transport', ); } if ( 200 !== $code ) { $data = json_decode( (string) $body, true ); $desc = ( is_array( $data ) && isset( $data['description'] ) ) ? (string) $data['description'] : ( 'HTTP ' . $code ); return array( 'ok' => false, 'username' => null, 'error' => $desc, ); } $data = json_decode( (string) $body, true ); if ( ! is_array( $data ) || empty( $data['ok'] ) || empty( $data['result']['username'] ) ) { return array( 'ok' => false, 'username' => null, 'error' => 'invalid_response', ); } return array( 'ok' => true, 'username' => (string) $data['result']['username'], 'error' => null, ); } /** * The bot's live connection status (getMe), cached per bot token so normal * page loads never hit the Telegram API — only a token change (synchronous, * for immediate feedback) or the twice-daily background re-check do. * * @param bool $force Bypass the cache and re-check now. * @return array{ok:bool,username:?string,error:?string} */ function abds_ln_telegram_status( $force = false ) { $token = trim( (string) get_option( ABDS_LN_OPT_TOKEN, '' ) ); if ( '' === $token ) { return array( 'ok' => false, 'username' => null, 'error' => 'no_token', ); } $transient_key = 'abds_ln_tg_status_' . md5( $token ); if ( ! $force ) { $cached = get_transient( $transient_key ); if ( is_array( $cached ) ) { return $cached; } } $resp = wp_remote_get( 'https://api.telegram.org/bot' . $token . '/getMe', array( 'timeout' => 10 ) ); $transport_error = is_wp_error( $resp ); $status = abds_ln_parse_telegram_getme( $transport_error, $transport_error ? 0 : (int) wp_remote_retrieve_response_code( $resp ), $transport_error ? '' : (string) wp_remote_retrieve_body( $resp ) ); // A transport error (server unreachable) shouldn't flash "not connected" // for a token that was working a minute ago — skip caching it so the next // normal page load or cron tick tries again instead of freezing on it. if ( 'transport' !== $status['error'] ) { set_transient( $transient_key, $status, 6 * HOUR_IN_SECONDS ); } return $status; } /** * On bot-token save: re-check the connection immediately (synchronous, * visible feedback on next page load) rather than waiting for the next * cron tick. */ function abds_ln_bot_token_changed() { abds_ln_telegram_status( true ); } add_action( 'add_option_' . ABDS_LN_OPT_TOKEN, 'abds_ln_bot_token_changed' ); add_action( 'update_option_' . ABDS_LN_OPT_TOKEN, 'abds_ln_bot_token_changed' ); /** * Twice-daily background re-check, so a bot revoked/broken outside of WP * (e.g. token disabled in @BotFather) doesn't show a stale "connected" ✓ * indefinitely. */ function abds_ln_telegram_daily_recheck() { if ( '' !== trim( (string) get_option( ABDS_LN_OPT_TOKEN, '' ) ) ) { abds_ln_telegram_status( true ); } } add_action( 'abds_ln_telegram_daily', 'abds_ln_telegram_daily_recheck' ); /* -------- iDance (CPT lead) -------- */ /** * The request-lifetime accumulator of iDance leads captured on `save_post` * but not yet notified — returned by reference so both the capture hook and * the shutdown flush share the same array without a global. * * @return array Lead post id => source URL, by reference. */ function &abds_ln_pending() { static $p = array(); return $p; } /** * The AJAX action name(s) abds_ln_idance_capture() accepts as a genuine * iDance lead submission — a UNION of three sources (2026-09-07: changed * from a single configured value to a union, so setting a custom name no * longer silently drops the built-ins): * * 1. The built-in set, unconditionally: ABDS_LN_IDANCE_ACTION_DEFAULT (the * standard name, works on any site with no settings change) and * ABDS_LN_IDANCE_ACTION_LEGACY_DEFAULT (the one specific site's name * this plugin was originally hardcoded to). * 2. The settings field's own value, if the site owner set one — an * ADDITION on top of the built-ins, not a replacement for them. * 3. Whatever the abds_ln_idance_action_names filter adds, for a site to * accept another name programmatically without needing the UI at all. * * Hardcoding a single site's action name into the plugin, with no way for * any other site's iDance install to match it, was the original defect * this whole mechanism exists to fix (2026-09-05 ТЗ, Priority 2). * * @return string[] Accepted action names, deduplicated. */ function abds_ln_idance_action_names() { $names = array( ABDS_LN_IDANCE_ACTION_DEFAULT, ABDS_LN_IDANCE_ACTION_LEGACY_DEFAULT ); $configured = trim( (string) get_option( ABDS_LN_OPT_IDANCE_ACTION, '' ) ); if ( '' !== $configured ) { $names[] = $configured; } $names = (array) apply_filters( 'abds_ln_idance_action_names', $names ); return array_values( array_unique( array_filter( array_map( 'strval', $names ) ) ) ); } /** * Captures an iDance lead's source URL on save, for the shutdown flush to * notify once the request (and all its post meta) is fully saved. * * @param int $post_id Lead post id. * @param WP_Post $post Lead post object (unused; hook signature). * @param bool $update Whether this is an update to an existing post. */ function abds_ln_idance_capture( $post_id, $post, $update ) { if ( ! abds_ln_source_on( 'idance' ) || $update || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ! wp_doing_ajax() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read of an AJAX submission the host form plugin already nonce-verified; value is sanitized and only routes the listener. $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( ! in_array( $action, abds_ln_idance_action_names(), true ) ) { // Not accepted -- but if it's a real, non-empty action name that // differs from what we last saw, remember it: turns "the lead // silently never arrived" into something the settings page can show // ("saw action `xyz_submit`, add it?") instead of staying invisible. // No secret is involved (an AJAX action name isn't one), so no // exposure concern in storing or displaying it. if ( '' !== $action && (string) get_option( ABDS_LN_OPT_IDANCE_LAST_SEEN, '' ) !== $action ) { update_option( ABDS_LN_OPT_IDANCE_LAST_SEEN, $action ); } return; } $src = wp_get_referer(); if ( ! $src && ! empty( $_SERVER['HTTP_REFERER'] ) ) { $src = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); } $p =& abds_ln_pending(); $p[ (int) $post_id ] = (string) $src; } add_action( 'save_post_lead', 'abds_ln_idance_capture', 20, 3 ); /** * Notifies Telegram for every iDance lead captured this request, reading * back each lead's preset + fields from its own post meta now that the form * plugin has finished saving them. Runs on `shutdown` so it never delays the * response the visitor is waiting on. */ function abds_ln_idance_flush() { $p =& abds_ln_pending(); if ( empty( $p ) ) { return; } $plabels = array( 'contact' => __( 'Contact / general', 'abds-connect' ), 'b2b' => __( 'For restaurants (B2B)', 'abds-connect' ), 'franchise' => __( 'Franchise', 'abds-connect' ), 'partnership' => __( 'Partnership', 'abds-connect' ), ); $flabels = array( 'name' => __( 'Name', 'abds-connect' ), 'email' => __( 'Email', 'abds-connect' ), 'phone' => __( 'Phone', 'abds-connect' ), 'company' => __( 'Company / venue', 'abds-connect' ), 'city' => __( 'City', 'abds-connect' ), 'interest' => __( 'Interested in', 'abds-connect' ), 'volume' => __( 'Volume (kg/week)', 'abds-connect' ), 'region' => __( 'Region / city', 'abds-connect' ), 'has_premises' => __( 'Premises', 'abds-connect' ), 'budget' => __( 'Budget', 'abds-connect' ), 'organisation' => __( 'Organisation', 'abds-connect' ), 'coop_type' => __( 'Type of cooperation', 'abds-connect' ), 'experience' => __( 'Experience', 'abds-connect' ), 'message' => __( 'Message', 'abds-connect' ), ); foreach ( $p as $lead_id => $src_url ) { $preset = (string) get_post_meta( (int) $lead_id, '_neborak_preset', true ); $form = isset( $plabels[ $preset ] ) ? $plabels[ $preset ] : ( $preset ? $preset : __( 'Form', 'abds-connect' ) ); $fields = array(); foreach ( $flabels as $key => $label ) { $val = (string) get_post_meta( (int) $lead_id, '_neborak_' . $key, true ); if ( '' !== trim( $val ) ) { $fields[ $label ] = $val; } } abds_ln_notify( $form, $src_url, $fields ); } $p = array(); } add_action( 'shutdown', 'abds_ln_idance_flush' ); /* -------- Contact Form 7 -------- */ /** * Notifies Telegram for a completed Contact Form 7 submission. * * @param WPCF7_ContactForm $contact_form The submitted form. */ function abds_ln_cf7( $contact_form ) { if ( ! abds_ln_source_on( 'cf7' ) || ! class_exists( 'WPCF7_Submission' ) ) { return; } $sub = WPCF7_Submission::get_instance(); if ( ! $sub ) { return; } $fields = array(); foreach ( (array) $sub->get_posted_data() as $k => $v ) { if ( 0 === strpos( (string) $k, '_wpcf7' ) ) { continue; } if ( is_array( $v ) ) { $v = implode( ', ', array_filter( array_map( 'strval', $v ) ) ); } if ( '' !== trim( (string) $v ) ) { $fields[ abds_ln_pretty( $k ) ] = (string) $v; } } $url = method_exists( $sub, 'get_meta' ) ? (string) $sub->get_meta( 'url' ) : ''; $name = is_object( $contact_form ) && method_exists( $contact_form, 'title' ) ? $contact_form->title() : 'Contact Form 7'; abds_ln_notify( $name, $url, $fields ); } add_action( 'wpcf7_mail_sent', 'abds_ln_cf7', 10, 1 ); /* -------- WPForms -------- */ /** * Notifies Telegram for a completed WPForms submission. * * @param array> $fields Submitted field values. * @param array $entry Unused; part of the hook signature. * @param array $form_data Form settings (title, etc.). * @param int $entry_id Unused; part of the hook signature. */ function abds_ln_wpforms( $fields, $entry, $form_data, $entry_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $entry_id is required by the wpforms_process_complete hook signature; nothing else here needs it. if ( ! abds_ln_source_on( 'wpforms' ) ) { return; } $out = array(); foreach ( (array) $fields as $f ) { $label = ( isset( $f['name'] ) && '' !== $f['name'] ) ? $f['name'] : ( isset( $f['id'] ) ? '#' . $f['id'] : __( 'Field', 'abds-connect' ) ); $val = isset( $f['value'] ) ? (string) $f['value'] : ''; if ( '' !== trim( $val ) ) { $out[ $label ] = $val; } } $title = isset( $form_data['settings']['form_title'] ) ? $form_data['settings']['form_title'] : 'WPForms'; abds_ln_notify( $title, wp_get_referer(), $out ); } add_action( 'wpforms_process_complete', 'abds_ln_wpforms', 20, 4 ); /* -------- Fluent Forms -------- */ /** * Notifies Telegram for a completed Fluent Forms submission. Fluent Forms * fires both a namespaced and a legacy action for the same event on some * versions, so a per-request static guards against notifying twice for one * entry. * * @param int $entry_id Entry id, used to de-duplicate. * @param array $form_data Submitted field values. * @param object $form Form object (title, etc.). */ function abds_ln_fluent( $entry_id, $form_data, $form ) { if ( ! abds_ln_source_on( 'fluent' ) ) { return; } static $done = array(); if ( isset( $done[ $entry_id ] ) ) { return; } $done[ $entry_id ] = 1; $out = array(); foreach ( (array) $form_data as $k => $v ) { if ( 0 === strpos( (string) $k, '_' ) ) { continue; } if ( is_array( $v ) ) { $v = implode( ', ', array_filter( array_map( 'strval', $v ) ) ); } if ( '' !== trim( (string) $v ) ) { $out[ abds_ln_pretty( $k ) ] = (string) $v; } } $title = ( is_object( $form ) && isset( $form->title ) ) ? $form->title : 'Fluent Forms'; abds_ln_notify( $title, wp_get_referer(), $out ); } add_action( 'fluentform/submission_inserted', 'abds_ln_fluent', 20, 3 ); add_action( 'fluentform_submission_inserted', 'abds_ln_fluent', 20, 3 ); /* -------- Gravity Forms -------- */ /** * Notifies Telegram for a completed Gravity Forms submission. * * @param array $entry Entry data (field id => value). * @param array $form Form definition (fields, title). */ function abds_ln_gravity( $entry, $form ) { if ( ! abds_ln_source_on( 'gravity' ) ) { return; } $out = array(); if ( isset( $form['fields'] ) && is_array( $form['fields'] ) ) { foreach ( $form['fields'] as $field ) { $label = is_object( $field ) && isset( $field->label ) ? (string) $field->label : ''; $id = is_object( $field ) && isset( $field->id ) ? (string) $field->id : ''; if ( '' === $label || '' === $id ) { continue; } $val = function_exists( 'rgar' ) ? rgar( $entry, $id ) : ( isset( $entry[ $id ] ) ? $entry[ $id ] : '' ); if ( '' !== trim( (string) $val ) ) { $out[ $label ] = (string) $val; } } } $url = isset( $entry['source_url'] ) ? (string) $entry['source_url'] : wp_get_referer(); $title = isset( $form['title'] ) ? (string) $form['title'] : 'Gravity Forms'; abds_ln_notify( $title, $url, $out ); } add_action( 'gform_after_submission', 'abds_ln_gravity', 10, 2 ); /* -------- Реєстрація опцій (група abds_connect) -------- */ /** * Sanitizes the submitted bot token for a write-only field: a blank * submission means "leave unchanged" (the field never echoes the real * stored token back for the user to accidentally clear), not "clear it". * * @param mixed $value Raw submitted value. * @return string */ function abds_ln_sanitize_bot_token( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return (string) get_option( ABDS_LN_OPT_TOKEN, '' ); } return $value; } /** * Registers the leads → Telegram settings (bot token, chat id, enabled * flag, and the per-source on/off map) in the shared `abds_connect` options * group. */ function abds_ln_register() { $g = 'abds_connect'; register_setting( $g, ABDS_LN_OPT_TOKEN, array( 'sanitize_callback' => 'abds_ln_sanitize_bot_token', 'default' => '', ) ); register_setting( $g, ABDS_LN_OPT_CHAT, array( 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( $g, ABDS_LN_OPT_ENABLED, array( 'sanitize_callback' => 'sanitize_text_field', 'default' => '1', ) ); register_setting( $g, ABDS_LN_OPT_SOURCES, array( 'sanitize_callback' => 'abds_ln_sanitize_sources', 'default' => array(), ) ); register_setting( $g, ABDS_LN_OPT_IDANCE_ACTION, array( 'sanitize_callback' => 'abds_ln_sanitize_idance_action', 'default' => '', ) ); } add_action( 'admin_init', 'abds_ln_register' ); /** * Sanitizes the submitted iDance AJAX action name -- a blank submission * stays blank (2026-09-07: this field is now an ADDITION on top of the * always-accepted built-in names, not the site's only accepted name, so * "nothing extra configured" is itself a valid, common state, not an * error to paper over with a fallback). abds_ln_idance_action_names()'s * own array_filter() already drops a blank value before it could ever * reach an in_array() needle. * * @param mixed $value Raw submitted value. * @return string */ function abds_ln_sanitize_idance_action( $value ) { return sanitize_key( (string) $value ); } /** * Sanitizes the submitted source on/off checkboxes into a complete 0/1 map * covering every known source (so a source added after the settings were * last saved defaults to off, not simply missing). * * @param mixed $val Raw submitted value. * @return array */ function abds_ln_sanitize_sources( $val ) { $out = array(); foreach ( array_keys( abds_ln_source_defs() ) as $k ) { $out[ $k ] = ( is_array( $val ) && ! empty( $val[ $k ] ) ) ? 1 : 0; } return $out; }