Integrations. Configures the site-wide default for each * free-tier integration (used as the fallback when a form doesn't set its * own). Cards for integrations that aren't built yet are marked "Coming * Soon" rather than "Pro" — none of this is actually gated behind a paid * tier today, so labeling it that way would be dishonest merchandising, * not a real product distinction. */ class Integrations_Page { public function register() { add_submenu_page( 'chargeforms', __( 'Integrations', 'chargeforms' ), __( 'Integrations', 'chargeforms' ), 'chargeforms_manage_forms', 'chargeforms-integrations', [ $this, 'render' ] ); } public function __construct() { add_action( 'admin_menu', [ $this, 'register' ] ); add_action( 'wp_ajax_chargeforms_test_integration', [ $this, 'ajax_test_connection' ] ); } /** * "Test Connection" button, shared by every credential-based card on * this page — tests whatever is CURRENTLY IN THE FIELD (not necessarily * saved yet), so a site owner can verify a freshly-pasted credential * before hitting Save and finding out it was wrong only once a real * form submission fails. Dispatches by `integration` rather than being * one handler per integration, since "POST a credential, get back * success/failure" is identical shape for all four. */ public function ajax_test_connection() { if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_ajax_referer( 'chargeforms_integration_test', 'nonce', false ) ) { wp_send_json_error( [ 'message' => __( 'Not allowed.', 'chargeforms' ) ], 403 ); } $integration = isset( $_POST['integration'] ) ? sanitize_key( wp_unslash( $_POST['integration'] ) ) : ''; $result = null; switch ( $integration ) { case 'brevo': case 'activecampaign': $token = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; $base = isset( $_POST['api_url'] ) ? esc_url_raw( wp_unslash( $_POST['api_url'] ) ) : ''; $result = \ChargeForms\Includes\Integrations\Marketing_Client::ping( $integration, $token, $base ); break; case 'mailchimp': $api_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; $result = '' === $api_key ? new \WP_Error( 'chargeforms_missing_field', __( 'Enter an API key first.', 'chargeforms' ) ) : Mailchimp_Client::ping( $api_key ); break; case 'hubspot': $token = isset( $_POST['access_token'] ) ? sanitize_text_field( wp_unslash( $_POST['access_token'] ) ) : ''; $result = '' === $token ? new \WP_Error( 'chargeforms_missing_field', __( 'Enter an access token first.', 'chargeforms' ) ) : Hubspot_Client::ping( $token ); break; case 'sheets': $json = isset( $_POST['service_account_json'] ) ? sanitize_textarea_field( wp_unslash( $_POST['service_account_json'] ) ) : ''; if ( '' === $json ) { $result = new \WP_Error( 'chargeforms_missing_field', __( 'Paste a service account JSON key first.', 'chargeforms' ) ); break; } $token = Google_Sheets_Client::access_token( $json ); $result = is_wp_error( $token ) ? $token : true; break; case 'slack': $url = isset( $_POST['webhook_url'] ) ? esc_url_raw( wp_unslash( $_POST['webhook_url'] ) ) : ''; if ( '' === $url || ! wp_http_validate_url( $url ) ) { $result = new \WP_Error( 'chargeforms_missing_field', __( 'Enter a valid webhook URL first.', 'chargeforms' ) ); break; } $response = wp_remote_post( $url, [ 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'text' => __( 'ChargeForms test message — your Slack integration is connected.', 'chargeforms' ) ] ), ] ); if ( is_wp_error( $response ) ) { $result = new \WP_Error( 'chargeforms_slack_error', self::friendly_network_error( $response, 'Slack' ) ); } else { $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $result = ( $code < 400 ) ? true : new \WP_Error( 'chargeforms_slack_error', self::friendly_slack_error( $body, $code ) ); } break; case 'discord': $url = isset( $_POST['webhook_url'] ) ? esc_url_raw( wp_unslash( $_POST['webhook_url'] ) ) : ''; if ( '' === $url || ! wp_http_validate_url( $url ) ) { $result = new \WP_Error( 'chargeforms_missing_field', __( 'Enter a valid webhook URL first.', 'chargeforms' ) ); break; } $response = wp_remote_post( $url, [ 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'content' => __( 'ChargeForms test message — your Discord integration is connected.', 'chargeforms' ) ] ), ] ); if ( is_wp_error( $response ) ) { $result = new \WP_Error( 'chargeforms_discord_error', self::friendly_network_error( $response, 'Discord' ) ); } else { // Discord's Incoming Webhook responds 204 (empty body) on // success, unlike Slack's 200 + "ok" — same `$code < 400` // success check still applies correctly. $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $result = ( $code < 400 ) ? true : new \WP_Error( 'chargeforms_discord_error', self::friendly_discord_error( $body, $code ) ); } break; case 'telegram': $token = isset( $_POST['bot_token'] ) ? sanitize_text_field( wp_unslash( $_POST['bot_token'] ) ) : ''; $chat_id = isset( $_POST['chat_id'] ) ? sanitize_text_field( wp_unslash( $_POST['chat_id'] ) ) : ''; if ( '' === $token || '' === $chat_id ) { $result = new \WP_Error( 'chargeforms_missing_field', __( 'Enter both a bot token and a chat ID first.', 'chargeforms' ) ); break; } $response = wp_remote_post( 'https://api.telegram.org/bot' . $token . '/sendMessage', [ 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'chat_id' => $chat_id, 'text' => __( 'ChargeForms test message — your Telegram integration is connected.', 'chargeforms' ), ] ), ] ); if ( is_wp_error( $response ) ) { $result = new \WP_Error( 'chargeforms_telegram_error', self::friendly_network_error( $response, 'Telegram' ) ); } else { // Telegram responds with a JSON body containing "ok": // true/false — that flag is the real success signal, the // HTTP status alone isn't always reliable for this API. $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $decoded = json_decode( (string) $body, true ); $ok = is_array( $decoded ) && ! empty( $decoded['ok'] ); $result = $ok ? true : new \WP_Error( 'chargeforms_telegram_error', self::friendly_telegram_error( $decoded, $code ) ); } break; case 'notion': $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; $database_id = isset( $_POST['database_id'] ) ? sanitize_text_field( wp_unslash( $_POST['database_id'] ) ) : ''; if ( '' === $token || '' === $database_id ) { $result = new \WP_Error( 'chargeforms_missing_field', __( 'Enter both an integration token and a database ID first.', 'chargeforms' ) ); break; } // A read-only database lookup, not a real test page — this // confirms the token is valid AND has been shared with the // target database (Notion's own real gotcha, see // Notion_Integration's class doc comment) without leaving // test data behind in the user's actual workspace. $response = wp_remote_get( 'https://api.notion.com/v1/databases/' . rawurlencode( $database_id ), [ 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Notion-Version' => '2022-06-28', ], ] ); if ( is_wp_error( $response ) ) { $result = new \WP_Error( 'chargeforms_notion_error', self::friendly_network_error( $response, 'Notion' ) ); } else { $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $decoded = json_decode( (string) $body, true ); $result = ( 200 === $code ) ? true : new \WP_Error( 'chargeforms_notion_error', self::friendly_notion_error( $decoded, $code ) ); } break; default: $result = new \WP_Error( 'chargeforms_unknown_integration', __( 'Unknown integration.', 'chargeforms' ) ); } if ( is_wp_error( $result ) ) { wp_send_json_error( [ 'message' => $result->get_error_message() ], 400 ); } wp_send_json_success( [ 'message' => __( 'Connected successfully.', 'chargeforms' ) ] ); } /** * Wraps a network-level failure (DNS, timeout, connection refused — a * real `WP_Error` from `wp_remote_post()` itself, before any response * ever came back) so the user sees plain language instead of a raw * cURL error string. Found live while testing Telegram: a real * connection timeout to api.telegram.org surfaced as the literal * "cURL error 28: Operation timed out after 10010 milliseconds with 0 * out of 0 bytes received" — the same unwrapped-`WP_Error::get_error_message()` * pattern existed identically for Slack and Discord's network-failure * path too (not new to Telegram), so fixed for all three at once * rather than patching only the one that happened to hit it first. */ private static function friendly_network_error( \WP_Error $error, $service ) { $message = $error->get_error_message(); if ( false !== stripos( $message, 'timed out' ) ) { /* translators: %s: service name, e.g. "Slack" */ return sprintf( __( 'The connection to %s timed out. Check your server\'s internet connection and try again.', 'chargeforms' ), $service ); } if ( false !== stripos( $message, 'could not resolve host' ) || false !== stripos( $message, 'name or service not known' ) ) { /* translators: %s: service name, e.g. "Discord" */ return sprintf( __( 'Couldn\'t reach %s — your server may not have internet access, or a firewall is blocking the connection.', 'chargeforms' ), $service ); } /* translators: 1: service name, e.g. "Telegram"; 2: the underlying network error */ return sprintf( __( 'Couldn\'t connect to %1$s (%2$s).', 'chargeforms' ), $service, $message ); } /** * Slack's Incoming Webhook endpoint responds with its raw internal error * code as the entire response body (e.g. "no_team", "channel_not_found") * — found live while auditing this button: a real, working test request * returned a genuine failure, but the UI showed that bare code straight * to the user with no explanation. Mapped to plain language for the * ones Slack actually documents; anything else still gets SOME context * (the raw code, not hidden) rather than a generic "something went * wrong" that would erase the one clue a site owner has to fix it. * * `messages_tab_disabled` added after a real, live user report against * a real webhook URL — confirmed via that live test to be Slack's own * API response, not anything ChargeForms-side: it means the Slack App * behind the Incoming Webhook has its App Home "Messages Tab" turned * off, which Slack requires enabled before that app can post messages. */ private static function friendly_slack_error( $body, $code ) { $body = trim( (string) $body ); $map = [ 'no_team' => __( 'This webhook URL doesn\'t belong to an active Slack workspace. Double-check you copied the full URL.', 'chargeforms' ), 'no_service' => __( 'This Slack webhook has been deleted or disabled. Create a new Incoming Webhook and paste it here.', 'chargeforms' ), 'channel_not_found' => __( 'The Slack channel this webhook posts to no longer exists.', 'chargeforms' ), 'channel_is_archived' => __( 'The Slack channel this webhook posts to has been archived.', 'chargeforms' ), 'action_prohibited' => __( 'Slack blocked this request. The app behind this webhook may have been removed from the workspace.', 'chargeforms' ), 'posting_to_general_channel_denied' => __( 'This webhook isn\'t allowed to post to that channel.', 'chargeforms' ), 'invalid_payload' => __( 'Slack rejected the message format. This shouldn\'t normally happen — please try again.', 'chargeforms' ), 'messages_tab_disabled' => __( 'The Slack app behind this webhook has its "Messages Tab" turned off, so Slack won\'t let it post. Go to api.slack.com/apps, open the app, go to App Home, and enable the Messages Tab — then try again.', 'chargeforms' ), ]; if ( isset( $map[ $body ] ) ) { return $map[ $body ]; } if ( '' === $body ) { return sprintf( /* translators: %d: HTTP status code */ __( 'Slack returned HTTP %d with no further detail.', 'chargeforms' ), $code ); } /* translators: %s: Slack's raw error response */ return sprintf( __( 'Slack rejected the webhook (%s).', 'chargeforms' ), $body ); } /** * Discord's Incoming Webhook errors are a JSON object * (`{"message": "...", "code": N}`, Discord's own documented numeric * API error codes — see discord.com/developers/docs/topics/opcodes-and-status-codes), * not a bare string like Slack's — same "translate the ones Discord * documents, still surface the rest with context" approach as * friendly_slack_error(). */ private static function friendly_discord_error( $body, $code ) { if ( 429 === (int) $code ) { return __( 'Discord is rate-limiting this webhook — too many messages sent too quickly. Wait a moment and try again.', 'chargeforms' ); } $decoded = json_decode( (string) $body, true ); $discord_code = is_array( $decoded ) ? ( $decoded['code'] ?? null ) : null; $map = [ 10015 => __( 'This Discord webhook no longer exists — it may have been deleted. Create a new Incoming Webhook and paste it here.', 'chargeforms' ), 50027 => __( 'This Discord webhook token is invalid. Double-check you copied the full URL.', 'chargeforms' ), 30007 => __( 'This Discord channel has reached its maximum number of webhooks. Delete an unused one or create the webhook on a different channel.', 'chargeforms' ), 50035 => __( 'Discord rejected the message format. This shouldn\'t normally happen — please try again.', 'chargeforms' ), ]; if ( null !== $discord_code && isset( $map[ $discord_code ] ) ) { return $map[ $discord_code ]; } if ( is_array( $decoded ) && ! empty( $decoded['message'] ) ) { /* translators: %s: Discord's raw error message */ return sprintf( __( 'Discord rejected the webhook (%s).', 'chargeforms' ), $decoded['message'] ); } if ( '' === trim( (string) $body ) ) { return sprintf( /* translators: %d: HTTP status code */ __( 'Discord returned HTTP %d with no further detail.', 'chargeforms' ), $code ); } /* translators: %s: Discord's raw error response */ return sprintf( __( 'Discord rejected the webhook (%s).', 'chargeforms' ), $body ); } /** * Telegram's Bot API errors are a JSON object * (`{"ok": false, "error_code": N, "description": "..."}`) — Telegram's * own `description` text is already fairly human-readable (unlike * Slack's bare codes or Discord's numeric-only codes), so this mostly * adds context for the couple of cases worth a more specific, * actionable message than Telegram's own wording gives. */ private static function friendly_telegram_error( $decoded, $code ) { $description = is_array( $decoded ) ? (string) ( $decoded['description'] ?? '' ) : ''; if ( 401 === (int) $code || false !== stripos( $description, 'unauthorized' ) ) { return __( 'This bot token is invalid. Double-check you copied it exactly from @BotFather.', 'chargeforms' ); } if ( false !== stripos( $description, 'chat not found' ) ) { return __( 'Telegram couldn\'t find that chat ID. Make sure the bot has been added to the chat/channel/group, and that the chat ID is correct.', 'chargeforms' ); } if ( false !== stripos( $description, 'bot was blocked' ) ) { return __( 'This chat has blocked the bot. Unblock it in Telegram, or use a different chat ID.', 'chargeforms' ); } if ( false !== stripos( $description, 'not enough rights' ) || false !== stripos( $description, 'CHAT_WRITE_FORBIDDEN' ) ) { return __( 'The bot doesn\'t have permission to post in this chat. If it\'s a channel, make sure the bot is an admin with post permission.', 'chargeforms' ); } if ( '' !== $description ) { /* translators: %s: Telegram's own error description */ return sprintf( __( 'Telegram rejected this (%s).', 'chargeforms' ), $description ); } return sprintf( /* translators: %d: HTTP status code */ __( 'Telegram returned HTTP %d with no further detail.', 'chargeforms' ), $code ); } /** * Notion's errors are `{"object":"error","status":N,"code":"...","message":"..."}`. * The single most common real-world failure isn't a bad token — it's a * valid token whose integration was simply never shared with the target * database inside Notion (see Notion_Integration's class doc comment) — * that specific 404 gets the actual fix, not a generic "not found". */ private static function friendly_notion_error( $decoded, $code ) { $notion_code = is_array( $decoded ) ? (string) ( $decoded['code'] ?? '' ) : ''; $message = is_array( $decoded ) ? (string) ( $decoded['message'] ?? '' ) : ''; if ( 401 === (int) $code || 'unauthorized' === $notion_code ) { return __( 'This integration token is invalid. Double-check you copied it exactly from notion.so/my-integrations.', 'chargeforms' ); } if ( 'object_not_found' === $notion_code || 404 === (int) $code ) { return __( 'Notion couldn\'t find that database — or, more likely, the database exists but hasn\'t been shared with this integration yet. In Notion, open the database, click "Share", and invite your integration by name.', 'chargeforms' ); } if ( 'restricted_resource' === $notion_code || 403 === (int) $code ) { return __( 'This integration doesn\'t have permission to access that database. Share the database with it in Notion first.', 'chargeforms' ); } if ( '' !== $message ) { /* translators: %s: Notion's own error message */ return sprintf( __( 'Notion rejected this (%s).', 'chargeforms' ), $message ); } return sprintf( /* translators: %d: HTTP status code */ __( 'Notion returned HTTP %d with no further detail.', 'chargeforms' ), $code ); } /** category: 'crm' | 'core' — mirrors the reference product's own "CRM & SaaS" / "WP Core Modules" split. */ public static function modules() { return [ [ 'key' => 'webhook', 'label' => __( 'Webhook', 'chargeforms' ), 'category' => 'core', 'built' => true ], [ 'key' => 'email', 'label' => __( 'Email Notifications', 'chargeforms' ), 'category' => 'core', 'built' => true ], [ 'key' => 'slack', 'label' => __( 'Slack', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'discord', 'label' => __( 'Discord', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'telegram', 'label' => __( 'Telegram', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'notion', 'label' => __( 'Notion', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'mailchimp', 'label' => __( 'Mailchimp', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'hubspot', 'label' => __( 'HubSpot', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'sheets', 'label' => __( 'Google Sheets', 'chargeforms' ), 'category' => 'crm', 'built' => true ], // Not built yet — listed "Coming Soon" so a site owner can see // what's on the roadmap rather than assuming ChargeForms simply // doesn't plan to support it (the same reason the reference product lists // its own unbuilt modules rather than hiding them). [ 'key' => 'activecampaign', 'label' => __( 'ActiveCampaign', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'brevo', 'label' => __( 'Brevo', 'chargeforms' ), 'category' => 'crm', 'built' => true ], [ 'key' => 'constant-contact', 'label' => __( 'Constant Contact', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'getresponse', 'label' => __( 'GetResponse', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'convertkit', 'label' => __( 'ConvertKit', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'drip', 'label' => __( 'Drip', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'zapier', 'label' => __( 'Zapier', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'pipedrive', 'label' => __( 'Pipedrive', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'salesforce', 'label' => __( 'Salesforce', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'trello', 'label' => __( 'Trello', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'zoom', 'label' => __( 'Zoom', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'google-calendar', 'label' => __( 'Google Calendar', 'chargeforms' ), 'category' => 'crm', 'built' => false ], [ 'key' => 'campaign-monitor', 'label' => __( 'Campaign Monitor', 'chargeforms' ), 'category' => 'crm', 'built' => false ], ]; /** * Lets an add-on plugin list itself on the Integrations screen. * * This is where a site owner goes to see what ChargeForms can talk * to, so an installed add-on that doesn't appear here reads as "not * installed" even when it's active and working — which is exactly * the confusion this filter exists to prevent. * * A listener appends an array with at least `key`, `label`, * `category` and `built`. `key` must be unique. Because the card * bodies below are hand-written per built-in key, a module whose key * isn't one of those renders through the generic branch, which uses * these optional entries: * * description Sentence shown on the card. * action_url Where the card's button goes. * action_label That button's text. * status 'on' for the green Enabled pill, anything else for * the muted one. * status_label Text of that pill. * * @param array $modules */ return apply_filters( 'chargeforms_integration_modules', $modules ); } /** * Local inline service marks from Simple Icons (CC0), with Slack's existing * multicolor mark retained. See docs/INTEGRATION_ICON_SOURCES.md. * No external image requests or credential-bearing third-party URLs. */ private static function icon_svg( $key ) { switch ( $key ) { case 'brevo': return ''; case 'activecampaign': return Icon::html( 'mail', 24 ); case 'slack': return ''; case 'discord': return ''; case 'telegram': return ''; case 'notion': return ''; case 'mailchimp': return ''; case 'hubspot': return ''; case 'sheets': return ''; case 'webhook': return ''; case 'email': return ''; default: return Icon::html( 'puzzle', 20 ); } } public function render() { $settings = Settings::all(); $all_modules = self::modules(); // Whitelist derived from the modules actually present rather than a // hardcoded ['crm','core'], so a category introduced by an add-on // through `chargeforms_integration_modules` is filterable too instead // of silently falling back to "all". $valid_categories = array_unique( wp_list_pluck( $all_modules, 'category' ) ); $category = isset( $_GET['category'] ) && in_array( $_GET['category'], $valid_categories, true ) ? sanitize_key( $_GET['category'] ) : 'all'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $modules = array_filter( $all_modules, function ( $m ) use ( $category, $search ) { if ( 'all' !== $category && $m['category'] !== $category ) { return false; } if ( '' !== $search && false === stripos( $m['label'], $search ) ) { return false; } return true; } ); ?>