}> by form id */ abstract public function forms(); /** Hook the plugin's "form submitted" action. */ abstract public function listen(); /** Field types that never hold an answer. */ protected function skip_type( $type ) { return in_array( strtolower( (string) $type ), array( 'submit', 'html', 'hr', 'divider', 'pagebreak', 'page-break', 'step', 'recaptcha', 'recaptcha_v3', 'captcha', 'hcaptcha', 'turnstile', 'honeypot', 'spam', 'confirm', 'note', 'save', 'unknown', 'content', 'entry-preview', 'acceptance', 'quiz', 'captchac', 'captchar', 'layout', 'section' ), true ); } /** An answer as text; lists (checkboxes, multi-selects) joined with commas. */ protected function text( $value ) { if ( is_array( $value ) ) { $value = implode( ', ', array_map( 'strval', array_filter( $value, 'is_scalar' ) ) ); } return trim( wp_strip_all_tags( (string) $value ) ); } } /** * Form messages for every supported form plugin: one list of rules * (form → phone field → template → variables), sent after each submission. */ class Beon_Notify_Forms { /** @var Beon_Notify_Form_Source[] */ private static $sources = array(); public static function init() { $sources = array( new Beon_Notify_Source_CF7(), new Beon_Notify_Source_WPForms(), new Beon_Notify_Source_Elementor(), new Beon_Notify_Source_Gravity_Forms(), new Beon_Notify_Source_Fluent_Forms(), new Beon_Notify_Source_Forminator(), new Beon_Notify_Source_Ninja_Forms(), ); foreach ( apply_filters( 'beon_notify_form_sources', $sources ) as $source ) { self::$sources[ $source->key() ] = $source; // Hook every source; a callback only runs if its plugin fires it. $source->listen(); } } /** * How each supported plugin is shown in the Forms grid: initials + brand colour * (a tile, not the plugin's logo), and how to get it when it isn't installed. * * @return array */ public static function catalog() { return apply_filters( 'beon_notify_form_catalog', array( 'cf7' => array( 'name' => 'Contact Form 7', 'initials' => 'CF7', 'color' => '#2b80c5', 'free' => true, 'search' => 'contact form 7', 'about' => __( 'The classic free contact form plugin.', 'beon-chat' ) ), 'wpforms' => array( 'name' => 'WPForms', 'initials' => 'WPF', 'color' => '#e27730', 'free' => true, 'search' => 'wpforms', 'about' => __( 'Drag-and-drop forms (Lite and Pro).', 'beon-chat' ) ), 'elementor' => array( 'name' => 'Elementor Forms', 'initials' => 'EL', 'color' => '#92003b', 'free' => false, 'search' => '', 'about' => __( 'Form widgets on your Elementor Pro pages and popups.', 'beon-chat' ) ), 'gf' => array( 'name' => 'Gravity Forms', 'initials' => 'GF', 'color' => '#f15a2b', 'free' => false, 'search' => '', 'about' => __( 'Advanced forms for agencies and big stores.', 'beon-chat' ) ), 'ff' => array( 'name' => 'Fluent Forms', 'initials' => 'FF', 'color' => '#1a7efb', 'free' => true, 'search' => 'fluent forms', 'about' => __( 'Fast forms with a free and Pro version.', 'beon-chat' ) ), 'fm' => array( 'name' => 'Forminator', 'initials' => 'FM', 'color' => '#17a8e3', 'free' => true, 'search' => 'forminator', 'about' => __( 'Free forms, quizzes and polls by WPMU DEV.', 'beon-chat' ) ), 'nf' => array( 'name' => 'Ninja Forms', 'initials' => 'NF', 'color' => '#ef4748', 'free' => true, 'search' => 'ninja forms', 'about' => __( 'Beginner-friendly drag-and-drop forms.', 'beon-chat' ) ), ) ); } /** Each plugin's main file (folder/file.php), to find its icon. Elementor: Pro, else the free core. */ const PLUGIN_FILES = array( 'cf7' => array( 'contact-form-7/wp-contact-form-7.php' ), 'wpforms' => array( 'wpforms-lite/wpforms.php', 'wpforms/wpforms.php' ), 'elementor' => array( 'elementor-pro/elementor-pro.php', 'elementor/elementor.php' ), 'gf' => array( 'gravityforms/gravityforms.php' ), 'ff' => array( 'fluentform/fluentform.php' ), 'fm' => array( 'forminator/forminator.php' ), 'nf' => array( 'ninja-forms/ninja-forms.php' ), ); /** Icon files some plugins ship in their own folder (checked only if they exist). */ const LOCAL_ICONS = array( 'assets/icon.svg', 'assets/icon.png', 'assets/images/icon.svg', 'assets/images/icon.png', 'assets/img/icon.png', 'images/icon.png' ); /** * The plugin's real icon URL, found the way WordPress finds it for its own screens: * the icons in WordPress's plugin update data (from wordpress.org, or the vendor's * updater for paid plugins), else an icon file inside the installed plugin. Empty * when neither exists (the Forms grid then shows a coloured tile). Nothing is * bundled with BeOn Chat and nothing is downloaded here. */ public static function icon( $source ) { $files = apply_filters( 'beon_notify_form_plugin_files', self::PLUGIN_FILES[ $source ] ?? array(), $source ); if ( ! $files ) { return ''; } $updates = get_site_transient( 'update_plugins' ); foreach ( $files as $file ) { foreach ( array( 'response', 'no_update' ) as $list ) { $bucket = is_object( $updates ) && isset( $updates->{$list} ) ? (array) $updates->{$list} : array(); $item = isset( $bucket[ $file ] ) ? (array) $bucket[ $file ] : array(); $icons = (array) ( $item['icons'] ?? array() ); foreach ( array( 'svg', '2x', '1x', 'default' ) as $size ) { if ( ! empty( $icons[ $size ] ) && 0 === strpos( (string) $icons[ $size ], 'https://' ) ) { return (string) $icons[ $size ]; } } } } foreach ( $files as $file ) { $dir = WP_PLUGIN_DIR . '/' . dirname( $file ); if ( ! is_dir( $dir ) ) { continue; } foreach ( self::LOCAL_ICONS as $relative ) { if ( is_file( $dir . '/' . $relative ) ) { return plugins_url( $relative, $dir . '/' . basename( $file ) ); } } } return ''; } /** * @return Beon_Notify_Form_Source[] every supported plugin, installed or not */ public static function sources() { return self::$sources; } /** Messages only go out for plugins connected in the Forms tab. */ public static function connected( $source ) { return in_array( (string) $source, beon_notify_settings()['form_sources'], true ); } /** * @return Beon_Notify_Form_Source[] the ones whose plugin is active */ public static function active_sources() { return array_filter( self::$sources, static function ( $source ) { return $source->active(); } ); } /** * Every form of every active plugin, keyed "source:id". * * @return array */ public static function all() { $all = array(); foreach ( self::active_sources() as $source ) { foreach ( $source->forms() as $id => $form ) { $all[ $source->key() . ':' . $id ] = array( 'source' => $source->key(), 'plugin' => $source->name(), 'title' => $form['title'], 'fields' => $form['fields'], ); } } return $all; } /** A rule's form key: "source:id". */ public static function is_form_key( $key ) { return (bool) preg_match( '/^[a-z0-9_]+:[A-Za-z0-9_-]+$/', (string) $key ); } /** * Best guess for a form's phone or name field. */ public static function guess_field( array $fields, $kind ) { if ( 'phone' === $kind ) { foreach ( $fields as $f ) { if ( in_array( $f['type'], array( 'phone', 'tel' ), true ) ) { return $f['key']; } } } $words = 'phone' === $kind ? '/phone|mobile|whats|tel|موبايل|تليفون|رقم|هاتف|واتس/iu' : '/name|اسم/iu'; $types = 'phone' === $kind ? array( 'text', 'textbox', 'number' ) : array( 'text', 'textbox', 'name', 'firstname', 'lastname' ); foreach ( $fields as $f ) { if ( in_array( $f['type'], $types, true ) && preg_match( $words, $f['key'] . ' ' . $f['label'] ) ) { return $f['key']; } } if ( 'name' === $kind ) { foreach ( $fields as $f ) { if ( 'name' === $f['type'] ) { return $f['key']; } } } return ''; } /** * Called by a source after a submission: sends every rule for that form. * * @param string $source Source key, e.g. "cf7". * @param string $form_id The form's id within that plugin. * @param array $answers Answers by field key (text). * @param string $title Form title. * @param mixed $raw The plugin's own submission data, for the filter. */ public static function submitted( $source, $form_id, array $answers, $title = '', $raw = null ) { if ( ! self::connected( $source ) ) { return; } $form = $source . ':' . $form_id; $rules = array_filter( beon_notify_settings()['form_rules'], static function ( $rule ) use ( $form ) { return $rule['form'] === $form && ! empty( $rule['template_id'] ) && ! empty( $rule['enabled'] ); } ); if ( ! $rules ) { return; } $vars = array( 'site_name' => get_bloginfo( 'name' ), 'form_title' => (string) $title, ); foreach ( $answers as $key => $value ) { $vars[ 'field:' . $key ] = $value; } $vars = apply_filters( 'beon_notify_form_vars', $vars, $form, $raw ); foreach ( $rules as $rule ) { $variables = array_map( static function ( $line ) use ( $vars ) { return beon_notify_fill( $line, $vars ); }, beon_notify_lines( $rule['variables'] ) ); $name = (string) ( $answers[ $rule['name_field'] ] ?? '' ); $recipients = array(); if ( in_array( $rule['recipients'], array( 'visitor', 'both' ), true ) ) { $phone = beon_notify_normalize_phone( $answers[ $rule['phone_field'] ] ?? '' ); if ( $phone ) { $recipients[ $phone ] = $name; } else { beon_notify_log( sprintf( 'Form %s: the "%s" answer isn’t a phone number; visitor not messaged.', $form, $rule['phone_field'] ), 'warning' ); Beon_Notify_Log::add( 'skipped', array( 'phone' => sanitize_text_field( (string) ( $answers[ $rule['phone_field'] ] ?? '' ) ), 'name' => $name, 'template_id' => (int) $rule['template_id'], 'variables' => $variables, ), array( 'source' => $source, 'form' => $form, 'rule' => $rule['id'], ), /* translators: %s: form field name */ array( 'error' => sprintf( __( 'The visitor’s “%s” answer isn’t a phone number.', 'beon-chat' ), $rule['phone_field'] ) ) ); } } if ( in_array( $rule['recipients'], array( 'admin', 'both' ), true ) ) { foreach ( beon_notify_lines( str_replace( ',', "\n", beon_notify_settings()['admin_phones'] ) ) as $raw_phone ) { $phone = beon_notify_normalize_phone( $raw_phone ); if ( $phone && ! isset( $recipients[ $phone ] ) ) { $recipients[ $phone ] = get_bloginfo( 'name' ); } } } foreach ( $recipients as $phone => $to_name ) { Beon_Notify_Dispatcher::dispatch( array( 'phone' => $phone, 'name' => $to_name, 'template_id' => (int) $rule['template_id'], 'variables' => $variables, ), array( 'source' => $source, 'form' => $form, 'rule' => $rule['id'], ) ); } } } }