mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* Orchestrating jobs and running them in sandboxes * Fix heartbeat: dynamic max_tokens, empty content guard, notification fallback - Query /v1/models API for context_length and set max_tokens to half (floor 4096) instead of hardcoded 1024; reasoning models like GLM-4.7 need much larger budgets - Guard against empty LLM content (reasoning models can burn all tokens on chain-of-thought and return content: null) - Simplify notification routing: try configured channel first, fall back to broadcast_all so heartbeat alerts always reach someone - Add ModelMetadata struct and model_metadata() to LlmProvider trait - Refactor NearAiChatProvider::list_models into shared fetch_models() - Add standalone test_heartbeat example for isolated debugging Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add job detail view with drill-down from jobs list Click a job row to see full details across four sub-tabs: Overview (metadata grid, description, state transitions timeline), Actions (expandable tool call cards with input/output JSON), Thinking (conversation messages styled by role), and Files (embedded workspace tree browser). Co-Authored-By: Claude Opus 4.6 <[email protected]> * Strip model-internal XML tags from LLM responses, fix Telegram parse_mode 400 Some models (GLM-4.7, etc.) emit <tool_call>tool_list</tool_call> in the content field instead of using the OpenAI tool_calls array. This XML leaks through to channels as text, and Telegram's Markdown parser chokes on the underscores, returning 400 "can't parse entities". Two fixes: - Generalize clean_response() to strip <tool_call>, <function_call>, <tool_calls>, and pipe-delimited variants (<|tool_call|>) alongside the existing <thinking> tag stripping - Add Telegram send_message helper with parse_mode fallback: try Markdown first, retry as plain text on "can't parse entities" 400 errors Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add SystemCommand submission type for thread-state-independent commands System commands (/help, /model, /version, /tools, /ping, /debug) now bypass thread-state checks and safety validation via a dedicated Submission::SystemCommand variant. Previously these flowed through process_user_input() which blocked them during Processing/AwaitingApproval /Completed states. - Add /model [name] for runtime model switching with provider validation - Add active_model_name()/set_model() to LlmProvider trait with RwLock hot-swap in both NEAR AI providers - Rewrite /help with aligned columns grouped by category - Expand REPL tab-completion from 10 to 23 slash commands - Remove REPL-local /help interception (now handled by agent) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add per-tool execution timeouts, auto-create sandbox project dirs, serve built files The sandbox e2e pipeline (agent -> container -> built website -> browsable URL) was broken by three gaps: hardcoded 60s timeouts killed sandbox jobs that need minutes, no auto-created project directory meant container output vanished, and no HTTP route to browse the built files. - Add `execution_timeout()` to the `Tool` trait (default 60s), replace all four hardcoded `Duration::from_secs(60)` call sites (agent_loop, worker, scheduler, worker/runtime) with the per-tool value - Override to 660s in `RunInSandboxTool` (10 min polling + 60s buffer) - Auto-create `~/.ironclaw/projects/{uuid}/` when no `project_dir` is specified, so every sandbox job gets a persistent bind mount - Include `project_dir` and `browse_url` in sandbox tool output JSON - Add `/projects/{id}` and `/projects/{id}/{path}` static file serving routes to the web gateway with path traversal protection and MIME type detection - Add `mime_guess` dependency for content-type detection Co-Authored-By: Claude Opus 4.6 <[email protected]> * Apply cargo fmt to wizard.rs after merge Co-Authored-By: Claude Opus 4.6 <[email protected]> * Persist sandbox jobs in DB, fix web UI, unify job model Sandbox container jobs were invisible to the web UI because they lived only in ContainerJobManager's in-memory HashMap while the API queried ContextManager. This persists them to the agent_jobs table and fixes all six front-end bugs (empty job list, broken back button, empty actions/thinking tabs, wrong files tab, stuck status, no persistence). Key changes: - V4 migration adds project_dir and user_id columns to agent_jobs - Embedded migrations via refinery (no external CLI needed) - SandboxJobRecord CRUD in Store with fire-and-forget DB writes - Unified job_id: sandbox tool generates UUID, passes to ContainerJobManager - Web API queries DB for sandbox jobs, merges with ContextManager direct jobs - New endpoints: restart, project file list/read with path traversal protection - Front-end: rebuild DOM on back navigation, sandbox-aware tabs, job cards in chat stream, source badges, restart button for failed/interrupted jobs - Gateway defaults to enabled, prints Web UI URL on startup - Stale jobs marked "interrupted" on restart for visibility and restartability Co-Authored-By: Claude Opus 4.6 <[email protected]> * Secure in-chat auth: tokens never touch the LLM or chat history Remove the token parameter from tool_auth so the LLM cannot pass raw API keys. Add dedicated REST (POST /api/chat/auth-token) and WebSocket (auth_token) endpoints that route tokens directly to ext_mgr.auth(), completely bypassing the message pipeline, turns, history, and compaction. Web UI shows an auth card (password input + OAuth button) when the agent enters auth mode, submitted via the dedicated endpoint. CLI auth mode interception is unchanged (already secure). New StatusUpdate::AuthRequired/AuthCompleted variants propagate through all channels (SSE, WebSocket, REPL, WASM). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add Claude Code mode for sandbox jobs Run Claude Code CLI inside Docker containers as an alternative to the standard worker mode. The bridge spawns `claude -p` with stream-json output, posts events to the orchestrator, and supports follow-up prompts via `--resume`. Key additions: - `claude-bridge` CLI subcommand and ClaudeBridgeRuntime - JobMode enum (Worker vs ClaudeCode) with per-mode container config - Orchestrator endpoints for Claude events and prompt polling - SSE event variants for real-time Claude Code streaming to frontend - Claude Code sub-tab in web UI with terminal-style output and input bar - Database migration for job_mode column and claude_code_events table - ClaudeCodeConfig with env var support (CLAUDE_CODE_ENABLED, etc.) - Mode parameter on run_in_sandbox tool schema Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Skip create_job tool when sandbox is enabled to prevent duplicate jobs When sandbox mode is on, the LLM would call create_job (creating a pending "direct" entry) then run_in_sandbox (creating a second "sandbox" entry), producing two jobs in the list for a single user request. Now register_job_tools() skips create_job when sandbox is enabled since run_in_sandbox already creates tracked jobs. Also improved the run_in_sandbox description to guide the LLM to use it directly and to mention wait=false for async execution. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Web gateway UI quality-of-life improvements Phase 1: Send button disabled state to prevent double-sends, copy button on code blocks, confirm() guards on destructive actions, SSE-driven job list auto-refresh, log filters re-applied on tab switch, jobEvents memory leak fix (cap at 500, cleanup after 60s). Phase 2: Toast notification system replacing chat-based system messages, memory search highlighting with centered snippets, keyboard shortcuts (Ctrl+1-5 tabs, Ctrl+K focus, Ctrl+N new thread, Escape close/blur), activity tab toolbar with event type filter and auto-scroll toggle. Phase 3: Thread sidebar with load/switch/create, thread_id passed with messages, collapsible to hamburger. Memory inline editing with textarea, Save/Cancel, POST to /api/memory/write. Phase 4: Gateway status popover on hover (polls every 30s), extension install form (name/URL/kind), markdown rendering in memory viewer for .md files, mobile responsive layout at 768px breakpoint. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add routines system, remove non-sandbox job mode from web UI Routines: scheduled & reactive job system with cron and event triggers, lightweight (single LLM call) and full-job execution modes, guardrails (cooldown, max concurrent, dedup), and LLM-facing tools for CRUD. Web UI: remove ContextManager-backed "direct" job mode entirely. Jobs are now exclusively sandbox-backed (DB + container). Simplify job detail response, drop dead types (ActionInfo, MessageInfo, MessageToolCallInfo), fix Browse Files CSS loading (trailing-slash redirect), fix Activity tab event rendering. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Re-enable chat input on agent completion, auto-auth on tool_activate, recover tool calls from content XML Three fixes: 1. Chat input stays disabled after agent finishes: the "Done" status SSE event now calls enableChatInput() as a safety net when the response event is empty or lost. Same for auth_completed and cancelAuth(). 2. tool_activate never triggers auth: when activation fails due to missing authentication, it now auto-initiates the auth flow (same pattern as the web API handler). detect_auth_awaiting() also matches tool_activate results now. 3. Models like GLM-4.7 emit tool calls as XML tags in content (<tool_call>tool_list</tool_call>) instead of using the structured tool_calls array. recover_tool_calls_from_content() extracts and validates these before falling back to plain text. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add routines web UI tab, update docs for sandbox-jobs branch Add full routines management to the web gateway (list, detail, trigger, toggle, delete) with 7 new API endpoints, response types, and frontend (HTML, JS, CSS). Update FEATURE_PARITY.md (~23 rows), CLAUDE.md (new subsystems, config, TODOs), and README.md (architecture diagram, features, components, fix onboard command). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Bind Telegram bot to owner account during setup Without owner binding, anyone who discovers the bot can send it messages. The setup wizard now prompts the user to message their bot, captures their Telegram user ID via getUpdates, and persists it as telegram_owner_id in settings. On startup, the owner_id is injected into the WASM channel config so the existing owner restriction logic drops messages from non-owners. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Move settings from disk to PostgreSQL database Settings previously lived in three JSON files on disk (settings.json, mcp-servers.json, session.json). This made them inaccessible from the web UI and caused redundant disk reads (Settings::load() called 8+ times during startup). Now all settings live in a `settings` table (user_id + key -> JSONB) with only 4 bootstrap fields remaining on disk (database_url, pool size, secrets key source, onboard_completed) since they're needed before the DB connection exists. - Add V8 migration for settings table - Add BootstrapConfig (thin disk file) and Settings DB round-trip - Add Store CRUD methods for settings (get/set/delete/list/bulk) - Refactor Config to load from DB (env > DB > default cascade) - Add SessionManager DB persistence for session tokens - Add DB-backed MCP server config load/save functions - Add 6 settings web API endpoints (list/get/set/delete/export/import) - Add one-time disk-to-DB migration on first boot - Make CLI config commands async with DB access (disk fallback) Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Seed workspace on boot, fix gateway duplicate logs and URL auto-auth - Add Workspace::seed_if_empty() to create core identity files (README, MEMORY, IDENTITY, SOUL, AGENTS, USER, HEARTBEAT) when missing, called on every boot without overwriting existing user edits - Remove duplicate gateway log lines from web/mod.rs (main.rs has the useful clickable ?token= URL) - Auto-authenticate from ?token= URL parameter in the web UI and strip the token from the address bar after successful auth Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Harden sandbox security (path traversal + orchestrator auth) Two vulnerabilities fixed: 1. project_dir path traversal: The create_job tool let the LLM specify arbitrary host paths for Docker bind mounts. Removed project_dir from the tool schema entirely, and added canonicalization + prefix validation at both resolve_project_dir() and the job_manager bind mount point. 2. Orchestrator API auth bypass: worker_auth_middleware was defined but never applied. Each handler manually called validate_token(), so any new endpoint that forgot would be publicly accessible. Applied the middleware as route_layer on all /worker/ routes, removed manual auth from all 7 handlers. Bind to 127.0.0.1 on macOS/Windows (Linux keeps 0.0.0.0 since containers reach host via docker bridge, not loopback). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Rework gateway chat with pinned assistant, pagination, and NEAR AI response chaining Implements the 4-phase plan for overhauling the web gateway chat: - Phase 1: Pinned "Assistant" thread at top of sidebar, regular threads below - Phase 2: Cursor-based history pagination with infinite scroll - Phase 3: NEAR AI previous_response_id chaining (delta-only messages), with fallback to full history on chain errors, and DB persistence of chain state across restarts - Phase 4: SSE thread isolation (events filtered by thread_id) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Add per-request HTTP timeout to WASM host, redact credentials in errors Three fixes for WASM channel reliability: 1. Per-request timeout: Add optional timeout-ms parameter to http-request in both channel and tool WIT interfaces. Telegram long-poll now specifies 35s (outliving the 30s server-side hold), while regular API calls use the 30s default. Fixes the triple-30s timeout race that caused polling failures. 2. Credential redaction: reqwest::Error includes the full URL (with injected bot tokens) in its Display output. Scrub credential values from error messages before logging or returning to WASM. 3. Webhook route registration: Remove tunnel URL gate so webhook routes are always available when webhook channels exist, not only when TUNNEL_URL is configured. Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: Fix clippy warnings in WASM tools and channels - slack channel: allow dead_code on signing_secret_name (forward compat field) - gmail tool: use div_ceil() instead of manual (n+2)/3 - google-calendar tool: extract CreateEventParams/UpdateEventParams structs to fix too-many-arguments warnings Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix approval flow * fix: Rebuild bundled telegram.wasm with updated WIT interface The bundled WASM binary must match the host's WIT definition. Previous binary was compiled against the old 4-arg http-request; this rebuild includes the new timeout-ms parameter. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Load WASM channels from disk instead of bundling in binary Remove include_bytes! embedding of telegram.wasm. Channels are now loaded from their build output directories (channels-src/<name>/target/) during onboarding, then from ~/.ironclaw/channels/ at runtime. - bundled.rs: locate_channel_artifacts() finds WASM + capabilities from build output; IRONCLAW_CHANNELS_SRC env var overrides the default path - available_channel_names(): only lists channels with build artifacts - bundled_channel_names(): lists all known channels (manifest) - Setup wizard uses available_channel_names() to offer installable channels - Add *.wasm to .gitignore, remove tracked telegram.wasm Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Persist gateway auth token, fix thread hydration race, polish auth screen Three web gateway UX fixes: 1. Token persistence: Store auth token in sessionStorage so refreshing the page doesn't force re-authentication. Hide the auth screen immediately when a saved token exists to prevent flash. 2. Thread hydration: Remove the !msgs.is_empty() bail-out in maybe_hydrate_thread so that even brand-new (empty) assistant threads get hydrated with their correct DB UUID. Previously resolve_thread would mint a fresh UUID, causing messages to land in the wrong conversation and duplicate threads to appear. 3. Auth screen: Redesign as a centered card with brand, tagline, labeled input, and hint text. Also adds 34 new tests covering session/thread lifecycle, thread resolution isolation (user, channel, external ID), hydration edge cases, serialization round-trips, approval flows, and stale mapping recovery. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Use bindgen! for WASM tool wrapper, add dev tool loading Three changes: 1. Rewrite src/tools/wasm/wrapper.rs to use wasmtime::component::bindgen! instead of manual linker.root().func_wrap(). This fixes the "component imports instance 'near:agent/host', but a matching implementation was not found in the linker" error. All 6 host functions (log, now-millis, workspace-read, http-request, secret-exists, tool-invoke) are now properly registered under the near:agent/host namespace. Also adds WASI support, credential injection, and leak detection for HTTP requests made by WASM tools. 2. Add dev tool loading to src/tools/wasm/loader.rs. During startup, the loader now also scans tools-src/*/target/wasm32-wasip2/release/ for build artifacts that are newer than installed copies. This means during development you just rebuild the WASM and restart the host; no manual copy step needed. Set IRONCLAW_TOOLS_SRC to override the source dir. 3. Wire up load_dev_tools() in main.rs alongside the existing load_from_dir() call. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Wire main startup and CLI to use DB-backed settings main.rs now reloads Config from the database after connecting, attaches the store to the session manager for dual-write tokens, and loads MCP servers from DB instead of disk. ExtensionManager and MCP CLI commands use DB when available with disk fallback. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
624 lines
21 KiB
Rust
624 lines
21 KiB
Rust
//! Channel-specific setup flows.
|
|
//!
|
|
//! Each channel (Telegram, HTTP, etc.) has its own setup function that:
|
|
//! 1. Displays setup instructions
|
|
//! 2. Collects configuration (tokens, ports, etc.)
|
|
//! 3. Validates the configuration
|
|
//! 4. Saves secrets to the database
|
|
|
|
use std::sync::Arc;
|
|
|
|
use reqwest::Client;
|
|
use secrecy::{ExposeSecret, SecretString};
|
|
use serde::Deserialize;
|
|
|
|
use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
|
use crate::settings::Settings;
|
|
use crate::setup::prompts::{
|
|
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
|
};
|
|
|
|
/// Context for saving secrets during setup.
|
|
pub struct SecretsContext {
|
|
store: PostgresSecretsStore,
|
|
user_id: String,
|
|
}
|
|
|
|
impl SecretsContext {
|
|
/// Create a new secrets context.
|
|
pub fn new(pool: deadpool_postgres::Pool, crypto: Arc<SecretsCrypto>, user_id: &str) -> Self {
|
|
Self {
|
|
store: PostgresSecretsStore::new(pool, crypto),
|
|
user_id: user_id.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Save a secret to the database.
|
|
pub async fn save_secret(&self, name: &str, value: &SecretString) -> Result<(), String> {
|
|
let params = CreateSecretParams::new(name, value.expose_secret());
|
|
|
|
self.store
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| format!("Failed to save secret: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if a secret exists.
|
|
pub async fn secret_exists(&self, name: &str) -> bool {
|
|
self.store
|
|
.exists(&self.user_id, name)
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Read a secret from the database (decrypted).
|
|
pub async fn get_secret(&self, name: &str) -> Result<SecretString, String> {
|
|
let decrypted = self
|
|
.store
|
|
.get_decrypted(&self.user_id, name)
|
|
.await
|
|
.map_err(|e| format!("Failed to read secret: {}", e))?;
|
|
Ok(SecretString::from(decrypted.expose().to_string()))
|
|
}
|
|
}
|
|
|
|
/// Result of Telegram setup.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TelegramSetupResult {
|
|
pub enabled: bool,
|
|
pub bot_username: Option<String>,
|
|
pub webhook_secret: Option<String>,
|
|
pub owner_id: Option<i64>,
|
|
}
|
|
|
|
/// Telegram Bot API response for getMe.
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramGetMeResponse {
|
|
ok: bool,
|
|
result: Option<TelegramUser>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramUser {
|
|
username: Option<String>,
|
|
#[allow(dead_code)]
|
|
first_name: String,
|
|
}
|
|
|
|
/// Telegram Bot API response for getUpdates.
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramGetUpdatesResponse {
|
|
ok: bool,
|
|
result: Vec<TelegramUpdate>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramUpdate {
|
|
#[allow(dead_code)]
|
|
update_id: i64,
|
|
message: Option<TelegramUpdateMessage>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramUpdateMessage {
|
|
from: Option<TelegramUpdateUser>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TelegramUpdateUser {
|
|
id: i64,
|
|
first_name: String,
|
|
username: Option<String>,
|
|
}
|
|
|
|
/// Set up Telegram bot channel.
|
|
///
|
|
/// Guides the user through:
|
|
/// 1. Creating a bot with @BotFather
|
|
/// 2. Entering the bot token
|
|
/// 3. Validating the token
|
|
/// 4. Saving the token to the database
|
|
pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupResult, String> {
|
|
println!("Telegram Setup:");
|
|
println!();
|
|
print_info("To create a Telegram bot:");
|
|
print_info("1. Open Telegram and message @BotFather");
|
|
print_info("2. Send /newbot and follow the prompts");
|
|
print_info("3. Copy the bot token (looks like 123456:ABC-DEF...)");
|
|
println!();
|
|
|
|
// Check if token already exists
|
|
if secrets.secret_exists("telegram_bot_token").await {
|
|
print_info("Existing Telegram token found in database.");
|
|
if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? {
|
|
// Still offer to configure webhook secret and owner binding
|
|
let webhook_secret = setup_telegram_webhook_secret(secrets).await?;
|
|
let owner_id = bind_telegram_owner_flow(secrets).await?;
|
|
return Ok(TelegramSetupResult {
|
|
enabled: true,
|
|
bot_username: None,
|
|
webhook_secret,
|
|
owner_id,
|
|
});
|
|
}
|
|
}
|
|
|
|
let token = secret_input("Bot token (from @BotFather)").map_err(|e| e.to_string())?;
|
|
|
|
// Validate the token
|
|
print_info("Validating bot token...");
|
|
|
|
match validate_telegram_token(&token).await {
|
|
Ok(username) => {
|
|
print_success(&format!(
|
|
"Bot validated: @{}",
|
|
username.as_deref().unwrap_or("unknown")
|
|
));
|
|
|
|
// Save to database
|
|
secrets.save_secret("telegram_bot_token", &token).await?;
|
|
print_success("Token saved to database");
|
|
|
|
// Bind bot to owner's Telegram account
|
|
let owner_id = bind_telegram_owner(&token).await?;
|
|
|
|
// Offer webhook secret configuration
|
|
let webhook_secret = setup_telegram_webhook_secret(secrets).await?;
|
|
|
|
Ok(TelegramSetupResult {
|
|
enabled: true,
|
|
bot_username: username,
|
|
webhook_secret,
|
|
owner_id,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
print_error(&format!("Token validation failed: {}", e));
|
|
|
|
if confirm("Try again?", true).map_err(|e| e.to_string())? {
|
|
Box::pin(setup_telegram(secrets)).await
|
|
} else {
|
|
Ok(TelegramSetupResult {
|
|
enabled: false,
|
|
bot_username: None,
|
|
webhook_secret: None,
|
|
owner_id: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bind the bot to the owner's Telegram account by having them send a message.
|
|
///
|
|
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
|
|
/// Returns `None` if the user declines or the flow times out.
|
|
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String> {
|
|
println!();
|
|
print_info("Account Binding (recommended):");
|
|
print_info("Binding restricts the bot so only YOU can use it.");
|
|
print_info("Without this, anyone who finds your bot can send it messages.");
|
|
println!();
|
|
|
|
if !confirm("Bind bot to your Telegram account?", true).map_err(|e| e.to_string())? {
|
|
print_info("Skipping account binding. Bot will accept messages from all users.");
|
|
return Ok(None);
|
|
}
|
|
|
|
print_info("Send any message (e.g. /start) to your bot in Telegram.");
|
|
print_info("Waiting for your message (up to 120 seconds)...");
|
|
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(35))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
// Clear any existing webhook so getUpdates works
|
|
let delete_url = format!(
|
|
"https://api.telegram.org/bot{}/deleteWebhook",
|
|
token.expose_secret()
|
|
);
|
|
let _ = client.post(&delete_url).send().await;
|
|
|
|
let updates_url = format!(
|
|
"https://api.telegram.org/bot{}/getUpdates",
|
|
token.expose_secret()
|
|
);
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
|
|
|
while std::time::Instant::now() < deadline {
|
|
let response = client
|
|
.get(&updates_url)
|
|
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("getUpdates request failed: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("getUpdates returned status {}", response.status()));
|
|
}
|
|
|
|
let body: TelegramGetUpdatesResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse getUpdates response: {}", e))?;
|
|
|
|
if !body.ok {
|
|
return Err("Telegram API returned error for getUpdates".to_string());
|
|
}
|
|
|
|
// Find the first message with a sender
|
|
for update in &body.result {
|
|
if let Some(ref msg) = update.message {
|
|
if let Some(ref from) = msg.from {
|
|
let display_name = from
|
|
.username
|
|
.as_ref()
|
|
.map(|u| format!("@{}", u))
|
|
.unwrap_or_else(|| from.first_name.clone());
|
|
|
|
print_success(&format!(
|
|
"Received message from {} (ID: {})",
|
|
display_name, from.id
|
|
));
|
|
|
|
// Acknowledge the update so it doesn't pile up
|
|
let ack_url = format!(
|
|
"https://api.telegram.org/bot{}/getUpdates",
|
|
token.expose_secret()
|
|
);
|
|
let _ = client
|
|
.get(&ack_url)
|
|
.query(&[("offset", &(update.update_id + 1).to_string())])
|
|
.send()
|
|
.await;
|
|
|
|
return Ok(Some(from.id));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
print_error("Timed out waiting for a message. You can re-run setup to try again.");
|
|
print_info("Bot will accept messages from all users until owner is bound.");
|
|
Ok(None)
|
|
}
|
|
|
|
/// Bind flow when the token already exists (reads from secrets store).
|
|
///
|
|
/// Retrieves the saved bot token and delegates to `bind_telegram_owner`.
|
|
async fn bind_telegram_owner_flow(secrets: &SecretsContext) -> Result<Option<i64>, String> {
|
|
// Check current settings first
|
|
let settings = Settings::load();
|
|
if settings.channels.telegram_owner_id.is_some() {
|
|
print_info("Bot is already bound to a Telegram account.");
|
|
if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? {
|
|
return Ok(settings.channels.telegram_owner_id);
|
|
}
|
|
}
|
|
|
|
// We need the token to poll getUpdates
|
|
let token = secrets.get_secret("telegram_bot_token").await?;
|
|
|
|
bind_telegram_owner(&token).await
|
|
}
|
|
|
|
/// Set up a tunnel for exposing the agent to the internet.
|
|
///
|
|
/// This is shared across all channels that need webhook endpoints.
|
|
/// Returns the tunnel URL if configured.
|
|
pub fn setup_tunnel() -> Result<Option<String>, String> {
|
|
// Check if already configured
|
|
let settings = Settings::load();
|
|
if let Some(ref url) = settings.tunnel.public_url {
|
|
print_info(&format!("Existing tunnel configured: {}", url));
|
|
if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? {
|
|
return Ok(Some(url.clone()));
|
|
}
|
|
}
|
|
|
|
println!();
|
|
print_info("Tunnel Configuration (for webhook endpoints):");
|
|
print_info("A tunnel exposes your local agent to the internet, enabling:");
|
|
print_info(" - Instant Telegram message delivery (instead of polling)");
|
|
print_info(" - Future: Slack, Discord, GitHub webhooks");
|
|
print_info("");
|
|
print_info("Supported tunnel providers:");
|
|
print_info(" - ngrok: ngrok http 8080");
|
|
print_info(" - Cloudflare: cloudflared tunnel --url http://localhost:8080");
|
|
print_info(" - localtunnel: lt --port 8080");
|
|
print_info("");
|
|
print_info("Security note: Webhook endpoints don't use tunnel-level auth.");
|
|
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
|
|
println!();
|
|
|
|
if !confirm("Configure a tunnel?", false).map_err(|e| e.to_string())? {
|
|
return Ok(None);
|
|
}
|
|
|
|
let tunnel_url =
|
|
input("Tunnel URL (e.g., https://abc123.ngrok.io)").map_err(|e| e.to_string())?;
|
|
|
|
// Validate URL format
|
|
if !tunnel_url.starts_with("https://") {
|
|
print_error("URL must start with https:// (webhooks require HTTPS)");
|
|
return Err("Invalid tunnel URL: must use HTTPS".to_string());
|
|
}
|
|
|
|
// Remove trailing slash if present
|
|
let tunnel_url = tunnel_url.trim_end_matches('/').to_string();
|
|
|
|
// Save to settings
|
|
let mut settings = Settings::load();
|
|
settings.tunnel.public_url = Some(tunnel_url.clone());
|
|
settings
|
|
.save()
|
|
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
|
|
|
print_success(&format!("Tunnel URL saved: {}", tunnel_url));
|
|
print_info("");
|
|
print_info("Make sure your tunnel is running before starting the agent.");
|
|
print_info("You can also set TUNNEL_URL environment variable to override.");
|
|
|
|
Ok(Some(tunnel_url))
|
|
}
|
|
|
|
/// Set up Telegram webhook secret for signature validation.
|
|
///
|
|
/// Returns the webhook secret if configured.
|
|
async fn setup_telegram_webhook_secret(secrets: &SecretsContext) -> Result<Option<String>, String> {
|
|
// Check if tunnel is configured
|
|
let settings = Settings::load();
|
|
if settings.tunnel.public_url.is_none() {
|
|
print_info("");
|
|
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
|
print_info("Run setup again to configure a tunnel for instant delivery.");
|
|
return Ok(None);
|
|
}
|
|
|
|
println!();
|
|
print_info("Telegram Webhook Security:");
|
|
print_info("A webhook secret adds an extra layer of security by validating");
|
|
print_info("that requests actually come from Telegram's servers.");
|
|
|
|
if !confirm("Generate a webhook secret?", true).map_err(|e| e.to_string())? {
|
|
return Ok(None);
|
|
}
|
|
|
|
let secret = generate_webhook_secret();
|
|
secrets
|
|
.save_secret(
|
|
"telegram_webhook_secret",
|
|
&SecretString::from(secret.clone()),
|
|
)
|
|
.await?;
|
|
print_success("Webhook secret generated and saved");
|
|
|
|
Ok(Some(secret))
|
|
}
|
|
|
|
/// Validate a Telegram bot token by calling the getMe API.
|
|
///
|
|
/// Returns the bot's username if valid.
|
|
pub async fn validate_telegram_token(token: &SecretString) -> Result<Option<String>, String> {
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
let url = format!(
|
|
"https://api.telegram.org/bot{}/getMe",
|
|
token.expose_secret()
|
|
);
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("API returned status {}", response.status()));
|
|
}
|
|
|
|
let body: TelegramGetMeResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
|
|
|
if body.ok {
|
|
Ok(body.result.and_then(|u| u.username))
|
|
} else {
|
|
Err("Telegram API returned error".to_string())
|
|
}
|
|
}
|
|
|
|
/// Result of HTTP webhook setup.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HttpSetupResult {
|
|
pub enabled: bool,
|
|
pub port: u16,
|
|
pub host: String,
|
|
}
|
|
|
|
/// Set up HTTP webhook channel.
|
|
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, String> {
|
|
println!("HTTP Webhook Setup:");
|
|
println!();
|
|
print_info("The HTTP webhook allows external services to send messages to the agent.");
|
|
println!();
|
|
|
|
let port_str = optional_input("Port", Some("default: 8080")).map_err(|e| e.to_string())?;
|
|
let port: u16 = port_str
|
|
.as_deref()
|
|
.unwrap_or("8080")
|
|
.parse()
|
|
.map_err(|e| format!("Invalid port: {}", e))?;
|
|
|
|
if port < 1024 {
|
|
print_info("Note: Ports below 1024 may require root privileges");
|
|
}
|
|
|
|
let host = optional_input("Host", Some("default: 0.0.0.0"))
|
|
.map_err(|e| e.to_string())?
|
|
.unwrap_or_else(|| "0.0.0.0".to_string());
|
|
|
|
// Generate a webhook secret
|
|
if confirm("Generate a webhook secret for authentication?", true).map_err(|e| e.to_string())? {
|
|
let secret = generate_webhook_secret();
|
|
secrets
|
|
.save_secret("http_webhook_secret", &SecretString::from(secret.clone()))
|
|
.await?;
|
|
print_success("Webhook secret generated and saved to database");
|
|
print_info(&format!(
|
|
"Secret: {} (store this for your webhook clients)",
|
|
secret
|
|
));
|
|
}
|
|
|
|
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
|
|
|
Ok(HttpSetupResult {
|
|
enabled: true,
|
|
port,
|
|
host,
|
|
})
|
|
}
|
|
|
|
/// Generate a random webhook secret.
|
|
pub fn generate_webhook_secret() -> String {
|
|
use rand::RngCore;
|
|
let mut rng = rand::thread_rng();
|
|
let mut bytes = [0u8; 32];
|
|
rng.fill_bytes(&mut bytes);
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
}
|
|
|
|
/// Result of WASM channel setup.
|
|
#[derive(Debug, Clone)]
|
|
pub struct WasmChannelSetupResult {
|
|
pub enabled: bool,
|
|
pub channel_name: String,
|
|
}
|
|
|
|
/// Set up a WASM channel using its capabilities file setup schema.
|
|
///
|
|
/// Reads setup requirements from the channel's capabilities file and
|
|
/// prompts the user for each required secret.
|
|
pub async fn setup_wasm_channel(
|
|
secrets: &SecretsContext,
|
|
channel_name: &str,
|
|
setup: &crate::channels::wasm::SetupSchema,
|
|
) -> Result<WasmChannelSetupResult, String> {
|
|
println!("{} Setup:", channel_name);
|
|
println!();
|
|
|
|
for secret_config in &setup.required_secrets {
|
|
// Check if this secret already exists
|
|
if secrets.secret_exists(&secret_config.name).await {
|
|
print_info(&format!(
|
|
"Existing {} found in database.",
|
|
secret_config.name
|
|
));
|
|
if !confirm("Replace existing value?", false).map_err(|e| e.to_string())? {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Get the value from user or auto-generate
|
|
let value = if secret_config.optional {
|
|
let input_value =
|
|
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if let Some(v) = input_value {
|
|
if !v.is_empty() {
|
|
SecretString::from(v)
|
|
} else if let Some(ref auto_gen) = secret_config.auto_generate {
|
|
let generated = generate_secret_with_length(auto_gen.length);
|
|
print_info(&format!(
|
|
"Auto-generated {} ({} bytes)",
|
|
secret_config.name, auto_gen.length
|
|
));
|
|
SecretString::from(generated)
|
|
} else {
|
|
continue; // Skip optional secret with no auto-generate
|
|
}
|
|
} else if let Some(ref auto_gen) = secret_config.auto_generate {
|
|
let generated = generate_secret_with_length(auto_gen.length);
|
|
print_info(&format!(
|
|
"Auto-generated {} ({} bytes)",
|
|
secret_config.name, auto_gen.length
|
|
));
|
|
SecretString::from(generated)
|
|
} else {
|
|
continue; // Skip optional secret with no auto-generate
|
|
}
|
|
} else {
|
|
// Required secret
|
|
let input_value = secret_input(&secret_config.prompt).map_err(|e| e.to_string())?;
|
|
|
|
// Validate if pattern is provided
|
|
if let Some(ref pattern) = secret_config.validation {
|
|
let re = regex::Regex::new(pattern)
|
|
.map_err(|e| format!("Invalid validation pattern: {}", e))?;
|
|
if !re.is_match(input_value.expose_secret()) {
|
|
print_error(&format!(
|
|
"Value does not match expected format: {}",
|
|
pattern
|
|
));
|
|
return Err("Validation failed".to_string());
|
|
}
|
|
}
|
|
|
|
input_value
|
|
};
|
|
|
|
// Save the secret
|
|
secrets.save_secret(&secret_config.name, &value).await?;
|
|
print_success(&format!("{} saved to database", secret_config.name));
|
|
}
|
|
|
|
// Optionally validate the configuration
|
|
if let Some(ref validation_endpoint) = setup.validation_endpoint {
|
|
print_info("Validating configuration...");
|
|
// The validation endpoint may contain placeholders like {telegram_bot_token}
|
|
// For now, we skip validation since we'd need to substitute secrets
|
|
// A full implementation would fetch secrets and substitute them
|
|
print_info(&format!(
|
|
"Validation endpoint configured: {} (validation skipped)",
|
|
validation_endpoint
|
|
));
|
|
}
|
|
|
|
print_success(&format!("{} channel configured", channel_name));
|
|
|
|
Ok(WasmChannelSetupResult {
|
|
enabled: true,
|
|
channel_name: channel_name.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Generate a random secret of specified length (in bytes).
|
|
fn generate_secret_with_length(length: usize) -> String {
|
|
use rand::RngCore;
|
|
let mut rng = rand::thread_rng();
|
|
let mut bytes = vec![0u8; length];
|
|
rng.fill_bytes(&mut bytes);
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_generate_webhook_secret() {
|
|
let secret = generate_webhook_secret();
|
|
assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars
|
|
}
|
|
}
|