//! Slack Web API implementation. //! //! All API calls go through the host's HTTP capability, which handles //! credential injection and rate limiting. The WASM tool never sees //! the actual bot token. use crate::near::agent::host; use crate::types::*; const SLACK_API_BASE: &str = "https://slack.com/api"; /// Percent-encode a string for use as a URL query parameter value. fn url_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } _ => { out.push('%'); out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize])); out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize])); } } } out } /// Make a Slack API call. fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result { let url = format!("{}/{}", SLACK_API_BASE, endpoint); // Content-Type header for POST requests let headers = if body.is_some() { r#"{"Content-Type": "application/json; charset=utf-8"}"# } else { "{}" }; let body_bytes = body.map(|b| b.as_bytes().to_vec()); host::log( host::LogLevel::Debug, &format!("Slack API: {} {}", method, endpoint), ); let response = host::http_request(method, &url, headers, body_bytes.as_deref(), None)?; if response.status < 200 || response.status >= 300 { return Err(format!( "Slack API returned status {}: {}", response.status, String::from_utf8_lossy(&response.body) )); } String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) } /// Send a message to a Slack channel. pub fn send_message( channel: &str, text: &str, thread_ts: Option<&str>, ) -> Result { let mut payload = serde_json::json!({ "channel": channel, "text": text, }); if let Some(ts) = thread_ts { payload["thread_ts"] = serde_json::Value::String(ts.to_string()); } let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?; let response = slack_api_call("POST", "chat.postMessage", Some(&body))?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; if !parsed["ok"].as_bool().unwrap_or(false) { let error = parsed["error"].as_str().unwrap_or("unknown_error"); return Err(format!("Slack API error: {}", error)); } Ok(SendMessageResult { ok: true, channel: parsed["channel"].as_str().unwrap_or(channel).to_string(), ts: parsed["ts"].as_str().unwrap_or("").to_string(), message: parsed.get("message").map(|m| MessageInfo { text: m["text"].as_str().unwrap_or("").to_string(), user: m["user"].as_str().map(|s| s.to_string()), ts: m["ts"].as_str().unwrap_or("").to_string(), }), }) } /// List channels the bot has access to. pub fn list_channels(limit: u32) -> Result { let url = format!( "conversations.list?types=public_channel,private_channel&limit={}", limit ); let response = slack_api_call("GET", &url, None)?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; if !parsed["ok"].as_bool().unwrap_or(false) { let error = parsed["error"].as_str().unwrap_or("unknown_error"); return Err(format!("Slack API error: {}", error)); } let channels = parsed["channels"] .as_array() .map(|arr| { arr.iter() .map(|c| Channel { id: c["id"].as_str().unwrap_or("").to_string(), name: c["name"].as_str().unwrap_or("").to_string(), is_private: c["is_private"].as_bool().unwrap_or(false), is_member: c["is_member"].as_bool().unwrap_or(false), topic: c["topic"]["value"].as_str().map(|s| s.to_string()), purpose: c["purpose"]["value"].as_str().map(|s| s.to_string()), }) .collect() }) .unwrap_or_default(); Ok(ListChannelsResult { ok: true, channels }) } /// Get message history from a channel. pub fn get_channel_history(channel: &str, limit: u32) -> Result { let url = format!( "conversations.history?channel={}&limit={}", url_encode(channel), limit ); let response = slack_api_call("GET", &url, None)?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; if !parsed["ok"].as_bool().unwrap_or(false) { let error = parsed["error"].as_str().unwrap_or("unknown_error"); return Err(format!("Slack API error: {}", error)); } let messages = parsed["messages"] .as_array() .map(|arr| { arr.iter() .map(|m| HistoryMessage { ts: m["ts"].as_str().unwrap_or("").to_string(), text: m["text"].as_str().unwrap_or("").to_string(), user: m["user"].as_str().map(|s| s.to_string()), msg_type: m["type"].as_str().unwrap_or("message").to_string(), }) .collect() }) .unwrap_or_default(); Ok(ChannelHistoryResult { ok: true, messages }) } /// Add a reaction to a message. pub fn post_reaction( channel: &str, timestamp: &str, emoji: &str, ) -> Result { let payload = serde_json::json!({ "channel": channel, "timestamp": timestamp, "name": emoji, }); let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?; let response = slack_api_call("POST", "reactions.add", Some(&body))?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; if !parsed["ok"].as_bool().unwrap_or(false) { let error = parsed["error"].as_str().unwrap_or("unknown_error"); // "already_reacted" is not really an error if error != "already_reacted" { return Err(format!("Slack API error: {}", error)); } } Ok(PostReactionResult { ok: true }) } /// Get information about a user. pub fn get_user_info(user_id: &str) -> Result { let url = format!("users.info?user={}", url_encode(user_id)); let response = slack_api_call("GET", &url, None)?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; if !parsed["ok"].as_bool().unwrap_or(false) { let error = parsed["error"].as_str().unwrap_or("unknown_error"); return Err(format!("Slack API error: {}", error)); } let user = &parsed["user"]; let profile = &user["profile"]; Ok(GetUserInfoResult { ok: true, user: UserInfo { id: user["id"].as_str().unwrap_or("").to_string(), name: user["name"].as_str().unwrap_or("").to_string(), real_name: profile["real_name"].as_str().map(|s| s.to_string()), display_name: profile["display_name"].as_str().map(|s| s.to_string()), email: profile["email"].as_str().map(|s| s.to_string()), is_bot: user["is_bot"].as_bool().unwrap_or(false), }, }) }