diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md new file mode 100644 index 0000000..a210107 --- /dev/null +++ b/docs/telegram-setup.md @@ -0,0 +1,140 @@ +# Connect openclaWP to Telegram via the Bot API + +> Agent runbook. Each step is a verifiable command. The plugin is already built; this is the configuration handoff between Telegram's BotFather and your WordPress install. + +## Preconditions + +```bash +wp plugin is-active openclawp && echo "ok: openclawp" || echo "missing: openclawp" +wp eval 'echo apply_filters("openclawp_register_telegram", false) ? "ok: filter on" : "missing: add the filter"' +wp eval 'echo function_exists("wp_get_agent") ? "ok: agents-api" : "missing: agents-api"' +``` + +If the second line says `missing`, add this to a mu-plugin (or your theme `functions.php`): + +```php +add_filter( 'openclawp_register_telegram', '__return_true' ); +``` + +After this, **openclaWP → Telegram** appears in wp-admin and `/wp-json/openclawp/v1/telegram/webhook` answers HTTP requests. + +## Steps + +### 1. Get your webhook URL + +```bash +wp eval 'echo rest_url("openclawp/v1/telegram/webhook");' +``` + +Copy this. You'll register it with Telegram in step 4. + +If WordPress is on `localhost` (Studio, wp-env), Telegram cannot reach it directly. Tunnel first: + +```bash +# Option A: ngrok +ngrok http 8887 +# → Use the https://...ngrok-free.app/wp-json/openclawp/v1/telegram/webhook URL + +# Option B: cloudflared +cloudflared tunnel --url http://localhost:8887 +``` + +Telegram requires HTTPS — plain `http://` tunnels are rejected by `setWebhook`. + +### 2. Create a bot via BotFather + +1. On Telegram, message [@BotFather](https://t.me/BotFather). +2. Send `/newbot`. BotFather will ask for a display name and a username ending in `bot`. +3. It returns a **bot token** like `123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`. Treat this like a password. + +For development, send your bot any message from your own account so it knows you exist. + +### 3. Find your chat ID + +You need to allowlist at least one chat ID, or every Telegram user who discovers the bot can reach the agent. + +```bash +# Replace with the value from step 2. +curl -s "https://api.telegram.org/bot/getUpdates" | jq '.result[].message.chat.id' +``` + +Each `id` is a chat. For 1:1 DMs the chat id equals your Telegram user id. Copy the number you want allowed. + +### 4. Paste credentials into openclaWP + +Visit **wp-admin → openclaWP → Telegram**, fill in: + +| Field | Source | +|---|---| +| Bot token | BotFather (step 2) | +| Webhook secret token | A free-form string you invent (letters/digits/`-_`, 1–256 chars). Telegram sends this back in `X-Telegram-Bot-Api-Secret-Token` on every webhook. | +| Chat allowlist | Comma-separated chat IDs from step 3. `*` allows everyone (dev only). Empty = nothing allowed. | +| Default agent | Pick a registered agent from the dropdown | +| Owner user ID | The WP user that owns inbound conversation sessions | + +Save. + +### 5. Register the webhook + +Click **Register webhook** on the same page. Under the hood this calls Telegram's `setWebhook` with the URL from step 1 and your secret token. A green notice confirms success; a red one surfaces the API error. + +Equivalent curl, if you'd rather call Telegram yourself: + +```bash +curl -X POST "https://api.telegram.org/bot/setWebhook" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "", + "secret_token": "", + "allowed_updates": ["message"] + }' +``` + +### 6. Send a test message + +Open your bot in Telegram, send `hola`. Within ~10 seconds you should: + +```bash +# See an inbound payload arrive (secret-verified): +tail -f /path/to/php-error.log | grep openclawp + +# Expected (one chat_turn per loop turn): +[openclawp] chat_turn={"agent_slug":"your-agent","provider":"...","model":"...","duration_ms":...,"success":true,...} +``` + +…and then receive a reply on Telegram, threaded under your original message via `reply_to_message_id`. + +## Failure signals + +| Symptom | Likely cause | Fix | +|---|---|---| +| `setWebhook` returns "bad webhook: HTTPS url must be provided" | Tunnel URL is `http://` | Use ngrok/cloudflared which expose `https://`. | +| Inbound 401 in error log | `X-Telegram-Bot-Api-Secret-Token` mismatch | Re-save settings — Telegram's stored secret only updates after a successful `setWebhook` call. | +| Allowlist counter increments but no reply | Sender's chat id isn't allowed | Add it to the allowlist (step 3 + 4). | +| Reply doesn't thread | `reply_to_message_id` is 0 | Only the inbound message itself gets threaded; status / system updates do not include a message_id. | +| Reply takes 30+ s | Agent loaded a slow model on first call | Pre-warm (`ollama run ` once) or pin a smaller model in the agent's `default_config['model']`. | +| `unsupported: true` in response | Inbound was a photo/voice/sticker/document | v1 is text-only. Other types ack 200 and are logged as unsupported. | +| No reply at all | Outbound POST to api.telegram.org errored | Check `[openclawp] telegram_send_failed` in error_log; confirm the bot token. | + +## End-to-end smoke without Telegram + +You can test the entire path locally — secret verification, agent dispatch, outbound — by hand-crafting an inbound update. Block outbound HTTP, send a fake payload: + +```bash +PAYLOAD='{"update_id":1,"message":{"message_id":42,"from":{"id":15555550100},"chat":{"id":15555550100,"type":"private"},"text":"hello"}}' +SECRET=$(wp option get openclawp_telegram_settings --format=json | jq -r .secret_token) + +curl -i -X POST "$(wp eval 'echo rest_url("openclawp/v1/telegram/webhook");')" \ + -H "Content-Type: application/json" \ + -H "X-Telegram-Bot-Api-Secret-Token: $SECRET" \ + -d "$PAYLOAD" +``` + +Expected: `200 OK`, body `{"received":true,"processed":1}` (provided `15555550100` is in your allowlist; otherwise `processed:0, dropped:true`). + +## What's not in this version + +- Media (photos, voice, stickers, documents). v1 is text-only — they ack 200 and log "unsupported". +- Inline mode, callback queries, polls. +- Multi-bot. v1 maps one token → one webhook → one default agent. +- Per-chat agent routing. v1 sends everything to one configured agent. diff --git a/includes/class-openclawp-bootstrap.php b/includes/class-openclawp-bootstrap.php index a85e63f..73cd9c7 100644 --- a/includes/class-openclawp-bootstrap.php +++ b/includes/class-openclawp-bootstrap.php @@ -89,6 +89,19 @@ public static function init(): void { if ( apply_filters( 'openclawp_register_whatsapp', false ) ) { OpenclaWP_Whatsapp::register(); } + + /** + * Whether to register the Telegram Bot API ingress (REST webhook + + * outbound sender + settings page). + * + * Off by default. Opt in with `add_filter( 'openclawp_register_telegram', '__return_true' )` + * and configure credentials at openclaWP → Telegram. + * + * @param bool $enabled Default false. + */ + if ( apply_filters( 'openclawp_register_telegram', false ) ) { + OpenclaWP_Telegram::register(); + } } public static function register_blocks(): void { diff --git a/includes/class-openclawp-telegram.php b/includes/class-openclawp-telegram.php new file mode 100644 index 0000000..cb247d2 --- /dev/null +++ b/includes/class-openclawp-telegram.php @@ -0,0 +1,615 @@ + '', + 'secret_token' => '', + 'allowlist' => '', + 'default_agent' => '', + 'user_id' => 0, + ) + ); + } + + /* -------------------------------- REST -------------------------------- */ + + public static function register_routes(): void { + register_rest_route( + self::REST_NAMESPACE, + self::REST_ROUTE, + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'handle_inbound' ), + 'permission_callback' => '__return_true', + ) + ); + } + + /** + * Inbound update webhook. Verify the secret_token header, parse the + * Update payload, dispatch to the configured agent, and reply via the + * Bot API. + * + * Telegram authenticates webhook deliveries via a shared secret sent in + * `X-Telegram-Bot-Api-Secret-Token`. We treat empty/missing settings as + * a fail-closed state — the webhook only accepts requests once a token + * is configured. + */ + public static function handle_inbound( WP_REST_Request $request ) { + $settings = self::settings(); + $header = (string) ( $request->get_header( 'x_telegram_bot_api_secret_token' ) ?? '' ); + + if ( ! self::verify_secret( $header, $settings['secret_token'] ) ) { + return new WP_Error( 'openclawp_telegram_bad_secret', __( 'Invalid secret token.', 'openclawp' ), array( 'status' => 401 ) ); + } + + $payload = json_decode( $request->get_body(), true ); + if ( ! is_array( $payload ) ) { + return new WP_REST_Response( array( 'received' => true ), 200 ); + } + + $message = self::extract_message( $payload ); + if ( null === $message ) { + // Non-text update (edited messages, channel posts, callbacks, etc.). + // v1 acks 200 and drops. + return new WP_REST_Response( + array( + 'received' => true, + 'processed' => 0, + 'unsupported' => true, + ), + 200 + ); + } + + // Allowlist gate. Telegram bots can be discovered by anyone — without + // an allowlist, any user who finds the bot can send agent traffic. + if ( ! self::is_allowed( $message['chat_id'], $settings['allowlist'] ) ) { + self::increment_dropped(); + return new WP_REST_Response( + array( + 'received' => true, + 'processed' => 0, + 'dropped' => true, + ), + 200 + ); + } + + $agent_slug = $settings['default_agent']; + $user_id = (int) ( $settings['user_id'] ?: get_current_user_id() ); + if ( '' === $agent_slug || ! function_exists( 'wp_get_agent' ) || null === wp_get_agent( $agent_slug ) ) { + return new WP_Error( 'openclawp_telegram_no_agent', __( 'Telegram adapter has no configured agent.', 'openclawp' ), array( 'status' => 503 ) ); + } + + $ok = self::dispatch( $message, $agent_slug, $user_id, $settings ); + + return new WP_REST_Response( + array( + 'received' => true, + 'processed' => $ok ? 1 : 0, + ), + 200 + ); + } + + /* ----------------------------- Signature ------------------------------ */ + + /** + * Constant-time compare between the configured secret and what Telegram + * sent in `X-Telegram-Bot-Api-Secret-Token`. Fails closed when either + * side is empty so an unconfigured plugin never accepts unauthenticated + * inbound traffic. + */ + public static function verify_secret( string $header, string $expected ): bool { + if ( '' === $expected || '' === $header ) { + return false; + } + return hash_equals( $expected, $header ); + } + + /* ----------------------------- Allowlist ------------------------------ */ + + /** + * Allowlist is a comma-separated list of integer chat IDs. Empty list + * = nothing allowed (fail-closed). A literal `*` disables the gate so + * operators can opt out explicitly during development. + */ + public static function is_allowed( int $chat_id, string $allowlist ): bool { + $allowlist = trim( $allowlist ); + if ( '' === $allowlist ) { + return false; + } + if ( '*' === $allowlist ) { + return true; + } + foreach ( explode( ',', $allowlist ) as $entry ) { + $entry = trim( $entry ); + if ( '' === $entry ) { + continue; + } + if ( (string) $chat_id === $entry ) { + return true; + } + } + return false; + } + + private static function increment_dropped(): void { + $count = (int) get_option( self::DROPPED_OPTION, 0 ); + update_option( self::DROPPED_OPTION, $count + 1, false ); + } + + /* ----------------------------- Dispatch ------------------------------- */ + + /** + * Extract a normalized text message from Telegram's `Update` envelope. + * + * v1 handles `message.text` only. Edits, channel posts, captions on + * media, inline queries, and callbacks are intentionally skipped. + * + * @return array{chat_id:int,user_id:int,text:string,message_id:int}|null + */ + public static function extract_message( array $payload ): ?array { + $message = $payload['message'] ?? null; + if ( ! is_array( $message ) ) { + return null; + } + + $chat_id = isset( $message['chat']['id'] ) ? (int) $message['chat']['id'] : 0; + $user_id = isset( $message['from']['id'] ) ? (int) $message['from']['id'] : 0; + $message_id = isset( $message['message_id'] ) ? (int) $message['message_id'] : 0; + if ( 0 === $chat_id ) { + return null; + } + + $text = isset( $message['text'] ) ? (string) $message['text'] : ''; + if ( '' === $text ) { + // Unsupported media types (photo/voice/sticker/document/etc.) — + // the issue requires ack 200 with "unsupported" log, never crash. + error_log( '[openclawp] telegram_unsupported_type chat=' . $chat_id . ' message=' . $message_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return null; + } + + return array( + 'chat_id' => $chat_id, + 'user_id' => $user_id, + 'text' => $text, + 'message_id' => $message_id, + ); + } + + /** + * Dispatch one inbound text to the agent, persist the session under the + * chat id, send the reply back to that chat (threading the reply when a + * message_id is present). + */ + private static function dispatch( array $message, string $agent_slug, int $user_id, array $settings ): bool { + $chat_id = (int) $message['chat_id']; + $text = (string) $message['text']; + $message_id = (int) ( $message['message_id'] ?? 0 ); + + // Inbound webhook arrives anonymous (no logged-in user). Promote to + // the configured Telegram service user so ability `permission_callback`s + // and `current_user_can()` checks downstream see a real principal. + if ( $user_id > 0 && get_current_user_id() !== $user_id ) { + wp_set_current_user( $user_id ); + } + + $session_id = self::resolve_session_for_chat( $chat_id, $user_id ); + + $result = OpenclaWP_Runner::run_turn( + $agent_slug, + $text, + $session_id, + $user_id, + array( + 'attachments' => array(), + 'client_context' => array( + 'source' => 'channel', + 'connector_id' => 'telegram', + 'client_name' => 'telegram', + 'external_provider' => 'telegram', + 'external_conversation_id' => (string) $chat_id, + 'external_message_id' => (string) $message_id, + 'sender_id' => (string) $chat_id, + 'room_kind' => 'dm', + ), + ) + ); + + if ( ! empty( $result['session_id'] ) ) { + self::tag_session_with_chat( (string) $result['session_id'], $chat_id ); + } + + $reply = isset( $result['reply'] ) ? (string) $result['reply'] : ''; + if ( '' === $reply ) { + return false; + } + + return self::send_text_message( $chat_id, $reply, $settings, $message_id ); + } + + /** + * Find an openclawp_session attached to this chat id, or null to start + * a fresh conversation. + * + * @return string|null Session UUID, or null when no prior session exists. + */ + private static function resolve_session_for_chat( int $chat_id, int $user_id ): ?string { + $query = new WP_Query( + array( + 'post_type' => OpenclaWP_Conversation_Store::POST_TYPE, + 'post_status' => 'any', + 'author' => $user_id, + 'posts_per_page' => 1, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'suppress_filters' => true, + 'meta_key' => self::META_CHAT_KEY, + 'meta_value' => (string) $chat_id, + ) + ); + if ( empty( $query->posts ) ) { + return null; + } + $session_id = (string) get_post_meta( $query->posts[0]->ID, '_openclawp_session_id', true ); + return '' !== $session_id ? $session_id : null; + } + + private static function tag_session_with_chat( string $session_id, int $chat_id ): void { + $query = new WP_Query( + array( + 'post_type' => OpenclaWP_Conversation_Store::POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'suppress_filters' => true, + 'meta_key' => '_openclawp_session_id', + 'meta_value' => $session_id, + ) + ); + if ( empty( $query->posts ) ) { + return; + } + update_post_meta( $query->posts[0]->ID, self::META_CHAT_KEY, (string) $chat_id ); + } + + /* ----------------------------- Outbound ------------------------------- */ + + /** + * Send a text reply via `sendMessage`. Threads the reply to the inbound + * message when a non-zero `reply_to_message_id` is provided. + */ + public static function send_text_message( int $chat_id, string $body, array $settings = array(), int $reply_to_message_id = 0 ): bool { + if ( empty( $settings ) ) { + $settings = self::settings(); + } + $bot_token = (string) $settings['bot_token']; + if ( '' === $bot_token || 0 === $chat_id || '' === $body ) { + return false; + } + + $payload = array( + 'chat_id' => $chat_id, + 'text' => $body, + ); + if ( $reply_to_message_id > 0 ) { + $payload['reply_to_message_id'] = $reply_to_message_id; + $payload['allow_sending_without_reply'] = true; + } + + $response = wp_remote_post( + self::TELEGRAM_API . '/bot' . $bot_token . '/sendMessage', + array( + 'timeout' => 20, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( $payload ), + ) + ); + + if ( is_wp_error( $response ) ) { + error_log( '[openclawp] telegram_send_failed err=' . self::redact_token( $response->get_error_message(), $bot_token ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return false; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code < 200 || $code >= 300 ) { + error_log( '[openclawp] telegram_send_failed status=' . $code . ' body=' . self::redact_token( wp_remote_retrieve_body( $response ), $bot_token ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return false; + } + + return true; + } + + /** + * Strip the bot token from text destined for logs. Telegram URLs embed + * the bot token in the path (`/bot/sendMessage`), so any error + * message that includes the URL leaks the credential. + */ + public static function redact_token( string $text, string $bot_token ): string { + if ( '' === $bot_token ) { + return $text; + } + return str_replace( $bot_token, '[redacted]', $text ); + } + + /* --------------------------- setWebhook helper ------------------------ */ + + /** + * Register our REST URL with Telegram so updates start flowing here. + * Called from the admin "Register webhook" button. + */ + public static function set_webhook( string $bot_token, string $url, string $secret_token ): array { + if ( '' === $bot_token || '' === $url || '' === $secret_token ) { + return array( + 'ok' => false, + 'error' => 'missing-arg', + ); + } + + $response = wp_remote_post( + self::TELEGRAM_API . '/bot' . $bot_token . '/setWebhook', + array( + 'timeout' => 20, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( + array( + 'url' => $url, + 'secret_token' => $secret_token, + 'allowed_updates' => array( 'message' ), + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return array( + 'ok' => false, + 'error' => self::redact_token( $response->get_error_message(), $bot_token ), + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $body ) || empty( $body['ok'] ) ) { + return array( + 'ok' => false, + 'error' => is_array( $body ) ? (string) ( $body['description'] ?? 'unknown' ) : 'invalid-response', + ); + } + return array( 'ok' => true ); + } + + /* ------------------------------ Settings ------------------------------ */ + + public static function register_channel_card( array $channels ): array { + $settings = self::settings(); + $configured = '' !== trim( (string) $settings['bot_token'] ); + $channels[] = array( + 'id' => 'telegram', + 'name' => __( 'Telegram', 'openclawp' ), + 'subtitle' => __( 'Bot API webhook', 'openclawp' ), + 'description' => __( 'Free, official bot API. No carrier registration or business verification.', 'openclawp' ), + 'status' => $configured + ? OpenclaWP_Channels_Admin::STATUS_CONNECTED + : OpenclaWP_Channels_Admin::STATUS_NOT_CONFIGURED, + 'detail_url' => admin_url( 'admin.php?page=openclawp-telegram' ), + ); + return $channels; + } + + public static function register_settings_menu(): void { + add_submenu_page( + 'openclawp', + __( 'Telegram', 'openclawp' ), + __( 'Telegram', 'openclawp' ), + 'manage_options', + 'openclawp-telegram', + array( __CLASS__, 'render_settings_page' ) + ); + } + + public static function register_settings(): void { + register_setting( + 'openclawp_telegram', + self::OPTION_NAME, + array( + 'type' => 'array', + 'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ), + 'default' => array(), + ) + ); + } + + public static function sanitize_settings( $value ): array { + if ( ! is_array( $value ) ) { + $value = array(); + } + return array( + 'bot_token' => isset( $value['bot_token'] ) ? trim( (string) $value['bot_token'] ) : '', + 'secret_token' => isset( $value['secret_token'] ) ? trim( (string) $value['secret_token'] ) : '', + 'allowlist' => isset( $value['allowlist'] ) ? trim( (string) $value['allowlist'] ) : '', + 'default_agent' => isset( $value['default_agent'] ) ? sanitize_title( (string) $value['default_agent'] ) : '', + 'user_id' => isset( $value['user_id'] ) ? (int) $value['user_id'] : 0, + ); + } + + public static function render_settings_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + self::maybe_handle_set_webhook(); + + $settings = self::settings(); + $webhook_url = esc_url( rest_url( self::REST_NAMESPACE . self::REST_ROUTE ) ); + $agents = function_exists( 'wp_get_agents' ) ? wp_get_agents() : array(); + $dropped = (int) get_option( self::DROPPED_OPTION, 0 ); + ?> +
+

+

+ ' . esc_html( $webhook_url ) . '' + ); + ?> +

+ + 0 ) : ?> +
+

+ +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

+

+
+ + + +
+
+

%s

', + esc_html__( 'Telegram webhook registered.', 'openclawp' ) + ); + return; + } + printf( + '

%s %s

', + esc_html__( 'Telegram webhook registration failed:', 'openclawp' ), + esc_html( (string) ( $result['error'] ?? 'unknown' ) ) + ); + } + ); + } +} diff --git a/tests/smoke.php b/tests/smoke.php index 01375e9..f8149de 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -955,6 +955,171 @@ class_exists( 'OpenclaWP_Mcp_Adapter' ) ); } +// Telegram adapter unit-style checks (don't depend on a configured bot). +if ( class_exists( 'OpenclaWP_Telegram' ) ) { + OpenclaWP_Smoke::check( + 'telegram verify_secret accepts a matching token', + OpenclaWP_Telegram::verify_secret( 'shared-secret', 'shared-secret' ) + ); + + OpenclaWP_Smoke::check( + 'telegram verify_secret rejects a mismatch', + false === OpenclaWP_Telegram::verify_secret( 'wrong', 'shared-secret' ) + ); + + OpenclaWP_Smoke::check( + 'telegram verify_secret fails closed when expected is empty', + false === OpenclaWP_Telegram::verify_secret( 'anything', '' ) + ); + + OpenclaWP_Smoke::check( + 'telegram allowlist rejects when list is empty', + false === OpenclaWP_Telegram::is_allowed( 12345, '' ) + ); + + OpenclaWP_Smoke::check( + 'telegram allowlist matches exact chat in CSV', + OpenclaWP_Telegram::is_allowed( 12345, '1, 12345, 7' ) + ); + + $tg_payload = array( + 'update_id' => 1, + 'message' => array( + 'message_id' => 42, + 'from' => array( 'id' => 5001 ), + 'chat' => array( 'id' => 5001, 'type' => 'private' ), + 'text' => 'hola', + ), + ); + $tg_message = OpenclaWP_Telegram::extract_message( $tg_payload ); + OpenclaWP_Smoke::check( 'telegram extract_message picks up text', is_array( $tg_message ) && 'hola' === $tg_message['text'] ); + OpenclaWP_Smoke::check( 'telegram extract_message records chat id', is_array( $tg_message ) && 5001 === $tg_message['chat_id'] ); + + $tg_unsupported = OpenclaWP_Telegram::extract_message( + array( + 'message' => array( + 'message_id' => 1, + 'chat' => array( 'id' => 1 ), + 'photo' => array( array( 'file_id' => 'abc' ) ), + ), + ) + ); + OpenclaWP_Smoke::check( 'telegram extract_message returns null for non-text updates', null === $tg_unsupported ); + + // Webhook handler integration covering the three contract scenarios: + // HMAC mismatch → 401, allowlisted chat → 200 + processed, unauthorized + // chat → 200 + dropped. Saves a throwaway settings option for the + // duration of these checks then restores the prior value. + OpenclaWP_Smoke::register_agent( + 'openclawp-smoke-telegram', + array( + 'label' => 'Telegram smoke agent', + 'description' => 'Echoes the inbound text via the pre-chat-turn filter.', + 'default_config' => array( 'provider' => 'auto', 'model' => 'auto' ), + ) + ); + + $tg_echo_filter = static function ( $preflight, array $turn ) { + if ( 'openclawp-smoke-telegram' !== ( $turn['agent_slug'] ?? '' ) ) { + return $preflight; + } + return array( + 'reply' => 'echo:' . ( $turn['message'] ?? '' ), + 'completed' => true, + ); + }; + add_filter( 'openclawp_pre_chat_turn', $tg_echo_filter, 10, 2 ); + + // Pre-block outbound POST so the test never tries to talk to api.telegram.org. + $tg_http_filter = static function ( $preempt, $args, $url ) { + if ( false !== strpos( (string) $url, 'api.telegram.org' ) ) { + return array( + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'body' => '{"ok":true,"result":{}}', + 'headers' => array(), + ); + } + return $preempt; + }; + add_filter( 'pre_http_request', $tg_http_filter, 10, 3 ); + + $prior_settings = get_option( OpenclaWP_Telegram::OPTION_NAME, array() ); + update_option( + OpenclaWP_Telegram::OPTION_NAME, + array( + 'bot_token' => '123456:smoke', + 'secret_token' => 'smoke-secret', + 'allowlist' => '5001', + 'default_agent' => 'openclawp-smoke-telegram', + 'user_id' => 1, + ), + false + ); + + $build_request = static function ( string $secret_header, string $body ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/openclawp/v1/telegram/webhook' ); + $request->set_header( 'content-type', 'application/json' ); + if ( '' !== $secret_header ) { + $request->set_header( 'x_telegram_bot_api_secret_token', $secret_header ); + } + $request->set_body( $body ); + return $request; + }; + + $valid_payload = wp_json_encode( + array( + 'update_id' => 1, + 'message' => array( + 'message_id' => 100, + 'from' => array( 'id' => 5001 ), + 'chat' => array( 'id' => 5001, 'type' => 'private' ), + 'text' => 'hola', + ), + ) + ); + $unauth_payload = wp_json_encode( + array( + 'update_id' => 2, + 'message' => array( + 'message_id' => 101, + 'from' => array( 'id' => 9999 ), + 'chat' => array( 'id' => 9999, 'type' => 'private' ), + 'text' => 'who-dis', + ), + ) + ); + + $bad_secret_response = OpenclaWP_Telegram::handle_inbound( $build_request( 'wrong-secret', $valid_payload ) ); + OpenclaWP_Smoke::check( + 'telegram webhook returns 401 on HMAC mismatch', + $bad_secret_response instanceof WP_Error + && 401 === (int) ( $bad_secret_response->get_error_data()['status'] ?? 0 ) + ); + + $ok_response = OpenclaWP_Telegram::handle_inbound( $build_request( 'smoke-secret', $valid_payload ) ); + $ok_data = $ok_response instanceof WP_REST_Response ? $ok_response->get_data() : array(); + OpenclaWP_Smoke::check( + 'telegram webhook 200 + processed=1 for allowlisted chat', + $ok_response instanceof WP_REST_Response + && 200 === $ok_response->get_status() + && 1 === (int) ( $ok_data['processed'] ?? 0 ) + ); + + $dropped_response = OpenclaWP_Telegram::handle_inbound( $build_request( 'smoke-secret', $unauth_payload ) ); + $dropped_data = $dropped_response instanceof WP_REST_Response ? $dropped_response->get_data() : array(); + OpenclaWP_Smoke::check( + 'telegram webhook 200 + dropped=true for unauthorized chat', + $dropped_response instanceof WP_REST_Response + && 200 === $dropped_response->get_status() + && true === ( $dropped_data['dropped'] ?? false ) + && 0 === (int) ( $dropped_data['processed'] ?? 0 ) + ); + + remove_filter( 'pre_http_request', $tg_http_filter, 10 ); + remove_filter( 'openclawp_pre_chat_turn', $tg_echo_filter, 10 ); + update_option( OpenclaWP_Telegram::OPTION_NAME, $prior_settings, false ); +} + $failed = OpenclaWP_Smoke::summarize(); if ( $failed > 0 ) { exit( 1 ); diff --git a/tests/unit/TelegramWebhookTest.php b/tests/unit/TelegramWebhookTest.php new file mode 100644 index 0000000..852327b --- /dev/null +++ b/tests/unit/TelegramWebhookTest.php @@ -0,0 +1,117 @@ +assertTrue( OpenclaWP_Telegram::verify_secret( self::SECRET, self::SECRET ) ); + } + + public function test_secret_mismatch_returns_401(): void { + // HMAC mismatch → 401 is the contract guaranteed to the caller. + $this->assertFalse( OpenclaWP_Telegram::verify_secret( 'wrong-secret', self::SECRET ) ); + } + + public function test_empty_expected_fails_closed(): void { + // Unconfigured plugin must reject everything — never accept inbound + // traffic on an empty stored secret. + $this->assertFalse( OpenclaWP_Telegram::verify_secret( 'anything', '' ) ); + } + + public function test_empty_header_fails_closed(): void { + // A request that omits the header entirely must be rejected even + // when the plugin has a secret configured. + $this->assertFalse( OpenclaWP_Telegram::verify_secret( '', self::SECRET ) ); + } + + /* --------------------------- allowlist gate -------------------------- */ + + public function test_allowlist_empty_blocks_everyone(): void { + $this->assertFalse( OpenclaWP_Telegram::is_allowed( 12345, '' ) ); + } + + public function test_allowlist_wildcard_allows_anyone(): void { + $this->assertTrue( OpenclaWP_Telegram::is_allowed( 12345, '*' ) ); + } + + public function test_allowlist_matches_exact_chat_id(): void { + $this->assertTrue( OpenclaWP_Telegram::is_allowed( 12345, '12345' ) ); + } + + public function test_allowlist_matches_within_csv(): void { + $this->assertTrue( OpenclaWP_Telegram::is_allowed( 999, '1,2,999, 7' ) ); + } + + public function test_allowlist_rejects_unlisted_chat(): void { + $this->assertFalse( OpenclaWP_Telegram::is_allowed( 42, '1,2,3' ) ); + } + + public function test_allowlist_does_not_match_prefix(): void { + // `123` must not match `1234` — guard against substring confusion. + $this->assertFalse( OpenclaWP_Telegram::is_allowed( 1234, '123' ) ); + } + + /* ------------------------- message extraction ------------------------ */ + + public function test_extracts_text_message(): void { + $payload = array( + 'update_id' => 1, + 'message' => array( + 'message_id' => 100, + 'from' => array( 'id' => 5001 ), + 'chat' => array( + 'id' => 5001, + 'type' => 'private', + ), + 'text' => 'hola', + ), + ); + $message = OpenclaWP_Telegram::extract_message( $payload ); + $this->assertIsArray( $message ); + $this->assertSame( 5001, $message['chat_id'] ); + $this->assertSame( 100, $message['message_id'] ); + $this->assertSame( 'hola', $message['text'] ); + } + + public function test_extract_message_returns_null_for_non_text(): void { + // Photo / voice / sticker etc. must ack 200 (caller drops to + // "unsupported"), never crash. + $payload = array( + 'message' => array( + 'message_id' => 1, + 'chat' => array( 'id' => 1 ), + 'photo' => array( array( 'file_id' => 'abc' ) ), + ), + ); + $this->assertNull( OpenclaWP_Telegram::extract_message( $payload ) ); + } + + public function test_extract_message_returns_null_when_no_message_field(): void { + // Channel posts, edited messages, callback queries — anything that + // isn't a fresh `message` update is skipped in v1. + $this->assertNull( OpenclaWP_Telegram::extract_message( array( 'update_id' => 1 ) ) ); + } + + /* --------------------------- log redaction --------------------------- */ + + public function test_redact_token_strips_bot_token_from_text(): void { + $token = '987654:SECRETTOKEN'; + $text = 'POST https://api.telegram.org/bot' . $token . '/sendMessage failed'; + $this->assertStringNotContainsString( $token, OpenclaWP_Telegram::redact_token( $text, $token ) ); + } +}