id, array( 'dashboard', 'plugins' ), true ) ) { return; } // The notice only invites setup, so it goes away for good once an endpoint exists. if ( Endpoints::all() ) { delete_option( self::WELCOME_OPTION ); return; } $start = admin_url( 'admin.php?page=hookbird&hookbird_tour=1' ); $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=hookbird_dismiss_welcome' ), 'hookbird_dismiss_welcome' ); ?>

403 ) ); } check_admin_referer( 'hookbird_dismiss_welcome' ); delete_option( self::WELCOME_OPTION ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'plugins.php' ) ); exit; } /** * Remember that the tour was finished or skipped. */ public function ajax_tour_done() { check_ajax_referer( 'hookbird_tour', 'nonce' ); if ( ! current_user_can( Admin::CAP ) ) { wp_send_json_error( null, 403 ); } update_user_meta( get_current_user_id(), self::TOUR_META, time() ); delete_option( self::WELCOME_OPTION ); wp_send_json_success(); } /** * Setup checklist state for the endpoints screen. * * @return array[] Each item: key, label, help, done, url. */ public static function checklist() { $endpoints = Endpoints::all(); $has_ep = ! empty( $endpoints ); $tested = false; $real = false; if ( $has_ep ) { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table, two cheap existence checks on an admin screen. $tested = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE is_test = 1 AND status = 'success' LIMIT 1", $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- See above. $real = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE is_test = 0 AND status = 'success' LIMIT 1", $table ) ); } $first = $has_ep ? admin_url( 'admin.php?page=hookbird-edit&id=' . $endpoints[0]['id'] ) : admin_url( 'admin.php?page=hookbird-edit' ); return array( array( 'key' => 'endpoint', 'label' => __( 'Connect a destination', 'hookbird' ), 'help' => __( 'Pick Slack, Discord, n8n or any URL and choose the events it should get.', 'hookbird' ), 'done' => $has_ep, 'url' => admin_url( 'admin.php?page=hookbird-edit' ), ), array( 'key' => 'test', 'label' => __( 'Send a test event', 'hookbird' ), 'help' => __( 'Sample data shows you exactly what will arrive, before anything real happens.', 'hookbird' ), 'done' => $tested, 'url' => $first, ), array( 'key' => 'live', 'label' => __( 'Receive your first real event', 'hookbird' ), 'help' => __( 'Publish a post, register a user or place an order and watch it arrive.', 'hookbird' ), 'done' => $real, 'url' => admin_url( 'admin.php?page=hookbird-log' ), ), ); } /** * Health checks for the Help & Setup screen. * * @return array[] Each: label, status (ok|warn|fail|info), detail. */ public static function status_checks() { global $wpdb, $wp_version; $checks = array(); $checks[] = array( 'label' => __( 'PHP version', 'hookbird' ), 'status' => version_compare( PHP_VERSION, '7.4', '>=' ) ? 'ok' : 'fail', 'detail' => PHP_VERSION, ); $checks[] = array( 'label' => __( 'WordPress version', 'hookbird' ), 'status' => version_compare( $wp_version, '6.2', '>=' ) ? 'ok' : 'fail', 'detail' => $wp_version, ); $tables = 0; foreach ( array( Installer::endpoints_table(), Installer::deliveries_table() ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Diagnostic check on an admin screen. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) { ++$tables; } } $checks[] = array( 'label' => __( 'Database tables', 'hookbird' ), 'status' => 2 === $tables ? 'ok' : 'fail', 'detail' => 2 === $tables ? __( 'Both tables are in place.', 'hookbird' ) : __( 'Missing. Deactivate and activate Hookbird to create them.', 'hookbird' ), ); $https = function_exists( 'curl_version' ) || extension_loaded( 'openssl' ); $checks[] = array( 'label' => __( 'Secure connections', 'hookbird' ), 'status' => $https ? 'ok' : 'fail', 'detail' => $https ? __( 'cURL or OpenSSL is available for HTTPS requests.', 'hookbird' ) : __( 'Neither cURL nor OpenSSL is available, so HTTPS webhooks cannot be sent. Ask your host to enable one.', 'hookbird' ), ); $blocked = defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL; $checks[] = array( 'label' => __( 'Outgoing requests', 'hookbird' ), 'status' => $blocked ? 'warn' : 'ok', 'detail' => $blocked ? __( 'WP_HTTP_BLOCK_EXTERNAL is on. Add your webhook hosts to WP_ACCESSIBLE_HOSTS in wp-config.php or deliveries will fail.', 'hookbird' ) : __( 'WordPress is allowed to contact external services.', 'hookbird' ), ); $cron_off = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; $checks[] = array( 'label' => __( 'Background processing', 'hookbird' ), 'status' => $cron_off ? 'warn' : 'ok', 'detail' => $cron_off ? __( 'WP-Cron is disabled. Make sure a server cron job calls wp-cron.php every minute, or runs "wp hookbird run".', 'hookbird' ) : __( 'WP-Cron is enabled. Deliveries are sent in the background right after each page load.', 'hookbird' ), ); $last = (int) get_option( self::LAST_RUN ); $checks[] = array( 'label' => __( 'Last queue run', 'hookbird' ), 'status' => $last ? 'ok' : 'info', /* translators: %s: human time difference. */ 'detail' => $last ? sprintf( __( '%s ago', 'hookbird' ), human_time_diff( $last ) ) : __( 'Not yet. It runs after the first event is queued.', 'hookbird' ), ); $next = Deliveries::next_due_time(); $overdue = $next && $next < time() - 5 * MINUTE_IN_SECONDS; $pending = Deliveries::count( 'pending' ); $checks[] = array( 'label' => __( 'Queue', 'hookbird' ), 'status' => $overdue ? 'warn' : 'ok', 'detail' => $overdue /* translators: %d: number of deliveries. */ ? sprintf( _n( '%d delivery is overdue. Background processing may not be running.', '%d deliveries are waiting and some are overdue. Background processing may not be running.', $pending, 'hookbird' ), $pending ) /* translators: %d: number of deliveries. */ : sprintf( _n( '%d delivery waiting.', '%d deliveries waiting.', $pending, 'hookbird' ), $pending ), ); $paused = 0; foreach ( Endpoints::all() as $endpoint ) { if ( $endpoint['paused_at'] ) { ++$paused; } } $checks[] = array( 'label' => __( 'Paused endpoints', 'hookbird' ), 'status' => $paused ? 'warn' : 'ok', 'detail' => $paused /* translators: %d: number of endpoints. */ ? sprintf( _n( '%d endpoint was paused after repeated failures. Fix it and click Resume.', '%d endpoints were paused after repeated failures. Fix them and click Resume.', $paused, 'hookbird' ), $paused ) : __( 'None.', 'hookbird' ), ); $checks[] = array( 'label' => __( 'Local and private URLs', 'hookbird' ), 'status' => 'info', 'detail' => Settings::allow_local_urls() ? __( 'Allowed. Endpoints can point at localhost, your network and custom ports.', 'hookbird' ) : __( 'Blocked for safety. Turn this on in Settings to use a self-hosted n8n on your network or a custom port.', 'hookbird' ), ); $checks[] = array( 'label' => __( 'WooCommerce', 'hookbird' ), 'status' => 'info', 'detail' => class_exists( 'WooCommerce' ) ? __( 'Active. Order and stock events are available.', 'hookbird' ) : __( 'Not active. Order and stock events start working once it is.', 'hookbird' ), ); return $checks; } /** * Check that WordPress can reach itself, which WP-Cron needs to run in the background. */ public function handle_check_loopback() { if ( ! current_user_can( Admin::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'hookbird' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'hookbird_check_loopback' ); $response = wp_remote_post( site_url( 'wp-cron.php' ), array( 'timeout' => 10, 'blocking' => true, // The loopback goes to this same site, which may use a self-signed certificate locally. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter. ) ); $key = 'hookbird_notices_' . get_current_user_id(); $notices = (array) get_transient( $key ); if ( is_wp_error( $response ) ) { $notices[] = array( 'type' => 'error', /* translators: %s: error message. */ 'message' => sprintf( __( 'Your site could not reach itself: %s Background deliveries need this. Ask your host about loopback requests, or set up a server cron job.', 'hookbird' ), $response->get_error_message() ), ); } else { $code = (int) wp_remote_retrieve_response_code( $response ); $notices[] = $code >= 200 && $code < 400 ? array( 'type' => 'success', /* translators: %d: HTTP status code. */ 'message' => sprintf( __( 'Background processing works. Your site reached wp-cron.php (HTTP %d).', 'hookbird' ), $code ), ) : array( 'type' => 'warning', /* translators: %d: HTTP status code. */ 'message' => sprintf( __( 'Your site reached wp-cron.php but got HTTP %d. A security plugin or server rule may be blocking it.', 'hookbird' ), $code ), ); } set_transient( $key, $notices, 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( admin_url( 'admin.php?page=hookbird-help#status' ) ); exit; } /** * Step-by-step setup guides per destination. * * @return array key => [ label, kind, steps[] ] */ public static function guides() { return array( 'slack' => array( 'label' => 'Slack', 'kind' => __( 'Team chat', 'hookbird' ), 'steps' => array( __( 'In Slack, create a channel for the alerts, for example #shop-alerts.', 'hookbird' ), __( 'Open api.slack.com/apps, click Create New App, choose From scratch, name it and pick your workspace.', 'hookbird' ), __( 'Open Incoming Webhooks, switch it on, click Add New Webhook, choose the channel and click Allow.', 'hookbird' ), __( 'Copy the URL that starts with https://hooks.slack.com/services/.', 'hookbird' ), __( 'In Hookbird, add an endpoint, choose Slack, paste the URL, pick events and send a test.', 'hookbird' ), ), ), 'discord' => array( 'label' => 'Discord', 'kind' => __( 'Team chat', 'hookbird' ), 'steps' => array( __( 'Hover the channel in your server and click the gear icon (Edit Channel).', 'hookbird' ), __( 'Open Integrations, then Webhooks, then New Webhook. Give it a name such as Hookbird.', 'hookbird' ), __( 'Click Copy Webhook URL.', 'hookbird' ), __( 'In Hookbird, add an endpoint, choose Discord, paste the URL, pick events and send a test.', 'hookbird' ), ), ), 'telegram' => array( 'label' => 'Telegram', 'kind' => __( 'Messaging', 'hookbird' ), 'steps' => array( __( 'Message @BotFather, send /newbot and follow the prompts. Copy the bot token it gives you.', 'hookbird' ), __( 'Add the bot to your group or channel. For a channel, make it an admin so it can post.', 'hookbird' ), __( 'Find the chat ID: a public channel can use @channelname; for a group, send a message and open api.telegram.org/bot/getUpdates to read chat.id.', 'hookbird' ), __( 'In Hookbird, choose Telegram, use https://api.telegram.org/bot/sendMessage as the URL and enter the chat ID.', 'hookbird' ), ), ), 'teams' => array( 'label' => 'Microsoft Teams', 'kind' => __( 'Team chat', 'hookbird' ), 'steps' => array( __( 'In the Teams channel, open the ••• menu and choose Workflows.', 'hookbird' ), __( 'Pick the template "Post to a channel when a webhook request is received" and finish the wizard.', 'hookbird' ), __( 'Copy the workflow URL it shows at the end.', 'hookbird' ), __( 'In Hookbird, choose Microsoft Teams, paste the URL and send a test.', 'hookbird' ), ), ), 'googlechat' => array( 'label' => 'Google Chat', 'kind' => __( 'Team chat', 'hookbird' ), 'steps' => array( __( 'Open the space, click its name, then Apps and integrations.', 'hookbird' ), __( 'Choose Add webhooks, give it a name and save.', 'hookbird' ), __( 'Copy the webhook URL.', 'hookbird' ), __( 'In Hookbird, choose Google Chat, paste the URL and send a test.', 'hookbird' ), ), ), 'n8n' => array( 'label' => 'n8n', 'kind' => __( 'Automation', 'hookbird' ), 'steps' => array( __( 'Create a workflow and add a Webhook trigger node. Set the HTTP method to POST.', 'hookbird' ), __( 'Copy the Production URL (the Test URL only works while you are listening in the editor).', 'hookbird' ), __( 'If n8n runs on your own network or on port 5678, turn on "Allow local and private network URLs" in Hookbird settings.', 'hookbird' ), __( 'In Hookbird, choose n8n, paste the URL, then send a test and activate the workflow.', 'hookbird' ), ), ), 'make' => array( 'label' => 'Make', 'kind' => __( 'Automation', 'hookbird' ), 'steps' => array( __( 'Create a scenario and add Webhooks, Custom webhook as the first module.', 'hookbird' ), __( 'Click Add, name the hook and copy its address.', 'hookbird' ), __( 'In Hookbird, choose Make, paste the address and send a test while Make shows "Determine data structure".', 'hookbird' ), ), ), 'zapier' => array( 'label' => 'Zapier', 'kind' => __( 'Automation', 'hookbird' ), 'steps' => array( __( 'Create a Zap with the trigger Webhooks by Zapier, event Catch Hook.', 'hookbird' ), __( 'Copy the custom webhook URL.', 'hookbird' ), __( 'In Hookbird, choose Zapier, paste the URL and send a test, then click Test trigger in Zapier.', 'hookbird' ), ), ), ); } /** * Troubleshooting answers. * * @return array[] question, answer. */ public static function troubleshooting() { return array( array( 'q' => __( 'The test works but real events never arrive', 'hookbird' ), 'a' => __( 'Real events are sent in the background by WP-Cron, which runs when someone visits your site. On a quiet or local site, open any page to trigger it, or click Process queue now in Settings. Check Help & Setup, System status: if Background processing or the loopback test fails, set up a server cron job that calls wp-cron.php every minute.', 'hookbird' ), ), array( 'q' => __( '"This URL points to a local or private address"', 'hookbird' ), 'a' => __( 'For safety, Hookbird blocks localhost, private network addresses and non-standard ports. If you trust the destination, for example a self-hosted n8n on port 5678, turn on "Allow local and private network URLs" in Settings.', 'hookbird' ), ), array( 'q' => __( 'Deliveries fail with HTTP 404 or 410', 'hookbird' ), 'a' => __( 'The webhook was deleted or the URL is incomplete. Create a new webhook in the destination app, paste the full URL into the endpoint and send a test.', 'hookbird' ), ), array( 'q' => __( 'Deliveries fail with HTTP 400', 'hookbird' ), 'a' => __( 'The destination did not accept the message format. Check that the endpoint uses the matching destination (a Slack URL needs the Slack preset). For Telegram, check the chat ID and that the bot is in the chat. For custom webhooks, the response body in the delivery details usually says what is wrong.', 'hookbird' ), ), array( 'q' => __( 'Deliveries fail with HTTP 401 or 403', 'hookbird' ), 'a' => __( 'The receiver needs authentication. Add the header it expects under Extra headers, for example Authorization: Bearer your-token.', 'hookbird' ), ), array( 'q' => __( 'An endpoint shows Paused', 'hookbird' ), 'a' => __( 'After too many failed attempts in a row, Hookbird pauses the endpoint and emails you, so a broken destination does not fill your queue with retries. Events keep queueing. Fix the cause, then click Resume and the queued events are delivered.', 'hookbird' ), ), array( 'q' => __( 'WooCommerce events do not show up', 'hookbird' ), 'a' => __( 'Make sure WooCommerce is active and the events are ticked on the endpoint. Conditions only send matching orders, so check them too. Orders created by importing tools may skip the hooks WooCommerce normally fires.', 'hookbird' ), ), array( 'q' => __( 'I get too many failed login alerts', 'hookbird' ), 'a' => __( 'Failed login events are already limited to 3 per IP address and 20 per site every 10 minutes. To get fewer, add a condition such as username equals admin, or untick the event and use a security plugin for brute force protection.', 'hookbird' ), ), array( 'q' => __( 'How do I verify that a request came from my site?', 'hookbird' ), 'a' => __( 'Each request has webhook-id, webhook-timestamp and webhook-signature headers following the Standard Webhooks specification. Compute an HMAC-SHA256 of id.timestamp.body using the endpoint secret, base64 encode it and compare it with the part after "v1,". The endpoint screen has a PHP example.', 'hookbird' ), ), ); } }