mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: add interactive database backend selection during onboarding Previously the onboarding wizard silently defaulted to PostgreSQL because libsql wasn't in the default feature set. Now both backends ship by default and the wizard presents a selection prompt when both are available. DATABASE_BACKEND env var still bypasses the prompt for headless/CI use. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: resolve libSQL onboarding crash, keychain double-prompt, and setup audit findings Three bugs fixed: 1. libSQL onboarding crash ("Missing required setting 'database_url'"): DatabaseConfig::resolve() only checked DATABASE_BACKEND env var, falling back to Postgres default. Now reads settings.database_backend, plus settings.libsql_path and settings.libsql_url as fallbacks. 2. OS keychain prompts twice during startup: Config::from_env() and Config::from_db() both called get_master_key(). Now caches the key in SECRETS_MASTER_KEY env var after first read so from_db() skips keychain. 3. "Path not found: nearai.session" warning: from_db_map() tried to apply app-specific DB keys (nearai.session_token) to the Settings struct. Now skips keys that don't map to known Settings fields. Also fixed bootstrap migration key mismatch (nearai.session -> nearai.session_token). Setup module audit fixes (14 findings): - Replace unreachable!() with proper error in provider match - Extract setup_api_key_provider() to deduplicate setup_anthropic/setup_openai - Add SAFETY comments to all unsafe std::env::set_var blocks - Fix .unwrap() calls with proper error handling - Remove incorrect #[allow(dead_code)] on used TelegramUpdate::update_id - Log warnings instead of silently discarding HTTP errors in Telegram binding - Guard select_many against empty options, fix mask_api_key for non-ASCII - Update stale doc comment in mod.rs, rename misleading variable - Add 7 new tests (model fetcher fallbacks, channel discovery, secret gen) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review feedback (set_var safety, parse warnings, db_map efficiency) 1. Replace unsafe set_var keychain caching with OnceLock<String> in SecretsConfig::resolve(). Eliminates the env var write from main.rs entirely, using a process-wide OnceLock cache instead. 2. Log tracing::warn when database_backend or llm_backend settings fail to parse, instead of silently falling back to defaults. 3. Remove O(K*S) get() pre-check in from_db_map(). Instead, let set() run and match on "Path not found" errors to skip unknown keys, avoiding full Settings serialization per key. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address critical/high audit findings across WASM sub-crates - Telegram: remove .unwrap() panic on workspace_read (owner_id check) - WhatsApp: use configured api_version instead of hardcoded v18.0 - WhatsApp: log config parse errors before falling back to defaults - Slack: log serialization errors in emit_message and json_response - Google Docs: safe array access for batch update replies - Google Sheets: safe array access for add_sheet replies - Google Calendar: fix doc comment secret name mismatch - Gmail: avoid unnecessary String allocation in UNREAD check Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address second-round PR review feedback - Validate custom model ID is non-empty (loop until valid input) - Warn on unknown DATABASE_BACKEND env var before defaulting to Postgres - Force re-selection when llm_backend contains unknown provider value - Use ok_or_else for proper String error type in google-sheets Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: harden setup module error handling and secret safety - Introduce ChannelSetupError typed enum replacing raw String errors across all channel setup functions (setup_telegram, setup_http, setup_tunnel, setup_wasm_channel, validate_telegram_token) - Add From<ChannelSetupError> for SetupError to simplify call sites - Convert setup_telegram retry from recursion to loop (unbounded stack) - Stop printing HTTP webhook secret plaintext to terminal - Use secret_input() for Turso auth token (was visible input()) - Replace dirs::home_dir().unwrap_or_default() with proper error - Fix UTF-8 panic in model name truncation (byte-index to chars-based) - Log warning in secret_exists() instead of silently swallowing errors - Deduplicate generate_webhook_secret() to delegate to shared helper Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace unreachable!() with error return in setup wizard The provider match in step_inference_provider was guarded by is_known but used unreachable!() as the catch-all. If a new provider is added to the is_known check without a corresponding match arm, this would panic at runtime. Return a typed error instead. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove unsafe set_var, use thread-safe overlay for injected secrets Address PR #92 review comments: - Replace all 5 unsafe `std::env::set_var()` calls with safe alternatives - Add INJECTED_VARS OnceLock<HashMap> overlay in config.rs, checked by optional_env() before falling back to std::env::var() - Cache wizard API key in SetupWizard.llm_api_key field instead of env - Pass explicit key param to fetch_anthropic_models/fetch_openai_models - Persist env-provided API keys to secrets store during onboarding Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address remaining PR review comments (clippy, TODO, secrets backend ordering) - Fix empty line after doc comment (clippy: empty_line_after_doc_comments) - Collapse nested if in optional_env overlay check (clippy: collapsible_if) - Remove dangling TODO(#XX) placeholder issue ref in channels.rs - Fix init_secrets_context to respect selected database_backend when both postgres and libsql features are compiled, preventing wrong-backend secrets storage when DATABASE_URL is set but libsql was chosen Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address latest PR review comments (SecretString, empty env, docs, embeddings) - Change wizard llm_api_key from String to SecretString to prevent accidental logging of API keys - Fix inject_llm_keys_from_secrets skipping when env var is set but empty, matching optional_env's treatment of empty as unset - Fix inverted doc comment on INJECTED_VARS (env checked first, overlay is the fallback, not the other way around) - Update stale "env vars" comments in main.rs to reflect overlay pattern - Fix step_embeddings not seeing cached OpenAI key from wizard session Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: OAuth callback listener binds IPv4 first to match redirect URLs The listener was binding to [::1] (IPv6) first, but NEAR AI and other OAuth flows redirect to http://127.0.0.1:9876/... (IPv4 explicit). On macOS and most systems, [::1] and 127.0.0.1 are separate addresses, so the browser's connection to 127.0.0.1 was refused when the listener was on [::1]. Reversed the bind order: try 127.0.0.1 first, fall back to [::1] if IPv4 is unavailable. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: cache keychain key eagerly to avoid redundant macOS password dialogs Replace has_master_key() with get_master_key() in step_security() and immediately build SecretsCrypto from the result. This eliminates redundant keychain accesses later in init_secrets_context(), each of which triggers macOS system dialogs (keychain unlock + app authorization). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: persist DATABASE_BACKEND to ~/.ironclaw/.env for libSQL startup The wizard saved database_backend only to the database, but Config::from_env() needs it BEFORE connecting to any database (to decide which backend to use). Without it, the backend defaults to Postgres and then fails with "Missing required setting database_url". Now save all database bootstrap vars (DATABASE_BACKEND, DATABASE_URL, LIBSQL_PATH, LIBSQL_URL) to ~/.ironclaw/.env via save_bootstrap_env(). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: status command shows libSQL backend and skips keychain probe The status command only checked DATABASE_URL (postgres), showing "not configured" for libSQL users. Now detects the DATABASE_BACKEND env var and reports libSQL path and Turso sync status. Also remove the keychain probe from status. get_generic_password() triggers macOS unlock+authorization dialogs which is terrible UX for a read-only diagnostic command. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix rustfmt formatting in bootstrap test Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1241 lines
42 KiB
Rust
1241 lines
42 KiB
Rust
// Telegram API types have fields reserved for future use (entities, reply threading, etc.)
|
|
#![allow(dead_code)]
|
|
|
|
//! Telegram Bot API channel for IronClaw.
|
|
//!
|
|
//! This WASM component implements the channel interface for handling Telegram
|
|
//! webhooks and sending messages back via the Bot API.
|
|
//!
|
|
//! # Features
|
|
//!
|
|
//! - Webhook-based message receiving
|
|
//! - Private chat (DM) support
|
|
//! - Group chat support with @mention triggering
|
|
//! - Reply threading support
|
|
//! - User name extraction
|
|
//!
|
|
//! # Security
|
|
//!
|
|
//! - Bot token is injected by host during HTTP requests
|
|
//! - WASM never sees raw credentials
|
|
//! - Optional webhook secret validation by host
|
|
|
|
// Generate bindings from the WIT file
|
|
wit_bindgen::generate!({
|
|
world: "sandboxed-channel",
|
|
path: "../../wit/channel.wit",
|
|
});
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// Re-export generated types
|
|
use exports::near::agent::channel::{
|
|
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
|
OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate,
|
|
};
|
|
use near::agent::channel_host::{self, EmittedMessage};
|
|
|
|
// ============================================================================
|
|
// Telegram API Types
|
|
// ============================================================================
|
|
|
|
/// Telegram Update object (webhook payload).
|
|
/// https://core.telegram.org/bots/api#update
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramUpdate {
|
|
/// Unique update identifier.
|
|
update_id: i64,
|
|
|
|
/// New incoming message.
|
|
message: Option<TelegramMessage>,
|
|
|
|
/// Edited message.
|
|
edited_message: Option<TelegramMessage>,
|
|
|
|
/// Channel post (we ignore these for now).
|
|
channel_post: Option<TelegramMessage>,
|
|
}
|
|
|
|
/// Telegram Message object.
|
|
/// https://core.telegram.org/bots/api#message
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramMessage {
|
|
/// Unique message identifier.
|
|
message_id: i64,
|
|
|
|
/// Sender (empty for channel posts).
|
|
from: Option<TelegramUser>,
|
|
|
|
/// Chat the message belongs to.
|
|
chat: TelegramChat,
|
|
|
|
/// Message text.
|
|
text: Option<String>,
|
|
|
|
/// Caption for media (photo, video, document, etc.).
|
|
#[serde(default)]
|
|
caption: Option<String>,
|
|
|
|
/// Original message if this is a reply.
|
|
reply_to_message: Option<Box<TelegramMessage>>,
|
|
|
|
/// Bot command entities (for /commands).
|
|
entities: Option<Vec<MessageEntity>>,
|
|
}
|
|
|
|
/// Telegram User object.
|
|
/// https://core.telegram.org/bots/api#user
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramUser {
|
|
/// Unique user identifier.
|
|
id: i64,
|
|
|
|
/// True if this is a bot.
|
|
is_bot: bool,
|
|
|
|
/// User's first name.
|
|
first_name: String,
|
|
|
|
/// User's last name.
|
|
last_name: Option<String>,
|
|
|
|
/// Username (without @).
|
|
username: Option<String>,
|
|
}
|
|
|
|
/// Telegram Chat object.
|
|
/// https://core.telegram.org/bots/api#chat
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramChat {
|
|
/// Unique chat identifier.
|
|
id: i64,
|
|
|
|
/// Type of chat: private, group, supergroup, or channel.
|
|
#[serde(rename = "type")]
|
|
chat_type: String,
|
|
|
|
/// Title for groups/channels.
|
|
title: Option<String>,
|
|
|
|
/// Username for private chats.
|
|
username: Option<String>,
|
|
}
|
|
|
|
/// Message entity (for parsing @mentions, commands, etc.).
|
|
/// https://core.telegram.org/bots/api#messageentity
|
|
#[derive(Debug, Deserialize)]
|
|
struct MessageEntity {
|
|
/// Type: mention, bot_command, etc.
|
|
#[serde(rename = "type")]
|
|
entity_type: String,
|
|
|
|
/// Offset in UTF-16 code units.
|
|
offset: i64,
|
|
|
|
/// Length in UTF-16 code units.
|
|
length: i64,
|
|
|
|
/// For "mention" type, the mentioned user.
|
|
user: Option<TelegramUser>,
|
|
}
|
|
|
|
/// Telegram API response wrapper.
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramApiResponse<T> {
|
|
/// True if the request was successful.
|
|
ok: bool,
|
|
|
|
/// Error description if not ok.
|
|
description: Option<String>,
|
|
|
|
/// Result on success.
|
|
result: Option<T>,
|
|
}
|
|
|
|
/// Response from sendMessage.
|
|
#[derive(Debug, Deserialize)]
|
|
struct SentMessage {
|
|
message_id: i64,
|
|
}
|
|
|
|
/// Workspace path for storing polling state.
|
|
const POLLING_STATE_PATH: &str = "state/last_update_id";
|
|
|
|
/// Workspace path for persisting owner_id across WASM callbacks.
|
|
const OWNER_ID_PATH: &str = "state/owner_id";
|
|
|
|
/// Workspace path for persisting dm_policy across WASM callbacks.
|
|
const DM_POLICY_PATH: &str = "state/dm_policy";
|
|
|
|
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
|
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
|
|
|
/// Channel name for pairing store (used by pairing host APIs).
|
|
const CHANNEL_NAME: &str = "telegram";
|
|
|
|
/// Workspace path for persisting bot_username for mention detection in groups.
|
|
const BOT_USERNAME_PATH: &str = "state/bot_username";
|
|
|
|
/// Workspace path for persisting respond_to_all_group_messages flag.
|
|
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
|
|
|
|
// ============================================================================
|
|
// Channel Metadata
|
|
// ============================================================================
|
|
|
|
/// Metadata stored with emitted messages for response routing.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct TelegramMessageMetadata {
|
|
/// Chat ID where the message was received.
|
|
chat_id: i64,
|
|
|
|
/// Original message ID (for reply_to_message_id).
|
|
message_id: i64,
|
|
|
|
/// User ID who sent the message.
|
|
user_id: i64,
|
|
|
|
/// Whether this is a private (DM) chat.
|
|
is_private: bool,
|
|
}
|
|
|
|
/// Channel configuration injected by host.
|
|
///
|
|
/// The host injects runtime values like tunnel_url and webhook_secret.
|
|
/// The channel doesn't need to know about polling vs webhook mode - it just
|
|
/// checks if tunnel_url is set to determine behavior.
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramConfig {
|
|
/// Bot username (without @) for mention detection in groups.
|
|
#[serde(default)]
|
|
bot_username: Option<String>,
|
|
|
|
/// Telegram user ID of the bot owner. When set, only messages from this
|
|
/// user are processed. All others are silently dropped.
|
|
#[serde(default)]
|
|
owner_id: Option<i64>,
|
|
|
|
/// DM policy: "pairing" (default), "allowlist", or "open".
|
|
#[serde(default)]
|
|
dm_policy: Option<String>,
|
|
|
|
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
|
|
#[serde(default)]
|
|
allow_from: Option<Vec<String>>,
|
|
|
|
/// Whether to respond to all group messages (not just mentions).
|
|
#[serde(default)]
|
|
respond_to_all_group_messages: bool,
|
|
|
|
/// Public tunnel URL for webhook mode (injected by host from global settings).
|
|
/// When set, webhook mode is enabled and polling is disabled.
|
|
#[serde(default)]
|
|
tunnel_url: Option<String>,
|
|
|
|
/// Secret token for webhook validation (injected by host from secrets store).
|
|
/// Telegram will include this in the X-Telegram-Bot-Api-Secret-Token header.
|
|
#[serde(default)]
|
|
webhook_secret: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Channel Implementation
|
|
// ============================================================================
|
|
|
|
struct TelegramChannel;
|
|
|
|
impl Guest for TelegramChannel {
|
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("Telegram channel config: {}", config_json),
|
|
);
|
|
|
|
let config: TelegramConfig = serde_json::from_str(&config_json)
|
|
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
|
|
|
channel_host::log(channel_host::LogLevel::Info, "Telegram channel starting");
|
|
|
|
if let Some(ref username) = config.bot_username {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Bot username: @{}", username),
|
|
);
|
|
}
|
|
|
|
// Persist owner_id so subsequent callbacks (on_http_request, on_poll) can read it
|
|
if let Some(owner_id) = config.owner_id {
|
|
if let Err(e) = channel_host::workspace_write(OWNER_ID_PATH, &owner_id.to_string()) {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to persist owner_id: {}", e),
|
|
);
|
|
}
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Owner restriction enabled: user {}", owner_id),
|
|
);
|
|
} else {
|
|
// Clear any stale owner_id from a previous config
|
|
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
"No owner_id configured, bot is open to all users",
|
|
);
|
|
}
|
|
|
|
// Persist dm_policy and allow_from for DM pairing in handle_message
|
|
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
|
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
|
|
|
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
|
.unwrap_or_else(|_| "[]".to_string());
|
|
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
|
|
|
// Persist bot_username and respond_to_all_group_messages for group handling
|
|
let _ = channel_host::workspace_write(
|
|
BOT_USERNAME_PATH,
|
|
&config.bot_username.unwrap_or_default(),
|
|
);
|
|
let _ = channel_host::workspace_write(
|
|
RESPOND_TO_ALL_GROUP_PATH,
|
|
&config.respond_to_all_group_messages.to_string(),
|
|
);
|
|
|
|
// Mode is determined by whether the host injected a tunnel_url
|
|
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
|
let webhook_mode = config.tunnel_url.is_some();
|
|
|
|
if webhook_mode {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Webhook mode enabled (tunnel configured)",
|
|
);
|
|
|
|
// Register webhook with Telegram API
|
|
if let Some(ref tunnel_url) = config.tunnel_url {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
|
|
);
|
|
|
|
if let Err(e) = register_webhook(tunnel_url, config.webhook_secret.as_deref()) {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to register webhook: {}", e),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Polling mode enabled (no tunnel configured)",
|
|
);
|
|
|
|
// Delete any existing webhook before polling
|
|
// Telegram doesn't allow getUpdates while a webhook is active
|
|
if let Err(e) = delete_webhook() {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
&format!("Failed to delete webhook (may not exist): {}", e),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Configure polling only if not in webhook mode
|
|
let poll = if !webhook_mode {
|
|
Some(PollConfig {
|
|
interval_ms: 30000, // 30 seconds minimum
|
|
enabled: true,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Webhook secret validation is handled by the host
|
|
let require_secret = config.webhook_secret.is_some();
|
|
|
|
Ok(ChannelConfig {
|
|
display_name: "Telegram".to_string(),
|
|
http_endpoints: vec![HttpEndpointConfig {
|
|
path: "/webhook/telegram".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret,
|
|
}],
|
|
poll,
|
|
})
|
|
}
|
|
|
|
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
// Check if webhook secret validation passed (if required)
|
|
// The host validates X-Telegram-Bot-Api-Secret-Token header and sets secret_validated
|
|
// If require_secret was true in config but validation failed, secret_validated will be false
|
|
if !req.secret_validated {
|
|
// This means require_secret was set but the secret didn't match
|
|
// We still check the field even though the host should have already rejected invalid requests
|
|
// This is defense in depth
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
"Webhook request with invalid or missing secret token",
|
|
);
|
|
// Return 401 but Telegram will keep retrying, so this is just for logging
|
|
// In practice, the host should reject these before they reach us
|
|
}
|
|
|
|
// Parse the request body as UTF-8
|
|
let body_str = match std::str::from_utf8(&req.body) {
|
|
Ok(s) => s,
|
|
Err(_) => {
|
|
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
|
}
|
|
};
|
|
|
|
// Parse as Telegram Update
|
|
let update: TelegramUpdate = match serde_json::from_str(body_str) {
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to parse Telegram update: {}", e),
|
|
);
|
|
// Still return 200 to prevent Telegram from retrying
|
|
return json_response(200, serde_json::json!({"ok": true}));
|
|
}
|
|
};
|
|
|
|
// Handle the update
|
|
handle_update(update);
|
|
|
|
// Always respond 200 quickly (Telegram expects fast responses)
|
|
json_response(200, serde_json::json!({"ok": true}))
|
|
}
|
|
|
|
fn on_poll() {
|
|
// Read last offset from workspace storage
|
|
let offset = match channel_host::workspace_read(POLLING_STATE_PATH) {
|
|
Some(s) => s.parse::<i64>().unwrap_or(0),
|
|
None => 0,
|
|
};
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("Polling getUpdates with offset {}", offset),
|
|
);
|
|
|
|
// Build getUpdates URL with parameters
|
|
// - offset: Identifier of the first update to be returned
|
|
// - timeout: Long polling timeout in seconds (Telegram recommends 30+)
|
|
// - allowed_updates: Only get message updates
|
|
let url = format!(
|
|
"https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getUpdates?offset={}&timeout=30&allowed_updates=[\"message\",\"edited_message\"]",
|
|
offset
|
|
);
|
|
|
|
let headers = serde_json::json!({});
|
|
|
|
// 35s HTTP timeout outlives Telegram's 30s server-side long-poll
|
|
let result =
|
|
channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000));
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("getUpdates returned {}: {}", response.status, body_str),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Parse response
|
|
let api_response: Result<TelegramApiResponse<Vec<TelegramUpdate>>, _> =
|
|
serde_json::from_slice(&response.body);
|
|
|
|
match api_response {
|
|
Ok(resp) if resp.ok => {
|
|
if let Some(updates) = resp.result {
|
|
let mut new_offset = offset;
|
|
|
|
for update in updates {
|
|
// Track highest update_id for next poll
|
|
if update.update_id >= new_offset {
|
|
new_offset = update.update_id + 1;
|
|
}
|
|
|
|
// Process the update (emits messages)
|
|
handle_update(update);
|
|
}
|
|
|
|
// Save new offset if it changed
|
|
if new_offset != offset {
|
|
if let Err(e) = channel_host::workspace_write(
|
|
POLLING_STATE_PATH,
|
|
&new_offset.to_string(),
|
|
) {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to save polling offset: {}", e),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(resp) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!(
|
|
"Telegram API error: {}",
|
|
resp.description.unwrap_or_else(|| "unknown".to_string())
|
|
),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to parse getUpdates response: {}", e),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("getUpdates request failed: {}", e),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
|
|
|
// Try sending with Markdown first; fall back to plain text if Telegram
|
|
// can't parse the entities (e.g. model leaked <tool_call> with underscores).
|
|
let result = send_message(
|
|
metadata.chat_id,
|
|
&response.content,
|
|
metadata.message_id,
|
|
Some("Markdown"),
|
|
);
|
|
|
|
match result {
|
|
Ok(msg_id) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Sent message to chat {}: message_id={}",
|
|
metadata.chat_id, msg_id
|
|
),
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(SendError::ParseEntities(detail)) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
&format!("Markdown parse failed ({}), retrying as plain text", detail),
|
|
);
|
|
let msg_id = send_message(
|
|
metadata.chat_id,
|
|
&response.content,
|
|
metadata.message_id,
|
|
None,
|
|
)
|
|
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Sent plain-text message to chat {}: message_id={}",
|
|
metadata.chat_id, msg_id
|
|
),
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn on_status(update: StatusUpdate) {
|
|
// Only send typing indicator for Thinking status
|
|
if !matches!(update.status, StatusType::Thinking) {
|
|
return;
|
|
}
|
|
|
|
// Parse chat_id from metadata
|
|
let metadata: TelegramMessageMetadata = match serde_json::from_str(&update.metadata_json) {
|
|
Ok(m) => m,
|
|
Err(_) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
"on_status: no valid Telegram metadata, skipping typing indicator",
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// POST /sendChatAction with action "typing"
|
|
let payload = serde_json::json!({
|
|
"chat_id": metadata.chat_id,
|
|
"action": "typing"
|
|
});
|
|
|
|
let payload_bytes = match serde_json::to_vec(&payload) {
|
|
Ok(b) => b,
|
|
Err(_) => return,
|
|
};
|
|
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json"
|
|
});
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
|
&headers.to_string(),
|
|
Some(&payload_bytes),
|
|
None,
|
|
);
|
|
|
|
if let Err(e) = result {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("sendChatAction failed: {}", e),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn on_shutdown() {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Telegram channel shutting down",
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Send Message Helper
|
|
// ============================================================================
|
|
|
|
/// Errors from send_message, split so callers can match on parse-entity failures.
|
|
enum SendError {
|
|
/// Telegram returned 400 with "can't parse entities" (Markdown issue).
|
|
ParseEntities(String),
|
|
/// Any other failure.
|
|
Other(String),
|
|
}
|
|
|
|
impl std::fmt::Display for SendError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail),
|
|
SendError::Other(msg) => write!(f, "{}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a message via the Telegram Bot API.
|
|
///
|
|
/// Returns the sent message_id on success. When `parse_mode` is set and
|
|
/// Telegram returns a 400 "can't parse entities" error, returns
|
|
/// `SendError::ParseEntities` so the caller can retry without formatting.
|
|
fn send_message(
|
|
chat_id: i64,
|
|
text: &str,
|
|
reply_to_message_id: i64,
|
|
parse_mode: Option<&str>,
|
|
) -> Result<i64, SendError> {
|
|
let mut payload = serde_json::json!({
|
|
"chat_id": chat_id,
|
|
"text": text,
|
|
"reply_to_message_id": reply_to_message_id,
|
|
});
|
|
|
|
if let Some(mode) = parse_mode {
|
|
payload["parse_mode"] = serde_json::Value::String(mode.to_string());
|
|
}
|
|
|
|
let payload_bytes = serde_json::to_vec(&payload)
|
|
.map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?;
|
|
|
|
let headers = serde_json::json!({ "Content-Type": "application/json" });
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
&headers.to_string(),
|
|
Some(&payload_bytes),
|
|
None,
|
|
);
|
|
|
|
match result {
|
|
Ok(http_response) => {
|
|
if http_response.status == 400 {
|
|
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
if body_str.contains("can't parse entities") {
|
|
return Err(SendError::ParseEntities(body_str.to_string()));
|
|
}
|
|
return Err(SendError::Other(format!(
|
|
"Telegram API returned 400: {}",
|
|
body_str
|
|
)));
|
|
}
|
|
|
|
if http_response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
return Err(SendError::Other(format!(
|
|
"Telegram API returned status {}: {}",
|
|
http_response.status, body_str
|
|
)));
|
|
}
|
|
|
|
let api_response: TelegramApiResponse<SentMessage> =
|
|
serde_json::from_slice(&http_response.body)
|
|
.map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?;
|
|
|
|
if !api_response.ok {
|
|
return Err(SendError::Other(format!(
|
|
"Telegram API error: {}",
|
|
api_response
|
|
.description
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
)));
|
|
}
|
|
|
|
Ok(api_response.result.map(|r| r.message_id).unwrap_or(0))
|
|
}
|
|
Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Webhook Management
|
|
// ============================================================================
|
|
|
|
/// Delete any existing webhook with Telegram API.
|
|
///
|
|
/// Called during on_start() when switching to polling mode.
|
|
/// Telegram doesn't allow getUpdates while a webhook is active.
|
|
fn delete_webhook() -> Result<(), String> {
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json"
|
|
});
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
|
&headers.to_string(),
|
|
None,
|
|
None,
|
|
);
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
return Err(format!("HTTP {}: {}", response.status, body_str));
|
|
}
|
|
|
|
let api_response: TelegramApiResponse<bool> = serde_json::from_slice(&response.body)
|
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
|
|
|
if !api_response.ok {
|
|
return Err(format!(
|
|
"Telegram API error: {}",
|
|
api_response
|
|
.description
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
));
|
|
}
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Webhook deleted successfully (switching to polling mode)",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
/// Register webhook URL with Telegram API.
|
|
///
|
|
/// Called during on_start() when tunnel_url is configured.
|
|
fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<(), String> {
|
|
let webhook_url = format!("{}/webhook/telegram", tunnel_url);
|
|
|
|
// Build setWebhook request body
|
|
let mut body = serde_json::json!({
|
|
"url": webhook_url,
|
|
"allowed_updates": ["message", "edited_message"]
|
|
});
|
|
|
|
if let Some(secret) = webhook_secret {
|
|
body["secret_token"] = serde_json::Value::String(secret.to_string());
|
|
}
|
|
|
|
let body_bytes =
|
|
serde_json::to_vec(&body).map_err(|e| format!("Failed to serialize body: {}", e))?;
|
|
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json"
|
|
});
|
|
|
|
// Make HTTP request to Telegram API
|
|
// Note: {TELEGRAM_BOT_TOKEN} is replaced by host with the actual token
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
|
&headers.to_string(),
|
|
Some(&body_bytes),
|
|
None,
|
|
);
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
return Err(format!("HTTP {}: {}", response.status, body_str));
|
|
}
|
|
|
|
// Parse Telegram API response
|
|
let api_response: TelegramApiResponse<serde_json::Value> =
|
|
serde_json::from_slice(&response.body)
|
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
|
|
|
if !api_response.ok {
|
|
return Err(format!(
|
|
"Telegram API error: {}",
|
|
api_response
|
|
.description
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
));
|
|
}
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Webhook registered successfully: {}", webhook_url),
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pairing Reply
|
|
// ============================================================================
|
|
|
|
/// Send a pairing code message to a chat. Used when an unknown user DMs the bot.
|
|
fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
|
let payload = serde_json::json!({
|
|
"chat_id": chat_id,
|
|
"text": format!(
|
|
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
|
|
code
|
|
),
|
|
"parse_mode": "Markdown",
|
|
});
|
|
|
|
let payload_bytes =
|
|
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
|
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json"
|
|
});
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
&headers.to_string(),
|
|
Some(&payload_bytes),
|
|
None,
|
|
);
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
return Err(format!("HTTP {}: {}", response.status, body_str));
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Update Handling
|
|
// ============================================================================
|
|
|
|
/// Process a Telegram update and emit messages if applicable.
|
|
fn handle_update(update: TelegramUpdate) {
|
|
// Handle regular messages
|
|
if let Some(message) = update.message {
|
|
handle_message(message);
|
|
}
|
|
|
|
// Optionally handle edited messages the same way
|
|
if let Some(message) = update.edited_message {
|
|
handle_message(message);
|
|
}
|
|
}
|
|
|
|
/// Process a single message.
|
|
fn handle_message(message: TelegramMessage) {
|
|
// Use text or caption (for media messages)
|
|
let content = message
|
|
.text
|
|
.filter(|t| !t.is_empty())
|
|
.or_else(|| message.caption.filter(|c| !c.is_empty()))
|
|
.unwrap_or_default();
|
|
|
|
if content.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Skip messages without a sender (channel posts)
|
|
let from = match message.from {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
|
|
// Skip bot messages to avoid loops
|
|
if from.is_bot {
|
|
return;
|
|
}
|
|
|
|
let is_private = message.chat.chat_type == "private";
|
|
|
|
// Owner validation: when owner_id is set, only that user can message
|
|
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
|
|
|
if let Some(ref id_str) = owner_id_str {
|
|
if let Ok(owner_id) = id_str.parse::<i64>() {
|
|
if from.id != owner_id {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Dropping message from non-owner user {} (owner: {})",
|
|
from.id, owner_id
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
} else if is_private {
|
|
// No owner_id: apply dm_policy for private chats
|
|
let dm_policy =
|
|
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
|
|
|
if dm_policy != "open" {
|
|
// Build effective allow list: config allow_from + pairing store
|
|
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default();
|
|
|
|
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
|
allowed.extend(store_allowed);
|
|
}
|
|
|
|
let id_str = from.id.to_string();
|
|
let username_opt = from.username.as_deref();
|
|
let is_allowed = allowed.contains(&"*".to_string())
|
|
|| allowed.contains(&id_str)
|
|
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
|
|
|
|
if !is_allowed {
|
|
if dm_policy == "pairing" {
|
|
// Upsert pairing request and send reply
|
|
let meta = serde_json::json!({
|
|
"chat_id": message.chat.id,
|
|
"user_id": from.id,
|
|
"username": username_opt,
|
|
})
|
|
.to_string();
|
|
|
|
match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) {
|
|
Ok(result) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!(
|
|
"Pairing request for user {} (chat {}): code {}",
|
|
from.id, message.chat.id, result.code
|
|
),
|
|
);
|
|
if result.created {
|
|
let _ = send_pairing_reply(message.chat.id, &result.code);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Pairing upsert failed: {}", e),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
|
|
if !is_private {
|
|
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
|
|
.as_deref()
|
|
.unwrap_or("false")
|
|
== "true";
|
|
|
|
if !respond_to_all {
|
|
let has_command = content.starts_with('/');
|
|
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
|
let has_bot_mention = if bot_username.is_empty() {
|
|
content.contains('@')
|
|
} else {
|
|
let mention = format!("@{}", bot_username);
|
|
content.to_lowercase().contains(&mention.to_lowercase())
|
|
};
|
|
|
|
if !has_command && !has_bot_mention {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("Ignoring group message without mention: {}", content),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build user display name
|
|
let user_name = if let Some(ref last) = from.last_name {
|
|
format!("{} {}", from.first_name, last)
|
|
} else {
|
|
from.first_name.clone()
|
|
};
|
|
|
|
// Build metadata for response routing
|
|
let metadata = TelegramMessageMetadata {
|
|
chat_id: message.chat.id,
|
|
message_id: message.message_id,
|
|
user_id: from.id,
|
|
is_private,
|
|
};
|
|
|
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
|
|
|
// Clean the message text (strip bot mentions and commands)
|
|
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
|
let cleaned_text = clean_message_text(
|
|
&content,
|
|
if bot_username.is_empty() {
|
|
None
|
|
} else {
|
|
Some(bot_username.as_str())
|
|
},
|
|
);
|
|
|
|
// For /start with no args, emit placeholder so agent can respond with welcome
|
|
let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') {
|
|
"[User started the bot]".to_string()
|
|
} else if cleaned_text.is_empty() {
|
|
return;
|
|
} else {
|
|
cleaned_text
|
|
};
|
|
|
|
// Emit the message to the agent
|
|
channel_host::emit_message(&EmittedMessage {
|
|
user_id: from.id.to_string(),
|
|
user_name: Some(user_name),
|
|
content: content_to_emit,
|
|
thread_id: None, // Telegram doesn't have threads in the same way
|
|
metadata_json,
|
|
});
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Emitted message from user {} in chat {}",
|
|
from.id, message.chat.id
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Clean message text by removing bot commands and @mentions at the start.
|
|
/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention.
|
|
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
|
|
let mut result = text.trim().to_string();
|
|
|
|
// Remove leading /command
|
|
if result.starts_with('/') {
|
|
if let Some(space_idx) = result.find(' ') {
|
|
result = result[space_idx..].trim_start().to_string();
|
|
} else {
|
|
// Just a command with no text
|
|
return String::new();
|
|
}
|
|
}
|
|
|
|
// Remove leading @mention
|
|
if result.starts_with('@') {
|
|
if let Some(bot) = bot_username {
|
|
let mention = format!("@{}", bot);
|
|
let mention_lower = mention.to_lowercase();
|
|
let result_lower = result.to_lowercase();
|
|
if result_lower.starts_with(&mention_lower) {
|
|
let rest = result[mention.len()..].trim_start();
|
|
if rest.is_empty() {
|
|
return String::new();
|
|
}
|
|
result = rest.to_string();
|
|
} else if let Some(space_idx) = result.find(' ') {
|
|
// Different leading @mention - only strip if it's the bot
|
|
let first_word = &result[..space_idx];
|
|
if first_word.eq_ignore_ascii_case(&mention) {
|
|
result = result[space_idx..].trim_start().to_string();
|
|
}
|
|
}
|
|
} else {
|
|
// No bot_username: strip any leading @mention
|
|
if let Some(space_idx) = result.find(' ') {
|
|
result = result[space_idx..].trim_start().to_string();
|
|
} else {
|
|
return String::new();
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utilities
|
|
// ============================================================================
|
|
|
|
/// Create a JSON HTTP response.
|
|
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
|
let body = serde_json::to_vec(&value).unwrap_or_default();
|
|
let headers = serde_json::json!({"Content-Type": "application/json"});
|
|
|
|
OutgoingHttpResponse {
|
|
status,
|
|
headers_json: headers.to_string(),
|
|
body,
|
|
}
|
|
}
|
|
|
|
// Export the component
|
|
export!(TelegramChannel);
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_clean_message_text() {
|
|
// Without bot_username: strips any leading @mention
|
|
assert_eq!(clean_message_text("/start hello", None), "hello");
|
|
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
|
|
assert_eq!(clean_message_text("/start", None), "");
|
|
assert_eq!(clean_message_text("@botname", None), "");
|
|
assert_eq!(clean_message_text("just text", None), "just text");
|
|
assert_eq!(clean_message_text(" spaced ", None), "spaced");
|
|
|
|
// With bot_username: only strips @MyBot, not @alice
|
|
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
|
|
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
|
|
assert_eq!(
|
|
clean_message_text("@alice hello", Some("MyBot")),
|
|
"@alice hello"
|
|
);
|
|
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_with_owner_id() {
|
|
let json = r#"{"owner_id": 123456789}"#;
|
|
let config: TelegramConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.owner_id, Some(123456789));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_without_owner_id() {
|
|
let json = r#"{}"#;
|
|
let config: TelegramConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.owner_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_with_null_owner_id() {
|
|
let json = r#"{"owner_id": null}"#;
|
|
let config: TelegramConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.owner_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_full() {
|
|
let json = r#"{
|
|
"bot_username": "my_bot",
|
|
"owner_id": 42,
|
|
"respond_to_all_group_messages": true
|
|
}"#;
|
|
let config: TelegramConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.bot_username, Some("my_bot".to_string()));
|
|
assert_eq!(config.owner_id, Some(42));
|
|
assert!(config.respond_to_all_group_messages);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_update() {
|
|
let json = r#"{
|
|
"update_id": 123,
|
|
"message": {
|
|
"message_id": 456,
|
|
"from": {
|
|
"id": 789,
|
|
"is_bot": false,
|
|
"first_name": "John",
|
|
"last_name": "Doe"
|
|
},
|
|
"chat": {
|
|
"id": 789,
|
|
"type": "private"
|
|
},
|
|
"text": "Hello bot"
|
|
}
|
|
}"#;
|
|
|
|
let update: TelegramUpdate = serde_json::from_str(json).unwrap();
|
|
assert_eq!(update.update_id, 123);
|
|
|
|
let message = update.message.unwrap();
|
|
assert_eq!(message.message_id, 456);
|
|
assert_eq!(message.text.unwrap(), "Hello bot");
|
|
|
|
let from = message.from.unwrap();
|
|
assert_eq!(from.id, 789);
|
|
assert_eq!(from.first_name, "John");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_message_with_caption() {
|
|
let json = r#"{
|
|
"message_id": 1,
|
|
"from": {"id": 1, "is_bot": false, "first_name": "A"},
|
|
"chat": {"id": 1, "type": "private"},
|
|
"caption": "What's in this image?"
|
|
}"#;
|
|
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
|
|
assert_eq!(msg.text, None);
|
|
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
|
|
}
|
|
}
|