human names (status table, logs). */ public static function services(): array { return [ 'mailchimp' => 'Mailchimp', 'brevo' => 'Brevo', 'sheets' => 'Google Sheets', 'slack' => 'Slack / Discord', ]; } /** * @param array $settings Saved widget settings. * @param array $fields Normalised field definitions (EPPRO_Form_Handler::normalize_fields()). * @param array $clean Field ID => validated value. * @param array $data Field label => value (what e-mail / webhook / entries get). * @param array $meta form_name, page_url. */ public static function run( array $settings, array $fields, array $clean, array $data, array $meta ): void { $global = class_exists( 'EPPRO_Admin_Settings' ) ? EPPRO_Admin_Settings::get_settings() : []; $contact = self::contact( $settings, $fields, $clean ); if ( 'yes' === ( $settings['mc_enable'] ?? '' ) ) { self::mailchimp( (string) ( $global['mailchimp_api_key'] ?? '' ), $settings, $contact ); } if ( 'yes' === ( $settings['brevo_enable'] ?? '' ) ) { self::brevo( (string) ( $global['brevo_api_key'] ?? '' ), $settings, $contact ); } if ( 'yes' === ( $settings['sheets_enable'] ?? '' ) ) { self::sheets( (string) ( $settings['sheets_url'] ?? '' ), $data, $meta ); } if ( 'yes' === ( $settings['slack_enable'] ?? '' ) ) { self::slack( (string) ( $settings['slack_url'] ?? '' ), $data, $meta ); } } /** True when at least one integration is switched on in this form. */ public static function any_enabled( array $settings ): bool { foreach ( [ 'mc_enable', 'brevo_enable', 'sheets_enable', 'slack_enable' ] as $key ) { if ( 'yes' === ( $settings[ $key ] ?? '' ) ) return true; } return false; } /** * Run the integrations once the AJAX response is on its way. On PHP-FPM and * LiteSpeed the response is flushed to the visitor first; elsewhere they run * at the end of the same request (as before). */ public static function run_after_response( array $settings, array $fields, array $clean, array $data, array $meta ): void { if ( ! self::any_enabled( $settings ) ) return; add_action( 'shutdown', static function () use ( $settings, $fields, $clean, $data, $meta ) { ignore_user_abort( true ); if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } elseif ( function_exists( 'litespeed_finish_request' ) ) { litespeed_finish_request(); } self::run( $settings, $fields, $clean, $data, $meta ); }, 0 ); } // ========================================================================= // Contact mapping // ========================================================================= /** * E-mail, first / last name and phone picked from the submitted values. * Mapped field IDs come from the widget; when the e-mail mapping is empty * the first Email field is used, and a single "name" field is split. */ private static function contact( array $settings, array $fields, array $clean ): array { $pick = static function ( string $key ) use ( $settings, $clean ): string { $id = sanitize_key( (string) ( $settings[ $key ] ?? '' ) ); return '' !== $id && isset( $clean[ $id ] ) ? trim( (string) $clean[ $id ] ) : ''; }; $email = $pick( 'map_email' ); if ( '' === $email ) { foreach ( $fields as $id => $field ) { if ( 'email' === $field['type'] && ! empty( $clean[ $id ] ) ) { $email = (string) $clean[ $id ]; break; } } } $first = $pick( 'map_first_name' ); $last = $pick( 'map_last_name' ); if ( '' === $first && '' === $last ) { $full = $pick( 'map_name' ); if ( '' !== $full ) { $parts = preg_split( '/\s+/', $full, 2 ); $first = $parts[0]; $last = $parts[1] ?? ''; } } return [ 'email' => is_email( $email ) ? $email : '', 'first' => $first, 'last' => $last, 'phone' => $pick( 'map_phone' ), ]; } // ========================================================================= // Services // ========================================================================= private static function mailchimp( string $api_key, array $settings, array $contact ): void { $api_key = trim( $api_key ); $audience = preg_replace( '/[^a-zA-Z0-9]/', '', (string) ( $settings['mc_audience'] ?? '' ) ); if ( '' === $api_key || ! preg_match( '/-([a-z]+[0-9]+)$/', $api_key, $m ) ) { self::status( 'mailchimp', false, __( 'API key missing or not in the "key-us21" format.', 'admore-widgets-suite' ) ); return; } if ( '' === $audience ) { self::status( 'mailchimp', false, __( 'No Audience ID set on the form.', 'admore-widgets-suite' ) ); return; } if ( '' === $contact['email'] ) { self::status( 'mailchimp', false, __( 'The submission had no valid e-mail address.', 'admore-widgets-suite' ) ); return; } $base = 'https://' . $m[1] . '.api.mailchimp.com/3.0/lists/' . $audience . '/members/' . md5( strtolower( $contact['email'] ) ); $headers = [ 'Authorization' => 'Basic ' . base64_encode( 'eppro:' . $api_key ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth required by the Mailchimp API. 'Content-Type' => 'application/json', ]; $merge = []; if ( '' !== $contact['first'] ) $merge['FNAME'] = $contact['first']; if ( '' !== $contact['last'] ) $merge['LNAME'] = $contact['last']; if ( '' !== $contact['phone'] ) $merge['PHONE'] = $contact['phone']; $body = [ 'email_address' => $contact['email'], // Double opt-in: new contacts get a confirmation e-mail first. 'status_if_new' => 'yes' === ( $settings['mc_double_optin'] ?? 'yes' ) ? 'pending' : 'subscribed', ]; if ( $merge ) { $body['merge_fields'] = $merge; } $response = wp_safe_remote_request( $base, [ 'method' => 'PUT', 'headers' => $headers, 'body' => wp_json_encode( $body ), 'timeout' => 8, ] ); if ( ! self::check( 'mailchimp', $response ) ) return; $tags = array_filter( array_map( 'trim', explode( ',', (string) ( $settings['mc_tags'] ?? '' ) ) ) ); if ( $tags ) { wp_safe_remote_post( $base . '/tags', [ 'headers' => $headers, 'body' => wp_json_encode( [ 'tags' => array_map( static fn( $t ) => [ 'name' => sanitize_text_field( $t ), 'status' => 'active' ], array_values( $tags ) ) ] ), 'timeout' => 8, 'blocking' => false, ] ); } } private static function brevo( string $api_key, array $settings, array $contact ): void { $api_key = trim( $api_key ); $list_id = absint( $settings['brevo_list'] ?? 0 ); if ( '' === $api_key ) { self::status( 'brevo', false, __( 'API key missing.', 'admore-widgets-suite' ) ); return; } if ( '' === $contact['email'] ) { self::status( 'brevo', false, __( 'The submission had no valid e-mail address.', 'admore-widgets-suite' ) ); return; } $attributes = []; if ( '' !== $contact['first'] ) $attributes['FIRSTNAME'] = $contact['first']; if ( '' !== $contact['last'] ) $attributes['LASTNAME'] = $contact['last']; // Brevo only accepts SMS numbers in international format (+countrycode…). $phone = preg_replace( '/[^0-9+]/', '', $contact['phone'] ); if ( '' !== $phone && str_starts_with( $phone, '+' ) ) { $attributes['SMS'] = $phone; } $body = [ 'email' => $contact['email'], 'updateEnabled' => true, ]; if ( $attributes ) $body['attributes'] = $attributes; if ( $list_id ) $body['listIds'] = [ $list_id ]; $response = wp_safe_remote_post( 'https://api.brevo.com/v3/contacts', [ 'headers' => [ 'api-key' => $api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode( $body ), 'timeout' => 8, ] ); self::check( 'brevo', $response ); } /** * Google Sheets via an Apps Script web app (script shown on the * Integrations tab): one row per submission, a column per field. */ private static function sheets( string $url, array $data, array $meta ): void { $url = esc_url_raw( trim( $url ), [ 'https' ] ); if ( '' === $url || ! preg_match( '#^https://script\.google\.com/#', $url ) ) { self::status( 'sheets', false, __( 'The Web App URL must start with https://script.google.com/.', 'admore-widgets-suite' ) ); return; } $row = array_merge( [ __( 'Date', 'admore-widgets-suite' ) => wp_date( 'Y-m-d H:i:s' ), __( 'Form', 'admore-widgets-suite' ) => (string) ( $meta['form_name'] ?? '' ), __( 'Page', 'admore-widgets-suite' ) => (string) ( $meta['page_url'] ?? '' ), ], array_map( 'strval', $data ) ); $response = wp_safe_remote_post( $url, [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $row ), 'timeout' => 10, 'redirection' => 0, // Apps Script answers with a redirect after the row is written. ] ); self::check( 'sheets', $response, [ 200, 302 ] ); } /** Slack incoming webhook or Discord webhook: a short text message. */ private static function slack( string $url, array $data, array $meta ): void { $url = esc_url_raw( trim( $url ), [ 'https' ] ); $is_slack = (bool) preg_match( '#^https://hooks\.slack\.com/#', $url ); $is_discord = (bool) preg_match( '#^https://(?:ptb\.|canary\.)?discord(?:app)?\.com/api/webhooks/#', $url ); if ( ! $is_slack && ! $is_discord ) { self::status( 'slack', false, __( 'Use a Slack (hooks.slack.com) or Discord (discord.com/api/webhooks) webhook URL.', 'admore-widgets-suite' ) ); return; } $lines = [ sprintf( /* translators: 1: form name, 2: site name */ __( 'New submission from "%1$s" on %2$s', 'admore-widgets-suite' ), (string) ( $meta['form_name'] ?? '' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), ]; foreach ( $data as $label => $value ) { $value = (string) $value; if ( '' === $value ) continue; $lines[] = '• ' . $label . ': ' . mb_substr( $value, 0, 500 ); } if ( ! empty( $meta['page_url'] ) ) { $lines[] = (string) $meta['page_url']; } $text = implode( "\n", $lines ); $response = wp_safe_remote_post( $url, [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $is_discord ? [ 'content' => mb_substr( $text, 0, 1900 ) ] : [ 'text' => $text ] ), 'timeout' => 8, ] ); self::check( 'slack', $response ); } // ========================================================================= // Status // ========================================================================= /** Record the result of a call; true on an accepted HTTP status. */ private static function check( string $service, $response, array $ok_codes = [] ): bool { if ( is_wp_error( $response ) ) { self::status( $service, false, $response->get_error_message() ); return false; } $code = (int) wp_remote_retrieve_response_code( $response ); $ok = $ok_codes ? in_array( $code, $ok_codes, true ) : ( $code >= 200 && $code < 300 ); if ( $ok ) { self::status( $service, true, 'HTTP ' . $code ); return true; } $message = 'HTTP ' . $code; $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( is_array( $body ) ) { $detail = $body['detail'] ?? ( $body['message'] ?? ( $body['title'] ?? '' ) ); if ( is_string( $detail ) && '' !== $detail ) { $message .= ' — ' . $detail; } } self::status( $service, false, $message ); return false; } private static function status( string $service, bool $ok, string $message ): void { $all = get_option( self::STATUS_OPTION, [] ); if ( ! is_array( $all ) ) $all = []; $all[ $service ] = [ 'ok' => $ok, 'message' => sanitize_text_field( mb_substr( $message, 0, 300 ) ), 'time' => time(), ]; update_option( self::STATUS_OPTION, $all, false ); if ( ! $ok ) { eppro_log( 'Essential Pro Pack ' . $service . ' integration: ' . $message ); } } /** Latest status per service (for the Integrations tab). */ public static function get_status(): array { $all = get_option( self::STATUS_OPTION, [] ); return is_array( $all ) ? $all : []; } /** Google Apps Script the site owner pastes into Extensions → Apps Script. */ public static function sheets_script(): string { return <<<'JS' function doPost(e) { var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); var data = JSON.parse(e.postData.contents); var lastCol = sheet.getLastColumn(); var headers = lastCol ? sheet.getRange(1, 1, 1, lastCol).getValues()[0] : []; Object.keys(data).forEach(function (key) { if (headers.indexOf(key) === -1) { headers.push(key); sheet.getRange(1, headers.length).setValue(key); } }); sheet.appendRow(headers.map(function (h) { return data[h] !== undefined ? data[h] : ''; })); return ContentService.createTextOutput('ok'); } JS; } }