Incoming Webhooks" feature — no * OAuth app review needed, the same reason Webhook_Integration itself needs * no third-party app registration). Structurally identical to * Webhook_Integration (fire-and-forget POST, http_api_debug-based logging, * WP-Cron retry) — kept as its own class rather than a config variant of * Webhook_Integration because the request BODY shape is Slack-specific * (`{"text": "..."}`) and because a form owner reasonably wants Slack and a * generic webhook configured independently and enabled separately. * * A Slack Incoming Webhook URL is inherently channel-specific (created * per-channel in Slack, not per-account), so — unlike Mailchimp's single * account-wide API key — the site-wide default here is just a fallback for * forms that don't set their own, mirroring Webhook_Integration's own * `resolve_url()` pattern exactly. */ class Slack_Integration extends Abstract_Integration { private static $pending_form_id = null; private static $pending_context = null; const RETRY_HOOK = 'chargeforms_retry_slack'; const MAX_ATTEMPTS = 3; const RETRY_DELAY_SECONDS = 300; public static function boot() { add_action( self::RETRY_HOOK, [ __CLASS__, 'handle_retry' ], 10, 5 ); } public function key(): string { return 'slack'; } public function label(): string { return __( 'Slack', 'chargeforms' ); } public function is_configured( array $settings ): bool { return ! empty( $settings['enabled'] ) && '' !== $this->resolve_url( $settings ); } public function process( array $values, array $form, int $entry_id, array $settings, $attempt = 1 ) { $url = $this->resolve_url( $settings ); self::$pending_form_id = $form['id']; self::$pending_context = [ 'values' => $values, 'form' => $form, 'entry_id' => $entry_id, 'settings' => $settings, 'attempt' => $attempt, 'url' => $url, ]; add_action( 'http_api_debug', [ __CLASS__, 'log_response' ], 10, 5 ); wp_remote_post( $url, [ 'timeout' => 8, 'blocking' => false, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'text' => $this->build_message( $form, $values, $settings ) ] ), ] ); remove_action( 'http_api_debug', [ __CLASS__, 'log_response' ], 10 ); self::$pending_form_id = null; self::$pending_context = null; } /** WP-Cron callback — same shape as Webhook_Integration::handle_retry(). */ public static function handle_retry( $values, $form, $entry_id, $settings, $attempt ) { ( new self() )->process( $values, $form, $entry_id, $settings, $attempt ); } /** Fire-and-forget response logging + retry — identical mechanics to Webhook_Integration::log_response(), see that method's own doc comment for the full rationale. */ public static function log_response( $response, $context, $class, $args, $url ) { if ( 'response' !== $context || null === self::$pending_form_id || ! self::$pending_context || $url !== self::$pending_context['url'] ) { return; } $failed = false; if ( is_wp_error( $response ) ) { $message = $response->get_error_message() ?: __( 'The request failed with no further detail.', 'chargeforms' ); Api_Log::log( 'slack', self::$pending_form_id, $url, null, $message ); $failed = true; } else { $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); // Slack's Incoming Webhook responds 200 with the literal body // "ok" on success — anything else (e.g. "invalid_payload", // "channel_not_found") is a real delivery failure worth surfacing. Api_Log::log( 'slack', self::$pending_form_id, $url, $code ?: null, $body ?: __( 'No response received.', 'chargeforms' ) ); $failed = ! $code || $code >= 400; } if ( $failed && self::$pending_context['attempt'] < self::MAX_ATTEMPTS ) { $ctx = self::$pending_context; wp_schedule_single_event( time() + self::RETRY_DELAY_SECONDS, self::RETRY_HOOK, [ $ctx['values'], $ctx['form'], $ctx['entry_id'], $ctx['settings'], $ctx['attempt'] + 1 ] ); } } private function resolve_url( array $settings ) { $url = $settings['webhook_url'] ?? Settings::get( 'slack_default_webhook_url' ); return ( $url && wp_http_validate_url( $url ) ) ? $url : ''; } /** * A custom template (settings.message_template) may reference * `{field_id}` placeholders; without one, a readable default listing * every submitted field is generated so Slack is useful the moment it's * turned on, with no template-writing required first. */ private function build_message( array $form, array $values, array $settings ) { $template = trim( (string) ( $settings['message_template'] ?? '' ) ); if ( '' !== $template ) { return preg_replace_callback( '/\{([a-zA-Z0-9_\-]+)\}/', function ( $m ) use ( $values ) { return isset( $values[ $m[1] ] ) ? (string) ( is_array( $values[ $m[1] ] ) ? wp_json_encode( $values[ $m[1] ] ) : $values[ $m[1] ] ) : $m[0]; }, $template ); } $lines = [ sprintf( /* translators: %s: form title */ __( 'New submission — %s', 'chargeforms' ), $form['title'] ?? '' ) ]; foreach ( Field_Sanitizer::flatten_submittable_fields( $form['fields'] ) as $field ) { if ( in_array( $field['type'], Field_Sanitizer::NON_INPUT_TYPES, true ) || empty( $values[ $field['id'] ] ) ) { continue; } $value = $values[ $field['id'] ]; $lines[] = sprintf( '*%s:* %s', $field['label'] ?? $field['id'], is_array( $value ) ? wp_json_encode( $value ) : $value ); } return implode( "\n", $lines ); } }