label), customer_phone (callable: context, rule => ?string; * the rule lets an event read e.g. the form field chosen in it), * object (callable: context => array{type,id,url}), sample (callable returning a context or null), * rule_fields (optional: extra rule fields the editor shows, 'phone_field' and/or 'hours_before'), * phone_field_default (optional, with 'phone_field': the field used when the rule chose none; the editor * suggests it and does not insist on a choice). * * @return array> */ public static function all(): array { /** * Lets other plugins add events. * * @param array> $events Events. */ try { $events = apply_filters( 'smsportal_register_events', array() ); } catch ( \Throwable $e ) { self::report( $e, '' ); return array(); } $result = array(); foreach ( (array) $events as $event ) { if ( is_array( $event ) && ! empty( $event['id'] ) && is_string( $event['id'] ) ) { $result[] = $event; } } return $result; } /** * One event. * * @param string $event_id Event ID. * @return array|null */ public static function get( string $event_id ): ?array { foreach ( self::all() as $event ) { if ( $event['id'] === $event_id ) { return $event; } } return null; } /** * Run every enabled rule of an event. Scalar values of $context fill the template's {variables}. * Runs inside other plugins' hooks, so it never throws: a failing rule is reported and skipped. * * An event needs an `object` callable returning a non-empty id for de-duplication: without it every * call is treated as a new occurrence and sends again. * * @param string $event_id Event ID. * @param mixed $context Event data (array; anything else is ignored). * @param string $dedupe_suffix Distinguishes legitimate repeats of the same object (e.g. a second tracking number). * @return void */ public static function fire( string $event_id, $context = array(), string $dedupe_suffix = '' ): void { if ( ! is_array( $context ) ) { return; } // Rules first: listing events runs every integration's filter (CF7 reads all forms), and most hooks // (every order status, every booking) have no rule at all. try { $rules = Rules::enabled_for_event( $event_id ); } catch ( \Throwable $e ) { self::report( $e, $event_id ); return; } if ( array() === $rules ) { return; } $event = self::get( $event_id ); if ( null === $event ) { return; } foreach ( $rules as $rule ) { try { self::run_rule( $event, $rule, $context, $dedupe_suffix ); } catch ( \Throwable $e ) { self::report( $e, $event_id ); } } } /** * Run one rule of an event (e.g. a reminder planned for that rule). Same guarantees as fire(): never throws. * Does nothing when the rule is gone, disabled or belongs to another event. * * @param string $event_id Event ID. * @param int $rule_id Rule ID. * @param mixed $context Event data (array; anything else is ignored). * @param string $dedupe_suffix Repeat distinguisher. * @param bool $rule_limit False: the rule's hourly cap does not apply (reminders of a full calendar * legitimately go out together); country and per-number limits still do. * @return void */ public static function fire_rule( string $event_id, int $rule_id, $context = array(), string $dedupe_suffix = '', bool $rule_limit = true ): void { if ( ! is_array( $context ) ) { return; } try { $rule = Rules::get( $rule_id ); $event = null !== $rule && $rule['enabled'] && $event_id === $rule['event_id'] ? self::get( $event_id ) : null; if ( null !== $event ) { self::run_rule( $event, $rule, $context, $dedupe_suffix, $rule_limit ); } } catch ( \Throwable $e ) { self::report( $e, $event_id ); } } /** * Create the message for one rule. * * @param array $event Event. * @param array $rule Rule. * @param array $context Event data. * @param string $dedupe_suffix Repeat distinguisher. * @param bool $rule_limit Whether the rule's hourly cap applies. * @return void */ private static function run_rule( array $event, array $rule, array $context, string $dedupe_suffix, bool $rule_limit = true ): void { $event_id = (string) $event['id']; $object = self::object_of( $event, $context ); $phone = self::recipient( $event, $rule, $context ); if ( null === $phone || '' === $phone ) { return; } $suffix = '' === $object['id'] ? $dedupe_suffix . '|' . wp_generate_uuid4() : $dedupe_suffix; $customer = 'fixed' !== $rule['recipient_type']; $result = Messages::enqueue( array( 'recipient' => $phone, 'message' => Template::render( $rule['template'], $context ), 'event_label' => (string) ( $event['label'] ?? $event_id ), 'object_type' => $object['type'], 'object_id' => $object['id'], 'dedupe_key' => implode( '|', array( $rule['id'], $event_id, $object['type'], $object['id'], $suffix ) ), 'rule_id' => $rule['id'], 'expires_at' => $rule['expires_hours'] > 0 ? time() + $rule['expires_hours'] * HOUR_IN_SECONDS : null, // Numbers typed by customers (forms, guest checkout, bookings) can be abused: see Limits. 'check_limits' => $customer, 'rule_limit' => $rule_limit, ) ); if ( $customer && is_wp_error( $result ) && 'smsportal_invalid_phone' === $result->get_error_code() ) { Limits::log_invalid_number( (int) $rule['id'] ); } } /** * Log a failure without personal data and let others react. Integrations use it for their own hook callbacks. * * @param \Throwable $e Error. * @param string $event_id Event ID. * @return void */ public static function report( \Throwable $e, string $event_id ): void { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- failures in third-party callbacks must be visible. error_log( sprintf( 'smsportal: event %s failed: %s in %s:%d', $event_id, get_class( $e ), basename( $e->getFile() ), $e->getLine() ) ); try { /** * Fires when an event callback or rule fails. * * @param \Throwable $e Error. * @param string $event_id Event ID. */ do_action( 'smsportal_error', $e, $event_id ); } catch ( \Throwable $listener_error ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- a failing listener must not break the host hook. error_log( sprintf( 'smsportal: smsportal_error listener failed: %s', get_class( $listener_error ) ) ); } } /** * Recipient number for a rule. * * @param array $event Event. * @param array $rule Rule. * @param array $context Event data. * @return string|null */ private static function recipient( array $event, array $rule, array $context ): ?string { if ( 'fixed' === $rule['recipient_type'] ) { return $rule['fixed_phone']; } if ( ! isset( $event['customer_phone'] ) || ! is_callable( $event['customer_phone'] ) ) { return null; } $phone = call_user_func( $event['customer_phone'], $context, $rule ); return is_string( $phone ) ? $phone : null; } /** * Object (order, form entry, ...) the event is about. * * @param array $event Event. * @param array $context Event data. * @return array{type:string,id:string,url:string} */ private static function object_of( array $event, array $context ): array { $object = isset( $event['object'] ) && is_callable( $event['object'] ) ? call_user_func( $event['object'], $context ) : array(); $object = is_array( $object ) ? $object : array(); return array( 'type' => (string) ( $object['type'] ?? '' ), 'id' => (string) ( $object['id'] ?? '' ), 'url' => (string) ( $object['url'] ?? '' ), ); } }