mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 08:39:24 +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]>
393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
//! Slack Events API channel for IronClaw.
|
|
//!
|
|
//! This WASM component implements the channel interface for handling Slack
|
|
//! webhooks and sending messages back to Slack.
|
|
//!
|
|
//! # Features
|
|
//!
|
|
//! - URL verification for Slack Events API
|
|
//! - Message event parsing (@mentions, DMs)
|
|
//! - Thread support for conversations
|
|
//! - Response posting via Slack Web API
|
|
//!
|
|
//! # Security
|
|
//!
|
|
//! - Signature validation is handled by the host (webhook secrets)
|
|
//! - Bot token is injected by host during HTTP requests
|
|
//! - WASM never sees raw credentials
|
|
|
|
// 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, StatusUpdate,
|
|
};
|
|
use near::agent::channel_host::{self, EmittedMessage};
|
|
|
|
/// Slack event wrapper.
|
|
#[derive(Debug, Deserialize)]
|
|
struct SlackEventWrapper {
|
|
/// Event type (url_verification, event_callback, etc.)
|
|
#[serde(rename = "type")]
|
|
event_type: String,
|
|
|
|
/// Challenge token for URL verification.
|
|
challenge: Option<String>,
|
|
|
|
/// The actual event payload (for event_callback).
|
|
event: Option<SlackEvent>,
|
|
|
|
/// Team ID that sent this event.
|
|
team_id: Option<String>,
|
|
|
|
/// Event ID for deduplication.
|
|
event_id: Option<String>,
|
|
}
|
|
|
|
/// Slack event payload.
|
|
#[derive(Debug, Deserialize)]
|
|
struct SlackEvent {
|
|
/// Event type (message, app_mention, etc.)
|
|
#[serde(rename = "type")]
|
|
event_type: String,
|
|
|
|
/// User who triggered the event.
|
|
user: Option<String>,
|
|
|
|
/// Channel where the event occurred.
|
|
channel: Option<String>,
|
|
|
|
/// Message text.
|
|
text: Option<String>,
|
|
|
|
/// Thread timestamp (for threaded messages).
|
|
thread_ts: Option<String>,
|
|
|
|
/// Message timestamp.
|
|
ts: Option<String>,
|
|
|
|
/// Bot ID (if message is from a bot).
|
|
bot_id: Option<String>,
|
|
|
|
/// Subtype (bot_message, etc.)
|
|
subtype: Option<String>,
|
|
}
|
|
|
|
/// Metadata stored with emitted messages for response routing.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct SlackMessageMetadata {
|
|
/// Slack channel ID.
|
|
channel: String,
|
|
|
|
/// Thread timestamp for threaded replies.
|
|
thread_ts: Option<String>,
|
|
|
|
/// Original message timestamp.
|
|
message_ts: String,
|
|
|
|
/// Team ID.
|
|
team_id: Option<String>,
|
|
}
|
|
|
|
/// Slack API response for chat.postMessage.
|
|
#[derive(Debug, Deserialize)]
|
|
struct SlackPostMessageResponse {
|
|
ok: bool,
|
|
error: Option<String>,
|
|
ts: Option<String>,
|
|
}
|
|
|
|
/// Channel configuration from capabilities file.
|
|
#[derive(Debug, Deserialize)]
|
|
struct SlackConfig {
|
|
/// Name of secret containing signing secret (for verification by host).
|
|
/// Parsed from config for forward compatibility; not yet used in WASM
|
|
/// (host handles signature verification).
|
|
#[serde(default = "default_signing_secret_name")]
|
|
#[allow(dead_code)]
|
|
signing_secret_name: String,
|
|
}
|
|
|
|
fn default_signing_secret_name() -> String {
|
|
"slack_signing_secret".to_string()
|
|
}
|
|
|
|
struct SlackChannel;
|
|
|
|
impl Guest for SlackChannel {
|
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
|
// Parse configuration
|
|
let _config: SlackConfig = serde_json::from_str(&config_json)
|
|
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
|
|
|
channel_host::log(channel_host::LogLevel::Info, "Slack channel starting");
|
|
|
|
Ok(ChannelConfig {
|
|
display_name: "Slack".to_string(),
|
|
http_endpoints: vec![HttpEndpointConfig {
|
|
path: "/webhook/slack".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: true,
|
|
}],
|
|
poll: None, // Slack uses push via webhooks, no polling needed
|
|
})
|
|
}
|
|
|
|
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
// Parse the request body
|
|
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 Slack event
|
|
let event_wrapper: SlackEventWrapper = match serde_json::from_str(body_str) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to parse Slack event: {}", e),
|
|
);
|
|
return json_response(400, serde_json::json!({"error": "Invalid event payload"}));
|
|
}
|
|
};
|
|
|
|
match event_wrapper.event_type.as_str() {
|
|
// URL verification challenge (Slack setup)
|
|
"url_verification" => {
|
|
if let Some(challenge) = event_wrapper.challenge {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Responding to Slack URL verification",
|
|
);
|
|
json_response(200, serde_json::json!({"challenge": challenge}))
|
|
} else {
|
|
json_response(400, serde_json::json!({"error": "Missing challenge"}))
|
|
}
|
|
}
|
|
|
|
// Actual event callback
|
|
"event_callback" => {
|
|
if let Some(event) = event_wrapper.event {
|
|
handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id);
|
|
}
|
|
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
|
json_response(200, serde_json::json!({"ok": true}))
|
|
}
|
|
|
|
// Unknown event type
|
|
_ => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
&format!("Unknown Slack event type: {}", event_wrapper.event_type),
|
|
);
|
|
json_response(200, serde_json::json!({"ok": true}))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_poll() {
|
|
// Slack uses webhooks, no polling needed
|
|
}
|
|
|
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
// Parse metadata to get channel info
|
|
let metadata: SlackMessageMetadata = serde_json::from_str(&response.metadata_json)
|
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
|
|
|
// Build Slack API request
|
|
let mut payload = serde_json::json!({
|
|
"channel": metadata.channel,
|
|
"text": response.content,
|
|
});
|
|
|
|
// Add thread_ts for threaded replies
|
|
if let Some(thread_ts) = response.thread_id.or(metadata.thread_ts) {
|
|
payload["thread_ts"] = serde_json::Value::String(thread_ts);
|
|
}
|
|
|
|
let payload_bytes = serde_json::to_vec(&payload)
|
|
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
|
|
|
// Make HTTP request to Slack API
|
|
// The bot token is injected by the host based on credential configuration
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json"
|
|
});
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
"https://slack.com/api/chat.postMessage",
|
|
&headers.to_string(),
|
|
Some(&payload_bytes),
|
|
None,
|
|
);
|
|
|
|
match result {
|
|
Ok(http_response) => {
|
|
if http_response.status != 200 {
|
|
return Err(format!(
|
|
"Slack API returned status {}",
|
|
http_response.status
|
|
));
|
|
}
|
|
|
|
// Parse Slack response
|
|
let slack_response: SlackPostMessageResponse =
|
|
serde_json::from_slice(&http_response.body)
|
|
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
|
|
|
|
if !slack_response.ok {
|
|
return Err(format!(
|
|
"Slack API error: {}",
|
|
slack_response
|
|
.error
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
));
|
|
}
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Posted message to Slack channel {}: ts={}",
|
|
metadata.channel,
|
|
slack_response.ts.unwrap_or_default()
|
|
),
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
fn on_status(_update: StatusUpdate) {}
|
|
|
|
fn on_shutdown() {
|
|
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
|
}
|
|
}
|
|
|
|
/// Handle a Slack event and emit message if applicable.
|
|
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
|
match event.event_type.as_str() {
|
|
// Direct mention of the bot
|
|
"app_mention" => {
|
|
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
|
event.user,
|
|
event.channel.clone(),
|
|
event.text,
|
|
event.ts.clone(),
|
|
) {
|
|
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
|
}
|
|
}
|
|
|
|
// Direct message to the bot
|
|
"message" => {
|
|
// Skip messages from bots (including ourselves)
|
|
if event.bot_id.is_some() || event.subtype.is_some() {
|
|
return;
|
|
}
|
|
|
|
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
|
event.user,
|
|
event.channel.clone(),
|
|
event.text,
|
|
event.ts.clone(),
|
|
) {
|
|
// Only process DMs (channel IDs starting with D)
|
|
if channel.starts_with('D') {
|
|
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
_ => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("Ignoring Slack event type: {}", event.event_type),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Emit a message to the agent.
|
|
fn emit_message(
|
|
user_id: String,
|
|
text: String,
|
|
channel: String,
|
|
thread_ts: Option<String>,
|
|
team_id: Option<String>,
|
|
) {
|
|
let message_ts = thread_ts.clone().unwrap_or_default();
|
|
|
|
let metadata = SlackMessageMetadata {
|
|
channel: channel.clone(),
|
|
thread_ts: thread_ts.clone(),
|
|
message_ts: message_ts.clone(),
|
|
team_id,
|
|
};
|
|
|
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to serialize Slack metadata: {}", e),
|
|
);
|
|
"{}".to_string()
|
|
});
|
|
|
|
// Strip @ mentions of the bot from the text for cleaner messages
|
|
let cleaned_text = strip_bot_mention(&text);
|
|
|
|
channel_host::emit_message(&EmittedMessage {
|
|
user_id,
|
|
user_name: None, // Could fetch from Slack API if needed
|
|
content: cleaned_text,
|
|
thread_id: thread_ts,
|
|
metadata_json,
|
|
});
|
|
}
|
|
|
|
/// Strip leading bot mention from text.
|
|
fn strip_bot_mention(text: &str) -> String {
|
|
// Slack mentions look like <@U12345678>
|
|
let trimmed = text.trim();
|
|
if trimmed.starts_with("<@") {
|
|
if let Some(end) = trimmed.find('>') {
|
|
return trimmed[end + 1..].trim_start().to_string();
|
|
}
|
|
}
|
|
trimmed.to_string()
|
|
}
|
|
|
|
/// Create a JSON HTTP response.
|
|
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
|
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to serialize JSON response: {}", e),
|
|
);
|
|
Vec::new()
|
|
});
|
|
let headers = serde_json::json!({"Content-Type": "application/json"});
|
|
|
|
OutgoingHttpResponse {
|
|
status,
|
|
headers_json: headers.to_string(),
|
|
body,
|
|
}
|
|
}
|
|
|
|
// Export the component
|
|
export!(SlackChannel);
|