. */ class NinjaForms { const GROUP = 'Ninja Forms'; const EVENT_PREFIX = 'ninjaforms.submitted.'; const OBJECT_TYPE = 'ninja_form'; /** * Field types that are not template variables: display only (no value), the anti-spam question, sensitive or binary * (passwords, card data, signatures, files), captchas, or a value that is only "checked" (single checkbox). */ const SKIPPED_TYPES = array( 'html', 'hr', 'note', 'submit', 'button', 'timed_submit', 'spam', 'unknown', 'password', 'passwordconfirm', 'confirm', 'checkbox', 'repeater', 'signature', 'file_upload', 'product', 'creditcard', 'creditcardcvc', 'creditcardexpiration', 'creditcardfullname', 'creditcardnumber', 'creditcardzip', 'recaptcha', 'recaptcha_v3', 'hcaptcha', 'turnstile', ); /** * Context keys of our own (prefixed so they cannot clash with field variables). */ const KEY_FORM = '_smsportal_form_id'; const KEY_HASH = '_smsportal_submission'; /** * Length of the window in which the same data from the same visitor counts as one submission * (used when the form does not store submissions). */ const DUPLICATE_WINDOW = 1800; /** * Whether Ninja Forms is active. * * @return bool */ public static function available(): bool { return function_exists( 'Ninja_Forms' ) && class_exists( 'NF_AJAX_Controllers_Submission' ); } /** * Register hooks. * * @return void */ public static function register(): void { add_filter( 'smsportal_register_events', array( self::class, 'events' ) ); add_action( 'ninja_forms_after_submission', array( self::class, 'submitted' ), 20 ); } /** * Add one event per form. * * @param mixed $events Events so far. * @return array> */ public static function events( $events ): array { $events = is_array( $events ) ? $events : array(); $forms = Ninja_Forms()->form()->get_forms(); foreach ( is_array( $forms ) ? $forms : array() as $form ) { $events[] = self::event( (int) $form->get_id(), (string) $form->get_setting( 'title' ) ); } return $events; } /** * Hook: a form was submitted and passed validation (Ninja Forms does not call it for failed submissions). * * @param mixed $form_data Processed submission: form_id, fields (key, label, type, value), actions, settings. * @return void */ public static function submitted( $form_data ): void { $form_id = is_array( $form_data ) ? (int) ( $form_data['form_id'] ?? 0 ) : 0; if ( $form_id <= 0 || ! is_array( $form_data['fields'] ?? null ) || ! empty( $form_data['settings']['is_preview'] ) ) { return; } $event_id = self::EVENT_PREFIX . $form_id; try { Events::fire( $event_id, self::context( $form_id, $form_data ) ); } catch ( \Throwable $e ) { Events::report( $e, $event_id ); } } /** * Event of one form. * * @param int $form_id Form id. * @param string $title Form title. * @return array */ private static function event( int $form_id, string $title ): array { return array( 'id' => self::EVENT_PREFIX . $form_id, /* translators: %s: form title. */ 'label' => sprintf( __( 'Ninja Forms: form "%s" sent', '85pl-android-sms-api' ), $title ), 'group' => self::GROUP, 'icon' => SMSPORTAL_URL_BASE . 'assets/integrations/ninja-forms.png', 'variables' => self::variables( $form_id ), 'customer_phone' => static function ( array $context, array $rule = array() ) { $field = (string) ( $rule['phone_field'] ?? '' ); $phone = '' !== $field ? ( $context[ $field ] ?? null ) : null; return is_string( $phone ) && '' !== trim( $phone ) ? $phone : null; }, 'object' => static function ( array $context ) use ( $form_id ): array { $id = (string) ( $context[ self::KEY_HASH ] ?? '' ); return array( 'type' => self::OBJECT_TYPE, // One SMS per submission: the stored submission's id, or a hash of the data and sender. 'id' => '' === $id ? '' : $form_id . '-' . $id, 'url' => admin_url( 'admin.php?page=ninja-forms&form_id=' . $form_id ), ); }, // Submissions are optional in Ninja Forms and their fields are keyed by id, so there is no sample to preview. 'sample' => null, 'rule_fields' => array( 'phone_field' ), ); } /** * Variables of a form: variable name => the field's label (the phone field can be any of them). * * @param int $form_id Form id. * @return array */ private static function variables( int $form_id ): array { // Fresh: Ninja_Forms()->form( $id ) remembers the fields it loaded earlier in this request. $fields = array_values( (array) Ninja_Forms()->form( $form_id )->get_fields( array(), true ) ); usort( $fields, static function ( $a, $b ) { return (int) $a->get_setting( 'order' ) <=> (int) $b->get_setting( 'order' ); } ); $variables = array(); foreach ( $fields as $field ) { $type = (string) $field->get_setting( 'type' ); $id = (int) $field->get_id(); if ( $id <= 0 || '' === $type || in_array( $type, self::SKIPPED_TYPES, true ) ) { continue; } $variables[ self::name( $id, (string) $field->get_setting( 'key' ) ) ] = self::label( $id, (string) $field->get_setting( 'label' ) ); } return $variables; } /** * Label of a field, with a fallback for fields without one. * * @param int $id Field id. * @param string $label Label. * @return string */ private static function label( int $id, string $label ): string { $label = trim( wp_strip_all_tags( $label ) ); /* translators: %d: field number in the form. */ return '' !== $label ? $label : sprintf( __( 'Field %d', '85pl-android-sms-api' ), $id ); } /** * Variable name of a field: its key when that is a safe name, otherwise field_. * * @param int $id Field id. * @param string $key Field key. * @return string */ private static function name( int $id, string $key ): string { return 1 === preg_match( '/^[A-Za-z0-9_:.\-]+$/', $key ) ? $key : 'field_' . $id; } /** * Submitted values as template variables (lists joined with commas). * * @param int $form_id Form id. * @param array $form_data Processed submission. * @return array */ private static function context( int $form_id, array $form_data ): array { $context = array(); foreach ( $form_data['fields'] as $field ) { $type = is_array( $field ) ? (string) ( $field['type'] ?? '' ) : ''; $id = is_array( $field ) ? (int) ( $field['id'] ?? 0 ) : 0; if ( $id <= 0 || '' === $type || in_array( $type, self::SKIPPED_TYPES, true ) ) { continue; } $context[ self::name( $id, (string) ( $field['key'] ?? '' ) ) ] = self::value( $field['value'] ?? '', $type, $field['options'] ?? array() ); } $sub_id = (int) ( $form_data['actions']['save']['sub_id'] ?? 0 ); $context[ self::KEY_FORM ] = $form_id; $context[ self::KEY_HASH ] = $sub_id > 0 ? (string) $sub_id : self::hash( $form_id, $context ); return $context; } /** * One value as text: lists show the options' labels, several values become a comma-separated list; capped in length. * * @param mixed $value Value from Ninja Forms. * @param string $type Field type. * @param mixed $options The field's options (lists), each with label and value. * @return string */ private static function value( $value, string $type, $options ): string { $labels = array(); foreach ( is_array( $options ) ? $options : array() as $option ) { if ( is_array( $option ) && isset( $option['value'], $option['label'] ) && is_scalar( $option['value'] ) && is_scalar( $option['label'] ) ) { $labels[ (string) $option['value'] ] = (string) $option['label']; } } $parts = array(); foreach ( is_array( $value ) ? $value : array( $value ) as $item ) { $text = is_scalar( $item ) ? (string) $item : ''; if ( '' !== $text ) { $parts[] = $labels[ $text ] ?? $text; } } $text = implode( ', ', $parts ); if ( 'textarea' !== $type ) { $text = (string) preg_replace( '/\s*\R\s*/', ', ', trim( $text ) ); } return wp_strip_all_tags( $text ); } /** * Identifier of a submission when Ninja Forms stores none: the data, the sender and a half-hour window. * * @param int $form_id Form id. * @param array $context Values. * @return string */ private static function hash( int $form_id, array $context ): string { $sender = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $window = (int) floor( time() / self::DUPLICATE_WINDOW ); return md5( wp_json_encode( array( $form_id, $context, $sender, $window ) ) ); } }