diff --git a/.claude/commands/add-tool.md b/.claude/commands/add-tool.md new file mode 100644 index 00000000..8557cf45 --- /dev/null +++ b/.claude/commands/add-tool.md @@ -0,0 +1,382 @@ +--- +description: Scaffold a new tool (WASM or built-in Rust) with all boilerplate wired up +allowed-tools: Read, Edit, Write, Glob, Grep, Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo component:*), Bash(ls:*), Bash(mkdir:*) +argument-hint: [description] +model: opus +--- + +Scaffold a new tool called `$ARGUMENTS` for the IronClaw agent. First, determine the tool type and then follow the appropriate path. + +## Step 0: Determine tool type + +Ask the user which type of tool to create: + +- **WASM tool** (recommended) - Sandboxed, dynamically loadable, external API integrations. Lives in `tools-src//`. This is the right choice for anything that talks to an external service (Notion, GitHub, Discord, etc.). +- **Built-in tool** - Compiled into the main binary. Only for core agent infrastructure (e.g., memory, file ops, shell). Lives in `src/tools/builtin/.rs`. + +If the description clearly implies an external service integration, default to WASM. If it's a core agent capability, default to built-in. + +--- + +## Path A: WASM Tool + +### A1: Create directory structure + +Create `tools-src//` with: + +``` +tools-src// +├── Cargo.toml +├── -tool.capabilities.json +└── src/ + ├── lib.rs + ├── types.rs + └── api.rs +``` + +### A2: Write `Cargo.toml` + +Follow this exact pattern (adjust name and description): + +```toml +[package] +name = "-tool" +version = "0.1.0" +edition = "2021" +description = " tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +``` + +### A3: Write `-tool.capabilities.json` + +Declare the tool's security requirements. Determine what APIs it needs and create the allowlist. Reference `tools-src/slack/slack-tool.capabilities.json` for the format. + +Key sections to include: +- `http.allowlist` - API endpoints (host, path_prefix, methods) +- `http.credentials` - Secret injection config (secret_name, location type: bearer/header/query) +- `http.rate_limit` - requests_per_minute, requests_per_hour +- `http.timeout_secs` +- `secrets.allowed_names` - Which secrets the tool can check existence of +- `auth` - Authentication setup (OAuth or manual token entry) + +If the tool needs OAuth, include: +```json +{ + "auth": { + "secret_name": "_token", + "display_name": "", + "oauth": { + "authorization_url": "https://...", + "token_url": "https://...", + "client_id_env": "_OAUTH_CLIENT_ID", + "client_secret_env": "_OAUTH_CLIENT_SECRET", + "scopes": [], + "use_pkce": false + }, + "env_var": "_TOKEN" + } +} +``` + +If no OAuth, include manual setup instructions: +```json +{ + "auth": { + "secret_name": "_api_key", + "display_name": "", + "instructions": "Get your API key from ", + "setup_url": "https://...", + "token_hint": "Starts with ''", + "env_var": "_API_KEY" + } +} +``` + +### A4: Write `src/types.rs` + +Define the action enum using serde's tagged enum pattern: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum Action { + // Add variants based on the tool's capabilities. + // Each variant maps to one API operation. +} +``` + +Add result structs with `#[derive(Debug, Serialize)]`. Use `#[serde(skip_serializing_if = "Option::is_none")]` for optional fields. + +### A5: Write `src/api.rs` + +Implement the API calls using the host HTTP capability: + +```rust +use crate::near::agent::host; +use crate::types::*; + +const API_BASE: &str = "https://api.example.com"; + +fn api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result { + let url = format!("{}/{}", API_BASE, endpoint); + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log(host::LogLevel::Debug, &format!("API: {} {}", method, endpoint)); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + return Err(format!( + "API returned status {}: {}", + response.status, + String::from_utf8_lossy(&response.body) + )); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e)) +} +``` + +Add one function per action variant that calls `api_call` and parses the response into the result structs. + +### A6: Write `src/lib.rs` + +Wire everything together: + +```rust +mod api; +mod types; + +use types::Action; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct Tool; + +impl exports::near::agent::tool::Guest for Tool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + // Return JSON Schema matching the action enum + todo!("Fill in JSON Schema") + } + + fn description() -> String { + "".to_string() + } +} + +fn execute_inner(params: &str) -> Result { + // Check required secrets + if !crate::near::agent::host::secret_exists("") { + return Err(" not configured. Please add the '' secret.".to_string()); + } + + let action: Action = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing action: {:?}", action), + ); + + let result = match action { + // Dispatch to api:: functions for each variant + }; + + Ok(result) +} + +export!(Tool); +``` + +Fill in the `schema()` with a proper JSON Schema using `oneOf` for each action variant. Reference `tools-src/slack/src/lib.rs` for the exact pattern. + +### A7: Verify + +Run `cargo fmt` in the tool directory. If `cargo-component` is available, run `cargo component build --release` to verify the WASM compiles. + +--- + +## Path B: Built-in Tool + +### B1: Create the tool file + +Create `src/tools/builtin/.rs` implementing the `Tool` trait: + +```rust +use async_trait::async_trait; + +use crate::context::JobContext; +use crate::tools::tool::{Tool, ToolError, ToolOutput}; + +pub struct Tool; + +#[async_trait] +impl Tool for Tool { + fn name(&self) -> &str { + "" + } + + fn description(&self) -> &str { + "" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + // Define parameters here + }, + "required": [] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + // Extract and validate parameters + // Do the work + // Return result + + Ok(ToolOutput::text("result", start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false // Set true if tool processes external data + } + + fn requires_approval(&self) -> bool { + false // Set true if tool is destructive or contacts external services + } +} +``` + +If the tool needs shared state (HTTP client, config), add a struct field and `new()` constructor: + +```rust +pub struct Tool { + client: reqwest::Client, +} + +impl Tool { + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"), + } + } +} +``` + +### B2: Update `src/tools/builtin/mod.rs` + +Add the module declaration and pub use, keeping alphabetical order: + +```rust +mod ; +pub use ::Tool; +``` + +### B3: Update `src/tools/registry.rs` + +Add the import to the `use crate::tools::builtin::{...}` block and register the tool in the appropriate registration method: + +- If it's a core tool: add to `register_builtin_tools()` +- If it needs shared state (workspace, context_manager, etc.): create a new `register__tools()` method or add to an existing one +- Wire the new registration call in `src/main.rs` if a new method was created + +### B4: Add tests + +Add a `mod tests {}` block at the bottom of the tool file: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::context::JobContext; + + fn test_context() -> JobContext { + JobContext::test_default() + } + + #[tokio::test] + async fn test__basic() { + let tool = Tool::new(); + let params = serde_json::json!({ /* test params */ }); + let result = tool.execute(params, &test_context()).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test__missing_params() { + let tool = Tool::new(); + let params = serde_json::json!({}); + let result = tool.execute(params, &test_context()).await; + assert!(matches!(result, Err(ToolError::InvalidParameters(_)))); + } +} +``` + +### B5: Quality gate + +Run `cargo fmt` and `cargo clippy --all --benches --tests --examples --all-features`. Fix any issues. + +Run the new tests: `cargo test --lib -- builtin::::tests` + +--- + +## Checklist + +Before finishing, verify: +- [ ] Tool type chosen (WASM or built-in) and confirmed with user +- [ ] All files created with correct structure +- [ ] For WASM: capabilities.json declares all needed permissions (HTTP, secrets, auth) +- [ ] For WASM: JSON Schema in `schema()` matches the action enum variants +- [ ] For built-in: mod.rs updated with module + pub use +- [ ] For built-in: registry.rs imports and registers the tool +- [ ] For built-in: tests added and passing +- [ ] `cargo fmt` clean +- [ ] `cargo clippy` clean (for built-in) or `cargo component build` clean (for WASM) diff --git a/CLAUDE.md b/CLAUDE.md index 5a127104..2064847a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,13 +335,13 @@ Key test patterns: WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities. -1. Create a new crate in `tools-src/wasm-tools//` +1. Create a new crate in `tools-src//` 2. Implement the WIT interface (`wit/tool.wit`) 3. Create `.capabilities.json` declaring required permissions 4. Build with `cargo build --target wasm32-wasip2 --release` 5. Install with `ironclaw tool install path/to/tool.wasm` -See `tools-src/wasm-tools/` for examples. +See `tools-src/` for examples. ## Tool Architecture Principles @@ -423,6 +423,39 @@ When running `ironclaw tool auth `: The agent reads auth config from the tool's capabilities file and provides the appropriate flow. No service-specific code in the main agent. +### WASM Tools vs MCP Servers: When to Use Which + +Both are first-class in the extension system (`ironclaw tool install` handles both), but they have different strengths. + +**WASM Tools (IronClaw native)** + +- Sandboxed: fuel metering, memory limits, no access except what's allowlisted +- Credentials injected by host runtime, tool code never sees the actual token +- Output scanned for secret leakage before returning to the LLM +- Auth (OAuth/manual) declared in `capabilities.json`, agent handles the flow +- Single binary, no process management, works offline +- Cost: must build yourself in Rust, no ecosystem, synchronous only + +**MCP Servers (Model Context Protocol)** + +- Growing ecosystem of pre-built servers (GitHub, Notion, Postgres, etc.) +- Any language (TypeScript/Python most common) +- Can do websockets, streaming, background polling +- Cost: external process with full system access (no sandbox), manages own credentials, IronClaw can't prevent leaks + +**Decision guide:** + +| Scenario | Use | +|----------|-----| +| Good MCP server already exists | **MCP** | +| Handles sensitive credentials (email send, banking) | **WASM** | +| Quick prototype or one-off integration | **MCP** | +| Core capability you'll maintain long-term | **WASM** | +| Needs background connections (websockets, polling) | **MCP** | +| Multiple tools share one OAuth token (e.g., Google suite) | **WASM** | + +The LLM-facing interface is identical for both (tool name, schema, execute), so swapping between them is transparent to the agent. + ## Adding a New Channel 1. Create `src/channels/my_channel.rs` diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 666f2626..f4ed590f 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -687,13 +687,17 @@ impl SetupWizard { .await .map_err(SetupError::Channel)? } else if channel_name == "telegram" { - let telegram_result = setup_telegram(ctx).await.map_err(SetupError::Channel)?; + let telegram_result = + setup_telegram(ctx).await.map_err(SetupError::Channel)?; crate::setup::channels::WasmChannelSetupResult { enabled: telegram_result.enabled, channel_name: "telegram".to_string(), } } else { - print_info(&format!("No setup configuration found for {}", channel_name)); + print_info(&format!( + "No setup configuration found for {}", + channel_name + )); crate::setup::channels::WasmChannelSetupResult { enabled: true, channel_name: channel_name.clone(), diff --git a/tools-src/wasm-tools/slack/.gitignore b/tools-src/.gitignore similarity index 100% rename from tools-src/wasm-tools/slack/.gitignore rename to tools-src/.gitignore diff --git a/tools-src/TOOLS.md b/tools-src/TOOLS.md new file mode 100644 index 00000000..3fa4660e --- /dev/null +++ b/tools-src/TOOLS.md @@ -0,0 +1,25 @@ + +# Google + +All Google tools share `google_oauth_token` for authentication. + +- [x] Gmail - search, read, send, draft, reply to emails +- [x] Google Calendar - list, create, update, delete events +- [x] Google Drive - search, access, upload, share files; supports org and personal drives +- [x] Google Sheets - create spreadsheets, read/write/append values, manage sheets, format cells +- [x] Google Docs - create, read, edit documents; text formatting, paragraphs, tables, lists +- [x] Google Slides - create, read, edit presentations; shapes, images, text formatting, thumbnails, templates +- [ ] Google Cloud - work with cloud instances, storage, allow to spin up and configure new instances, shut them down + +# Instant messengers + +For all messengers: receive notifications of new messages, read contacts, groups and 1:1 messages, send messages on behalf of the user. This is different from the channel because operates from the specific user's account. Be careful with accessing user's messages, make sure messages are kept unread. + +- [x] Slack - post messages, read channels, manage conversations +- [x] Telegram - user-mode via direct MTProto over HTTPS (contacts, messages, send, search, forward, delete); no Docker needed +- [ ] WhatsApp - Cloud API for messaging via Meta Business platform +- [ ] Signal - messaging (note: no official public API exists) + +# Transportation + +- [ ] Uber - call a car to specific destination from current place, check the status of the car/ride including stream the current position, support ordering food as well diff --git a/tools-src/gmail/Cargo.toml b/tools-src/gmail/Cargo.toml new file mode 100644 index 00000000..533f2aa4 --- /dev/null +++ b/tools-src/gmail/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gmail-tool" +version = "0.1.0" +edition = "2021" +description = "Gmail integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/gmail/gmail-tool.capabilities.json b/tools-src/gmail/gmail-tool.capabilities.json new file mode 100644 index 00000000..013cd690 --- /dev/null +++ b/tools-src/gmail/gmail-tool.capabilities.json @@ -0,0 +1,46 @@ +{ + "http": { + "allowlist": [ + { + "host": "gmail.googleapis.com", + "path_prefix": "/gmail/v1/", + "methods": ["GET", "POST", "DELETE"] + } + ], + "credentials": { + "google_oauth_token": { + "secret_name": "google_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["gmail.googleapis.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 500 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["google_oauth_token"] + }, + "auth": { + "secret_name": "google_oauth_token", + "display_name": "Google", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "client_id_env": "GOOGLE_OAUTH_CLIENT_ID", + "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.compose" + ], + "use_pkce": false, + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "env_var": "GOOGLE_OAUTH_TOKEN" + } +} diff --git a/tools-src/gmail/src/api.rs b/tools-src/gmail/src/api.rs new file mode 100644 index 00000000..a84c1f81 --- /dev/null +++ b/tools-src/gmail/src/api.rs @@ -0,0 +1,467 @@ +//! Gmail API v1 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 OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1/users/me"; + +/// Make a Gmail API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = format!("{}/{}", GMAIL_API_BASE, path); + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Gmail API: {} {}", method, path), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Gmail API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Extract a header value from a Gmail message payload. +fn get_header(payload: &serde_json::Value, name: &str) -> String { + payload["headers"] + .as_array() + .and_then(|headers| { + headers.iter().find(|h| { + h["name"] + .as_str() + .map(|n| n.eq_ignore_ascii_case(name)) + .unwrap_or(false) + }) + }) + .and_then(|h| h["value"].as_str()) + .unwrap_or("") + .to_string() +} + +/// Extract plain text body from a Gmail message payload. +/// Walks the MIME parts tree to find text/plain content. +fn extract_body(payload: &serde_json::Value) -> String { + // Try direct body first (simple messages) + if let Some(data) = payload["body"]["data"].as_str() { + if let Some(decoded) = base64url_decode(data) { + return decoded; + } + } + + // Walk parts for multipart messages + if let Some(parts) = payload["parts"].as_array() { + for part in parts { + let mime_type = part["mimeType"].as_str().unwrap_or(""); + + if mime_type == "text/plain" { + if let Some(data) = part["body"]["data"].as_str() { + if let Some(decoded) = base64url_decode(data) { + return decoded; + } + } + } + + // Recurse into nested parts (e.g., multipart/alternative inside multipart/mixed) + if mime_type.starts_with("multipart/") { + let nested = extract_body(part); + if !nested.is_empty() { + return nested; + } + } + } + + // Fall back to text/html if no text/plain found + for part in parts { + if part["mimeType"].as_str() == Some("text/html") { + if let Some(data) = part["body"]["data"].as_str() { + if let Some(decoded) = base64url_decode(data) { + return decoded; + } + } + } + } + } + + String::new() +} + +/// Parse a full message from the API response. +fn parse_message(v: &serde_json::Value) -> Message { + let payload = &v["payload"]; + let label_ids: Vec = v["labelIds"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|l| l.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + Message { + id: v["id"].as_str().unwrap_or("").to_string(), + thread_id: v["threadId"].as_str().unwrap_or("").to_string(), + subject: get_header(payload, "Subject"), + from: get_header(payload, "From"), + to: get_header(payload, "To"), + cc: { + let cc = get_header(payload, "Cc"); + if cc.is_empty() { + None + } else { + Some(cc) + } + }, + date: get_header(payload, "Date"), + body: extract_body(payload), + snippet: v["snippet"].as_str().unwrap_or("").to_string(), + is_unread: label_ids.contains(&"UNREAD".to_string()), + label_ids, + } +} + +/// List messages in the mailbox. +pub fn list_messages( + query: Option<&str>, + max_results: u32, + label_ids: &[String], +) -> Result { + let mut params = vec![format!("maxResults={}", max_results)]; + + if let Some(q) = query { + params.push(format!("q={}", url_encode(q))); + } + for label in label_ids { + params.push(format!("labelIds={}", url_encode(label))); + } + + let path = format!("messages?{}", params.join("&")); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let result_size_estimate = parsed["resultSizeEstimate"].as_u64().unwrap_or(0) as u32; + + // The list endpoint only returns message IDs and thread IDs. + // We need to fetch each message to get summaries. + let message_ids: Vec = parsed["messages"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let mut messages = Vec::new(); + for id in &message_ids { + // Fetch metadata format (lighter than full) for list view + let msg_path = format!("messages/{}?format=metadata", url_encode(id)); + if let Ok(msg_response) = api_call("GET", &msg_path, None) { + if let Ok(msg) = serde_json::from_str::(&msg_response) { + let payload = &msg["payload"]; + let label_ids: Vec = msg["labelIds"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|l| l.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + messages.push(MessageSummary { + id: msg["id"].as_str().unwrap_or("").to_string(), + thread_id: msg["threadId"].as_str().unwrap_or("").to_string(), + subject: get_header(payload, "Subject"), + from: get_header(payload, "From"), + to: get_header(payload, "To"), + date: get_header(payload, "Date"), + snippet: msg["snippet"].as_str().unwrap_or("").to_string(), + is_unread: label_ids.contains(&"UNREAD".to_string()), + label_ids, + }); + } + } + } + + Ok(ListMessagesResult { + messages, + result_size_estimate, + next_page_token: parsed["nextPageToken"].as_str().map(|s| s.to_string()), + }) +} + +/// Get a specific message with full content. +pub fn get_message(message_id: &str) -> Result { + let path = format!("messages/{}?format=full", url_encode(message_id)); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(parse_message(&parsed)) +} + +/// Build an RFC 2822 email and base64url-encode it. +fn build_raw_email( + to: &str, + subject: &str, + body: &str, + cc: Option<&str>, + bcc: Option<&str>, + in_reply_to: Option<&str>, + references: Option<&str>, +) -> String { + let mut email = String::new(); + email.push_str(&format!("To: {}\r\n", to)); + email.push_str(&format!("Subject: {}\r\n", subject)); + email.push_str("Content-Type: text/plain; charset=\"UTF-8\"\r\n"); + email.push_str("MIME-Version: 1.0\r\n"); + + if let Some(cc_val) = cc { + email.push_str(&format!("Cc: {}\r\n", cc_val)); + } + if let Some(bcc_val) = bcc { + email.push_str(&format!("Bcc: {}\r\n", bcc_val)); + } + if let Some(irt) = in_reply_to { + email.push_str(&format!("In-Reply-To: {}\r\n", irt)); + } + if let Some(refs) = references { + email.push_str(&format!("References: {}\r\n", refs)); + } + + email.push_str("\r\n"); + email.push_str(body); + + base64url_encode(email.as_bytes()) +} + +/// Send an email. +pub fn send_message( + to: &str, + subject: &str, + body: &str, + cc: Option<&str>, + bcc: Option<&str>, +) -> Result { + let raw = build_raw_email(to, subject, body, cc, bcc, None, None); + let payload = serde_json::json!({ "raw": raw }); + let body_str = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + + let response = api_call("POST", "messages/send", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(SendResult { + id: parsed["id"].as_str().unwrap_or("").to_string(), + thread_id: parsed["threadId"].as_str().unwrap_or("").to_string(), + label_ids: parsed["labelIds"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|l| l.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + }) +} + +/// Create a draft email. +pub fn create_draft( + to: &str, + subject: &str, + body: &str, + cc: Option<&str>, + bcc: Option<&str>, +) -> Result { + let raw = build_raw_email(to, subject, body, cc, bcc, None, None); + let payload = serde_json::json!({ + "message": { "raw": raw } + }); + let body_str = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + + let response = api_call("POST", "drafts", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(DraftResult { + id: parsed["id"].as_str().unwrap_or("").to_string(), + message_id: parsed["message"]["id"].as_str().unwrap_or("").to_string(), + }) +} + +/// Reply to an existing message. +pub fn reply_to_message( + message_id: &str, + body: &str, + reply_all: bool, +) -> Result { + // First, get the original message to extract headers + let original = get_message(message_id)?; + + let to = if reply_all { + // Combine From and To (excluding self, but we don't know self here, + // so include all and let Gmail dedupe) + let mut recipients = original.from.clone(); + if !original.to.is_empty() { + recipients.push_str(", "); + recipients.push_str(&original.to); + } + if let Some(ref cc) = original.cc { + recipients.push_str(", "); + recipients.push_str(cc); + } + recipients + } else { + original.from.clone() + }; + + let subject = if original.subject.to_lowercase().starts_with("re:") { + original.subject.clone() + } else { + format!("Re: {}", original.subject) + }; + + // Build Message-ID reference for threading. + // The original message_id from Gmail is not the RFC 2822 Message-ID header, + // so we use the thread_id to keep the thread together. + let raw = build_raw_email(&to, &subject, body, None, None, None, None); + let payload = serde_json::json!({ + "raw": raw, + "threadId": original.thread_id + }); + let body_str = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + + let response = api_call("POST", "messages/send", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(SendResult { + id: parsed["id"].as_str().unwrap_or("").to_string(), + thread_id: parsed["threadId"].as_str().unwrap_or("").to_string(), + label_ids: parsed["labelIds"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|l| l.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + }) +} + +/// Move a message to trash. +pub fn trash_message(message_id: &str) -> Result { + let path = format!("messages/{}/trash", url_encode(message_id)); + api_call("POST", &path, None)?; + + Ok(TrashResult { + id: message_id.to_string(), + trashed: true, + }) +} + +// ==================== Encoding Utilities ==================== + +const BASE64URL_CHARS: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/// Base64url-encode bytes (no padding, URL-safe alphabet). +fn base64url_encode(input: &[u8]) -> String { + let mut result = String::with_capacity((input.len() + 2) / 3 * 4); + + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + + let triple = (b0 << 16) | (b1 << 8) | b2; + + result.push(BASE64URL_CHARS[((triple >> 18) & 0x3F) as usize] as char); + result.push(BASE64URL_CHARS[((triple >> 12) & 0x3F) as usize] as char); + + if chunk.len() > 1 { + result.push(BASE64URL_CHARS[((triple >> 6) & 0x3F) as usize] as char); + } + if chunk.len() > 2 { + result.push(BASE64URL_CHARS[(triple & 0x3F) as usize] as char); + } + } + + result +} + +/// Base64url-decode a string. Returns None on invalid input. +fn base64url_decode(input: &str) -> Option { + let input = input.trim_end_matches('='); + let mut bytes = Vec::with_capacity(input.len() * 3 / 4); + + let mut buf: u32 = 0; + let mut bits: u32 = 0; + + for c in input.bytes() { + let val = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'-' => 62, + b'_' => 63, + b'+' => 62, // accept standard base64 too + b'/' => 63, + b'\n' | b'\r' | b' ' => continue, + _ => return None, + }; + + buf = (buf << 6) | val as u32; + bits += 6; + + if bits >= 8 { + bits -= 8; + bytes.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + + String::from_utf8(bytes).ok() +} + +/// Minimal percent-encoding for URL path segments and query values. +fn url_encode(s: &str) -> String { + let mut encoded = 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'~' => { + encoded.push(b as char); + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[(b >> 4) as usize])); + encoded.push(char::from(HEX[(b & 0x0F) as usize])); + } + } + } + encoded +} + +const HEX: [u8; 16] = *b"0123456789ABCDEF"; diff --git a/tools-src/gmail/src/lib.rs b/tools-src/gmail/src/lib.rs new file mode 100644 index 00000000..f78e4d33 --- /dev/null +++ b/tools-src/gmail/src/lib.rs @@ -0,0 +1,253 @@ +//! Gmail WASM Tool for IronClaw. +//! +//! Provides Gmail integration for reading, searching, sending, drafting, +//! and replying to emails. +//! +//! # Capabilities Required +//! +//! - HTTP: `gmail.googleapis.com/gmail/v1/*` (GET, POST, DELETE) +//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically) +//! +//! # Supported Actions +//! +//! - `list_messages`: List/search messages with Gmail query syntax +//! - `get_message`: Get a specific message with full content +//! - `send_message`: Send a new email +//! - `create_draft`: Create a draft email +//! - `reply_to_message`: Reply to an existing message (or reply-all) +//! - `trash_message`: Move a message to trash +//! +//! # Example Usage +//! +//! ```json +//! {"action": "list_messages", "query": "is:unread from:boss@company.com", "max_results": 5} +//! ``` + +mod api; +mod types; + +use types::GmailAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct GmailTool; + +impl exports::near::agent::tool::Guest for GmailTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "list_messages" }, + "query": { + "type": "string", + "description": "Gmail search query (same syntax as Gmail search box). Examples: 'is:unread', 'from:alice@example.com', 'subject:meeting after:2025/01/01'" + }, + "max_results": { + "type": "integer", + "description": "Maximum number of messages to return (default: 20)", + "default": 20 + }, + "label_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Label IDs to filter by (e.g., 'INBOX', 'SENT', 'DRAFT')" + } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_message" }, + "message_id": { + "type": "string", + "description": "The message ID to retrieve" + } + }, + "required": ["action", "message_id"] + }, + { + "properties": { + "action": { "const": "send_message" }, + "to": { + "type": "string", + "description": "Recipient email address(es), comma-separated" + }, + "subject": { + "type": "string", + "description": "Email subject" + }, + "body": { + "type": "string", + "description": "Email body (plain text)" + }, + "cc": { + "type": "string", + "description": "CC recipients, comma-separated" + }, + "bcc": { + "type": "string", + "description": "BCC recipients, comma-separated" + } + }, + "required": ["action", "to", "subject", "body"] + }, + { + "properties": { + "action": { "const": "create_draft" }, + "to": { + "type": "string", + "description": "Recipient email address(es), comma-separated" + }, + "subject": { + "type": "string", + "description": "Email subject" + }, + "body": { + "type": "string", + "description": "Email body (plain text)" + }, + "cc": { + "type": "string", + "description": "CC recipients, comma-separated" + }, + "bcc": { + "type": "string", + "description": "BCC recipients, comma-separated" + } + }, + "required": ["action", "to", "subject", "body"] + }, + { + "properties": { + "action": { "const": "reply_to_message" }, + "message_id": { + "type": "string", + "description": "The message ID to reply to" + }, + "body": { + "type": "string", + "description": "Reply body (plain text)" + }, + "reply_all": { + "type": "boolean", + "description": "If true, reply to all recipients (default: false)", + "default": false + } + }, + "required": ["action", "message_id", "body"] + }, + { + "properties": { + "action": { "const": "trash_message" }, + "message_id": { + "type": "string", + "description": "The message ID to move to trash" + } + }, + "required": ["action", "message_id"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Gmail integration for reading, searching, sending, drafting, and replying to emails. \ + Supports Gmail search query syntax (is:unread, from:, subject:, after:, etc.). \ + Requires a Google OAuth token with gmail.modify and gmail.compose scopes." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("google_oauth_token") { + return Err( + "Google OAuth token not configured. Run `ironclaw tool auth gmail` to set up \ + OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: GmailAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Gmail action: {:?}", action), + ); + + let result = match action { + GmailAction::ListMessages { + query, + max_results, + label_ids, + } => { + let result = api::list_messages(query.as_deref(), max_results, &label_ids)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GmailAction::GetMessage { message_id } => { + let result = api::get_message(&message_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GmailAction::SendMessage { + to, + subject, + body, + cc, + bcc, + } => { + let result = api::send_message(&to, &subject, &body, cc.as_deref(), bcc.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GmailAction::CreateDraft { + to, + subject, + body, + cc, + bcc, + } => { + let result = api::create_draft(&to, &subject, &body, cc.as_deref(), bcc.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GmailAction::ReplyToMessage { + message_id, + body, + reply_all, + } => { + let result = api::reply_to_message(&message_id, &body, reply_all)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GmailAction::TrashMessage { message_id } => { + let result = api::trash_message(&message_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +export!(GmailTool); diff --git a/tools-src/gmail/src/types.rs b/tools-src/gmail/src/types.rs new file mode 100644 index 00000000..48754a1a --- /dev/null +++ b/tools-src/gmail/src/types.rs @@ -0,0 +1,144 @@ +//! Types for Gmail API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Gmail tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GmailAction { + /// List messages in the mailbox. + ListMessages { + /// Gmail search query (same syntax as the Gmail search box). + /// Examples: "from:alice@example.com", "subject:meeting", "is:unread", + /// "after:2025/01/01 before:2025/02/01". + #[serde(default)] + query: Option, + /// Maximum number of messages to return (default: 20). + #[serde(default = "default_max_results")] + max_results: u32, + /// Label IDs to filter by (e.g., "INBOX", "SENT", "DRAFT"). + #[serde(default)] + label_ids: Vec, + }, + + /// Get a specific message with full content. + GetMessage { + /// The message ID. + message_id: String, + }, + + /// Send an email. + SendMessage { + /// Recipient email address(es), comma-separated. + to: String, + /// Email subject. + subject: String, + /// Email body (plain text). + body: String, + /// CC recipients, comma-separated. + #[serde(default)] + cc: Option, + /// BCC recipients, comma-separated. + #[serde(default)] + bcc: Option, + }, + + /// Create a draft email. + CreateDraft { + /// Recipient email address(es), comma-separated. + to: String, + /// Email subject. + subject: String, + /// Email body (plain text). + body: String, + /// CC recipients, comma-separated. + #[serde(default)] + cc: Option, + /// BCC recipients, comma-separated. + #[serde(default)] + bcc: Option, + }, + + /// Reply to an existing message. + ReplyToMessage { + /// The message ID to reply to. + message_id: String, + /// Reply body (plain text). + body: String, + /// If true, reply to all recipients. Default: false. + #[serde(default)] + reply_all: bool, + }, + + /// Move a message to trash. + TrashMessage { + /// The message ID to trash. + message_id: String, + }, +} + +fn default_max_results() -> u32 { + 20 +} + +/// A Gmail message summary (from list endpoint). +#[derive(Debug, Serialize)] +pub struct MessageSummary { + pub id: String, + pub thread_id: String, + pub subject: String, + pub from: String, + pub to: String, + pub date: String, + pub snippet: String, + pub label_ids: Vec, + pub is_unread: bool, +} + +/// A full Gmail message (from get endpoint). +#[derive(Debug, Serialize)] +pub struct Message { + pub id: String, + pub thread_id: String, + pub subject: String, + pub from: String, + pub to: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + pub date: String, + pub body: String, + pub snippet: String, + pub label_ids: Vec, + pub is_unread: bool, +} + +/// Result from list_messages. +#[derive(Debug, Serialize)] +pub struct ListMessagesResult { + pub messages: Vec, + pub result_size_estimate: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +/// Result from send_message or reply_to_message. +#[derive(Debug, Serialize)] +pub struct SendResult { + pub id: String, + pub thread_id: String, + pub label_ids: Vec, +} + +/// Result from create_draft. +#[derive(Debug, Serialize)] +pub struct DraftResult { + pub id: String, + pub message_id: String, +} + +/// Result from trash_message. +#[derive(Debug, Serialize)] +pub struct TrashResult { + pub id: String, + pub trashed: bool, +} diff --git a/tools-src/google-calendar/Cargo.toml b/tools-src/google-calendar/Cargo.toml new file mode 100644 index 00000000..a6c9a5a4 --- /dev/null +++ b/tools-src/google-calendar/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "google-calendar-tool" +version = "0.1.0" +edition = "2021" +description = "Google Calendar integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/google-calendar/google-calendar-tool.capabilities.json b/tools-src/google-calendar/google-calendar-tool.capabilities.json new file mode 100644 index 00000000..7ea74499 --- /dev/null +++ b/tools-src/google-calendar/google-calendar-tool.capabilities.json @@ -0,0 +1,45 @@ +{ + "http": { + "allowlist": [ + { + "host": "www.googleapis.com", + "path_prefix": "/calendar/v3/", + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE"] + } + ], + "credentials": { + "google_oauth_token": { + "secret_name": "google_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["www.googleapis.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 500 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["google_oauth_token"] + }, + "auth": { + "secret_name": "google_oauth_token", + "display_name": "Google", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "client_id_env": "GOOGLE_OAUTH_CLIENT_ID", + "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", + "scopes": [ + "https://www.googleapis.com/auth/calendar.events" + ], + "use_pkce": false, + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "env_var": "GOOGLE_OAUTH_TOKEN" + } +} diff --git a/tools-src/google-calendar/src/api.rs b/tools-src/google-calendar/src/api.rs new file mode 100644 index 00000000..ead7f7ea --- /dev/null +++ b/tools-src/google-calendar/src/api.rs @@ -0,0 +1,318 @@ +//! Google Calendar API v3 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 OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const CALENDAR_API_BASE: &str = "https://www.googleapis.com/calendar/v3"; + +/// Make a Google Calendar API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = format!("{}/{}", CALENDAR_API_BASE, path); + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Calendar API: {} {}", method, path), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Calendar API returned status {}: {}", + response.status, body_text + )); + } + + // DELETE returns no content + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Parse an event from the API's JSON response. +fn parse_event(v: &serde_json::Value) -> Event { + Event { + id: v["id"].as_str().unwrap_or("").to_string(), + summary: v["summary"].as_str().unwrap_or("(no title)").to_string(), + description: v["description"].as_str().map(|s| s.to_string()), + location: v["location"].as_str().map(|s| s.to_string()), + start: parse_event_time(&v["start"]), + end: parse_event_time(&v["end"]), + status: v["status"].as_str().unwrap_or("confirmed").to_string(), + html_link: v["htmlLink"].as_str().map(|s| s.to_string()), + attendees: v["attendees"] + .as_array() + .map(|arr| { + arr.iter() + .map(|a| Attendee { + email: a["email"].as_str().unwrap_or("").to_string(), + display_name: a["displayName"].as_str().map(|s| s.to_string()), + response_status: a["responseStatus"].as_str().map(|s| s.to_string()), + }) + .collect() + }) + .unwrap_or_default(), + organizer: v.get("organizer").map(|o| Organizer { + email: o["email"].as_str().unwrap_or("").to_string(), + display_name: o["displayName"].as_str().map(|s| s.to_string()), + }), + } +} + +fn parse_event_time(v: &serde_json::Value) -> EventTime { + EventTime { + date: v["date"].as_str().map(|s| s.to_string()), + date_time: v["dateTime"].as_str().map(|s| s.to_string()), + time_zone: v["timeZone"].as_str().map(|s| s.to_string()), + } +} + +/// List events from a calendar. +pub fn list_events( + calendar_id: &str, + time_min: Option<&str>, + time_max: Option<&str>, + max_results: u32, + query: Option<&str>, +) -> Result { + let mut params = vec![ + format!("maxResults={}", max_results), + "singleEvents=true".to_string(), + "orderBy=startTime".to_string(), + ]; + + if let Some(t) = time_min { + params.push(format!("timeMin={}", url_encode(t))); + } + if let Some(t) = time_max { + params.push(format!("timeMax={}", url_encode(t))); + } + if let Some(q) = query { + params.push(format!("q={}", url_encode(q))); + } + + let path = format!( + "calendars/{}/events?{}", + url_encode(calendar_id), + params.join("&") + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let events = parsed["items"] + .as_array() + .map(|arr| arr.iter().map(parse_event).collect()) + .unwrap_or_default(); + + Ok(ListEventsResult { + events, + next_page_token: parsed["nextPageToken"].as_str().map(|s| s.to_string()), + }) +} + +/// Get a single event by ID. +pub fn get_event(calendar_id: &str, event_id: &str) -> Result { + let path = format!( + "calendars/{}/events/{}", + url_encode(calendar_id), + url_encode(event_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(EventResult { + event: parse_event(&parsed), + }) +} + +/// Create a new event. +pub fn create_event( + calendar_id: &str, + summary: &str, + description: Option<&str>, + location: Option<&str>, + start_datetime: Option<&str>, + end_datetime: Option<&str>, + start_date: Option<&str>, + end_date: Option<&str>, + timezone: Option<&str>, + attendees: &[String], +) -> Result { + let mut event = serde_json::json!({ + "summary": summary, + }); + + if let Some(desc) = description { + event["description"] = serde_json::Value::String(desc.to_string()); + } + if let Some(loc) = location { + event["location"] = serde_json::Value::String(loc.to_string()); + } + + // Build start/end, preferring datetime over date + if let Some(dt) = start_datetime { + let mut start = serde_json::json!({ "dateTime": dt }); + if let Some(tz) = timezone { + start["timeZone"] = serde_json::Value::String(tz.to_string()); + } + event["start"] = start; + } else if let Some(d) = start_date { + event["start"] = serde_json::json!({ "date": d }); + } else { + return Err("Either start_datetime or start_date is required".to_string()); + } + + if let Some(dt) = end_datetime { + let mut end = serde_json::json!({ "dateTime": dt }); + if let Some(tz) = timezone { + end["timeZone"] = serde_json::Value::String(tz.to_string()); + } + event["end"] = end; + } else if let Some(d) = end_date { + event["end"] = serde_json::json!({ "date": d }); + } else { + return Err("Either end_datetime or end_date is required".to_string()); + } + + if !attendees.is_empty() { + event["attendees"] = serde_json::json!(attendees + .iter() + .map(|e| serde_json::json!({ "email": e })) + .collect::>()); + } + + let body = serde_json::to_string(&event).map_err(|e| e.to_string())?; + let path = format!("calendars/{}/events", url_encode(calendar_id)); + + let response = api_call("POST", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(EventResult { + event: parse_event(&parsed), + }) +} + +/// Update an existing event (PATCH for partial updates). +pub fn update_event( + calendar_id: &str, + event_id: &str, + summary: Option<&str>, + description: Option<&str>, + location: Option<&str>, + start_datetime: Option<&str>, + end_datetime: Option<&str>, + start_date: Option<&str>, + end_date: Option<&str>, + timezone: Option<&str>, + attendees: Option<&[String]>, +) -> Result { + let mut patch = serde_json::json!({}); + + if let Some(s) = summary { + patch["summary"] = serde_json::Value::String(s.to_string()); + } + if let Some(d) = description { + patch["description"] = serde_json::Value::String(d.to_string()); + } + if let Some(l) = location { + patch["location"] = serde_json::Value::String(l.to_string()); + } + + if let Some(dt) = start_datetime { + let mut start = serde_json::json!({ "dateTime": dt }); + if let Some(tz) = timezone { + start["timeZone"] = serde_json::Value::String(tz.to_string()); + } + patch["start"] = start; + } else if let Some(d) = start_date { + patch["start"] = serde_json::json!({ "date": d }); + } + + if let Some(dt) = end_datetime { + let mut end = serde_json::json!({ "dateTime": dt }); + if let Some(tz) = timezone { + end["timeZone"] = serde_json::Value::String(tz.to_string()); + } + patch["end"] = end; + } else if let Some(d) = end_date { + patch["end"] = serde_json::json!({ "date": d }); + } + + if let Some(att) = attendees { + patch["attendees"] = serde_json::json!(att + .iter() + .map(|e| serde_json::json!({ "email": e })) + .collect::>()); + } + + let body = serde_json::to_string(&patch).map_err(|e| e.to_string())?; + let path = format!( + "calendars/{}/events/{}", + url_encode(calendar_id), + url_encode(event_id) + ); + + let response = api_call("PATCH", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(EventResult { + event: parse_event(&parsed), + }) +} + +/// Delete an event. +pub fn delete_event(calendar_id: &str, event_id: &str) -> Result { + let path = format!( + "calendars/{}/events/{}", + url_encode(calendar_id), + url_encode(event_id) + ); + + api_call("DELETE", &path, None)?; + + Ok(DeleteResult { + deleted: true, + event_id: event_id.to_string(), + }) +} + +/// Minimal percent-encoding for URL path segments and query values. +fn url_encode(s: &str) -> String { + let mut encoded = 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'~' => { + encoded.push(b as char); + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[(b >> 4) as usize])); + encoded.push(char::from(HEX[(b & 0x0F) as usize])); + } + } + } + encoded +} + +const HEX: [u8; 16] = *b"0123456789ABCDEF"; diff --git a/tools-src/google-calendar/src/lib.rs b/tools-src/google-calendar/src/lib.rs new file mode 100644 index 00000000..bd13506c --- /dev/null +++ b/tools-src/google-calendar/src/lib.rs @@ -0,0 +1,338 @@ +//! Google Calendar WASM Tool for IronClaw. +//! +//! Provides Google Calendar integration for viewing, creating, updating, +//! and deleting calendar events. +//! +//! # Capabilities Required +//! +//! - HTTP: `www.googleapis.com/calendar/v3/*` (GET, POST, PUT, PATCH, DELETE) +//! - Secrets: `google_calendar_token` (OAuth 2.0 token, injected automatically) +//! +//! # Supported Actions +//! +//! - `list_events`: List upcoming events with optional time range and search +//! - `get_event`: Get a specific event by ID +//! - `create_event`: Create a new calendar event +//! - `update_event`: Update an existing event (partial update) +//! - `delete_event`: Delete an event +//! +//! # Example Usage +//! +//! ```json +//! {"action": "list_events", "time_min": "2025-01-15T00:00:00Z", "max_results": 10} +//! ``` + +mod api; +mod types; + +use types::GoogleCalendarAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct GoogleCalendarTool; + +impl exports::near::agent::tool::Guest for GoogleCalendarTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "list_events" }, + "calendar_id": { + "type": "string", + "description": "Calendar ID (default: 'primary')", + "default": "primary" + }, + "time_min": { + "type": "string", + "description": "Lower bound for event start time (RFC3339, e.g., '2025-01-15T00:00:00Z')" + }, + "time_max": { + "type": "string", + "description": "Upper bound for event end time (RFC3339)" + }, + "max_results": { + "type": "integer", + "description": "Maximum number of events to return (default: 25)", + "default": 25 + }, + "query": { + "type": "string", + "description": "Free text search terms to filter events" + } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_event" }, + "calendar_id": { + "type": "string", + "description": "Calendar ID (default: 'primary')", + "default": "primary" + }, + "event_id": { + "type": "string", + "description": "The event ID to retrieve" + } + }, + "required": ["action", "event_id"] + }, + { + "properties": { + "action": { "const": "create_event" }, + "calendar_id": { + "type": "string", + "description": "Calendar ID (default: 'primary')", + "default": "primary" + }, + "summary": { + "type": "string", + "description": "Event title" + }, + "description": { + "type": "string", + "description": "Event description" + }, + "location": { + "type": "string", + "description": "Event location" + }, + "start_datetime": { + "type": "string", + "description": "Start time as RFC3339 (e.g., '2025-01-15T09:00:00-05:00'). Use start_date for all-day events." + }, + "end_datetime": { + "type": "string", + "description": "End time as RFC3339. Use end_date for all-day events." + }, + "start_date": { + "type": "string", + "description": "Start date for all-day events (e.g., '2025-01-15')" + }, + "end_date": { + "type": "string", + "description": "End date for all-day events (exclusive, e.g., '2025-01-16' for a single day)" + }, + "timezone": { + "type": "string", + "description": "Timezone (e.g., 'America/New_York')" + }, + "attendees": { + "type": "array", + "items": { "type": "string" }, + "description": "Attendee email addresses" + } + }, + "required": ["action", "summary"] + }, + { + "properties": { + "action": { "const": "update_event" }, + "calendar_id": { + "type": "string", + "description": "Calendar ID (default: 'primary')", + "default": "primary" + }, + "event_id": { + "type": "string", + "description": "The event ID to update" + }, + "summary": { + "type": "string", + "description": "New event title" + }, + "description": { + "type": "string", + "description": "New event description" + }, + "location": { + "type": "string", + "description": "New event location" + }, + "start_datetime": { + "type": "string", + "description": "New start time (RFC3339)" + }, + "end_datetime": { + "type": "string", + "description": "New end time (RFC3339)" + }, + "start_date": { + "type": "string", + "description": "New start date for all-day events" + }, + "end_date": { + "type": "string", + "description": "New end date for all-day events" + }, + "timezone": { + "type": "string", + "description": "Timezone for datetime fields" + }, + "attendees": { + "type": "array", + "items": { "type": "string" }, + "description": "Replace attendees with these email addresses" + } + }, + "required": ["action", "event_id"] + }, + { + "properties": { + "action": { "const": "delete_event" }, + "calendar_id": { + "type": "string", + "description": "Calendar ID (default: 'primary')", + "default": "primary" + }, + "event_id": { + "type": "string", + "description": "The event ID to delete" + } + }, + "required": ["action", "event_id"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Google Calendar integration for viewing, creating, updating, and deleting calendar \ + events. Requires a Google Calendar OAuth token with the calendar.events scope. \ + Supports timed events, all-day events, attendees, locations, and free text search." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("google_oauth_token") { + return Err( + "Google OAuth token not configured. Run `ironclaw tool auth google-calendar` \ + to set up OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: GoogleCalendarAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Google Calendar action: {:?}", action), + ); + + let result = match action { + GoogleCalendarAction::ListEvents { + calendar_id, + time_min, + time_max, + max_results, + query, + } => { + let result = api::list_events( + &calendar_id, + time_min.as_deref(), + time_max.as_deref(), + max_results, + query.as_deref(), + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleCalendarAction::GetEvent { + calendar_id, + event_id, + } => { + let result = api::get_event(&calendar_id, &event_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleCalendarAction::CreateEvent { + calendar_id, + summary, + description, + location, + start_datetime, + end_datetime, + start_date, + end_date, + timezone, + attendees, + } => { + let result = api::create_event( + &calendar_id, + &summary, + description.as_deref(), + location.as_deref(), + start_datetime.as_deref(), + end_datetime.as_deref(), + start_date.as_deref(), + end_date.as_deref(), + timezone.as_deref(), + &attendees, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleCalendarAction::UpdateEvent { + calendar_id, + event_id, + summary, + description, + location, + start_datetime, + end_datetime, + start_date, + end_date, + timezone, + attendees, + } => { + let result = api::update_event( + &calendar_id, + &event_id, + summary.as_deref(), + description.as_deref(), + location.as_deref(), + start_datetime.as_deref(), + end_datetime.as_deref(), + start_date.as_deref(), + end_date.as_deref(), + timezone.as_deref(), + attendees.as_deref(), + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleCalendarAction::DeleteEvent { + calendar_id, + event_id, + } => { + let result = api::delete_event(&calendar_id, &event_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +export!(GoogleCalendarTool); diff --git a/tools-src/google-calendar/src/types.rs b/tools-src/google-calendar/src/types.rs new file mode 100644 index 00000000..10bd2d43 --- /dev/null +++ b/tools-src/google-calendar/src/types.rs @@ -0,0 +1,193 @@ +//! Types for Google Calendar API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Google Calendar tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleCalendarAction { + /// List events from a calendar. + ListEvents { + /// Calendar ID (default: "primary"). + #[serde(default = "default_calendar_id")] + calendar_id: String, + /// Lower bound (RFC3339 timestamp) for filtering by start time. + #[serde(default)] + time_min: Option, + /// Upper bound (RFC3339 timestamp) for filtering by end time. + #[serde(default)] + time_max: Option, + /// Maximum number of events to return (default: 25). + #[serde(default = "default_max_results")] + max_results: u32, + /// Free text search terms to filter events. + #[serde(default)] + query: Option, + }, + + /// Get a single event by ID. + GetEvent { + /// Calendar ID (default: "primary"). + #[serde(default = "default_calendar_id")] + calendar_id: String, + /// The event ID. + event_id: String, + }, + + /// Create a new event. + CreateEvent { + /// Calendar ID (default: "primary"). + #[serde(default = "default_calendar_id")] + calendar_id: String, + /// Event title. + summary: String, + /// Event description. + #[serde(default)] + description: Option, + /// Event location. + #[serde(default)] + location: Option, + /// Start time as RFC3339 timestamp (e.g., "2025-01-15T09:00:00-05:00"). + /// For all-day events, use date format "2025-01-15" in `start_date` instead. + #[serde(default)] + start_datetime: Option, + /// End time as RFC3339 timestamp. + #[serde(default)] + end_datetime: Option, + /// Start date for all-day events (e.g., "2025-01-15"). + #[serde(default)] + start_date: Option, + /// End date for all-day events (exclusive, e.g., "2025-01-16" for a single day). + #[serde(default)] + end_date: Option, + /// Timezone (e.g., "America/New_York"). Used with datetime fields. + #[serde(default)] + timezone: Option, + /// Attendee email addresses. + #[serde(default)] + attendees: Vec, + }, + + /// Update an existing event (partial update via PATCH). + UpdateEvent { + /// Calendar ID (default: "primary"). + #[serde(default = "default_calendar_id")] + calendar_id: String, + /// The event ID to update. + event_id: String, + /// New event title. + #[serde(default)] + summary: Option, + /// New event description. + #[serde(default)] + description: Option, + /// New event location. + #[serde(default)] + location: Option, + /// New start datetime (RFC3339). + #[serde(default)] + start_datetime: Option, + /// New end datetime (RFC3339). + #[serde(default)] + end_datetime: Option, + /// New start date for all-day events. + #[serde(default)] + start_date: Option, + /// New end date for all-day events. + #[serde(default)] + end_date: Option, + /// Timezone for datetime fields. + #[serde(default)] + timezone: Option, + /// Replace attendees list with these email addresses. + #[serde(default)] + attendees: Option>, + }, + + /// Delete an event. + DeleteEvent { + /// Calendar ID (default: "primary"). + #[serde(default = "default_calendar_id")] + calendar_id: String, + /// The event ID to delete. + event_id: String, + }, +} + +fn default_calendar_id() -> String { + "primary".to_string() +} + +fn default_max_results() -> u32 { + 25 +} + +/// A Google Calendar event. +#[derive(Debug, Serialize)] +pub struct Event { + pub id: String, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + pub start: EventTime, + pub end: EventTime, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub html_link: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub attendees: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub organizer: Option, +} + +/// Event start/end time. Either `date` (all-day) or `date_time` (timed). +#[derive(Debug, Serialize)] +pub struct EventTime { + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub time_zone: Option, +} + +/// An event attendee. +#[derive(Debug, Serialize)] +pub struct Attendee { + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_status: Option, +} + +/// Event organizer. +#[derive(Debug, Serialize)] +pub struct Organizer { + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +/// Result from list_events. +#[derive(Debug, Serialize)] +pub struct ListEventsResult { + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +/// Result from create/update operations. +#[derive(Debug, Serialize)] +pub struct EventResult { + pub event: Event, +} + +/// Result from delete_event. +#[derive(Debug, Serialize)] +pub struct DeleteResult { + pub deleted: bool, + pub event_id: String, +} diff --git a/tools-src/google-docs/Cargo.toml b/tools-src/google-docs/Cargo.toml new file mode 100644 index 00000000..7348343d --- /dev/null +++ b/tools-src/google-docs/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "google-docs-tool" +version = "0.1.0" +edition = "2021" +description = "Google Docs integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/google-docs/google-docs-tool.capabilities.json b/tools-src/google-docs/google-docs-tool.capabilities.json new file mode 100644 index 00000000..9beee15d --- /dev/null +++ b/tools-src/google-docs/google-docs-tool.capabilities.json @@ -0,0 +1,45 @@ +{ + "http": { + "allowlist": [ + { + "host": "docs.googleapis.com", + "path_prefix": "/v1/documents", + "methods": ["GET", "POST"] + } + ], + "credentials": { + "google_oauth_token": { + "secret_name": "google_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["docs.googleapis.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 500 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["google_oauth_token"] + }, + "auth": { + "secret_name": "google_oauth_token", + "display_name": "Google", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "client_id_env": "GOOGLE_OAUTH_CLIENT_ID", + "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", + "scopes": [ + "https://www.googleapis.com/auth/documents" + ], + "use_pkce": false, + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "env_var": "GOOGLE_OAUTH_TOKEN" + } +} diff --git a/tools-src/google-docs/src/api.rs b/tools-src/google-docs/src/api.rs new file mode 100644 index 00000000..2e90c744 --- /dev/null +++ b/tools-src/google-docs/src/api.rs @@ -0,0 +1,516 @@ +//! Google Docs API v1 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 OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const DOCS_API_BASE: &str = "https://docs.googleapis.com/v1/documents"; + +/// Make a Google Docs API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = if path.is_empty() { + DOCS_API_BASE.to_string() + } else { + format!("{}/{}", DOCS_API_BASE, path) + }; + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Docs API: {} {}", method, url), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Docs API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Send a batchUpdate to the document and return the parsed response. +fn batch_update_raw( + document_id: &str, + requests: Vec, +) -> Result { + let path = format!("{}:batchUpdate", url_encode(document_id)); + + let body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", &path, Some(&body_str))?; + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// Extract revision ID from a batchUpdate response. +fn extract_revision_id(parsed: &serde_json::Value) -> String { + parsed["writeControl"]["requiredRevisionId"] + .as_str() + .unwrap_or("") + .to_string() +} + +/// Create a new document. +pub fn create_document(title: &str) -> Result { + let body = serde_json::json!({ "title": title }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", "", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(CreateDocumentResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + }) +} + +/// Get document metadata. +pub fn get_document(document_id: &str) -> Result { + let path = url_encode(document_id); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + // Calculate body length from the last element's endIndex + let body_length = parsed["body"]["content"] + .as_array() + .and_then(|arr| arr.last()) + .and_then(|el| el["endIndex"].as_i64()) + .unwrap_or(1); + + // Extract named ranges + let mut named_ranges = Vec::new(); + if let Some(nr_map) = parsed["namedRanges"].as_object() { + for (_name, nr_group) in nr_map { + if let Some(ranges) = nr_group["namedRanges"].as_array() { + for nr in ranges { + let name = nr["name"].as_str().unwrap_or("").to_string(); + let id = nr["namedRangeId"].as_str().unwrap_or("").to_string(); + if let Some(range_list) = nr["ranges"].as_array() { + for range in range_list { + named_ranges.push(DocumentNamedRange { + name: name.clone(), + named_range_id: id.clone(), + start_index: range["startIndex"].as_i64().unwrap_or(0), + end_index: range["endIndex"].as_i64().unwrap_or(0), + }); + } + } + } + } + } + } + + Ok(DocumentMetadata { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + revision_id: parsed["revisionId"].as_str().unwrap_or("").to_string(), + body_length, + named_ranges, + }) +} + +/// Read the document body as plain text by walking the structural elements. +pub fn read_content(document_id: &str) -> Result { + let path = url_encode(document_id); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let mut text = String::new(); + if let Some(content) = parsed["body"]["content"].as_array() { + extract_text_from_elements(content, &mut text); + } + + Ok(ReadContentResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + content: text, + }) +} + +/// Recursively extract plain text from structural elements. +fn extract_text_from_elements(elements: &[serde_json::Value], out: &mut String) { + for el in elements { + // Paragraph + if let Some(para) = el.get("paragraph") { + if let Some(para_elements) = para["elements"].as_array() { + for pe in para_elements { + if let Some(text_run) = pe.get("textRun") { + if let Some(content) = text_run["content"].as_str() { + out.push_str(content); + } + } + } + } + } + // Table: recurse into cells + if let Some(table) = el.get("table") { + if let Some(rows) = table["tableRows"].as_array() { + for row in rows { + if let Some(cells) = row["tableCells"].as_array() { + for cell in cells { + if let Some(cell_content) = cell["content"].as_array() { + extract_text_from_elements(cell_content, out); + } + } + } + } + } + } + } +} + +/// Insert text at a position. +pub fn insert_text( + document_id: &str, + text: &str, + index: i64, + segment_id: &str, +) -> Result { + let request = if index < 0 { + // Append at end of segment + let mut loc = serde_json::json!({}); + if !segment_id.is_empty() { + loc["segmentId"] = serde_json::Value::String(segment_id.to_string()); + } + serde_json::json!({ + "insertText": { + "text": text, + "endOfSegmentLocation": loc, + } + }) + } else { + let mut loc = serde_json::json!({ "index": index }); + if !segment_id.is_empty() { + loc["segmentId"] = serde_json::Value::String(segment_id.to_string()); + } + serde_json::json!({ + "insertText": { + "text": text, + "location": loc, + } + }) + }; + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Delete content in a range. +pub fn delete_content( + document_id: &str, + start_index: i64, + end_index: i64, + segment_id: &str, +) -> Result { + let mut range = serde_json::json!({ + "startIndex": start_index, + "endIndex": end_index, + }); + if !segment_id.is_empty() { + range["segmentId"] = serde_json::Value::String(segment_id.to_string()); + } + + let request = serde_json::json!({ + "deleteContentRange": { "range": range } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Find and replace all occurrences of text. +pub fn replace_text( + document_id: &str, + find: &str, + replace: &str, + match_case: bool, +) -> Result { + let request = serde_json::json!({ + "replaceAllText": { + "containsText": { + "text": find, + "matchCase": match_case, + }, + "replaceText": replace, + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"] + .as_i64() + .unwrap_or(0); + + Ok(ReplaceResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + occurrences_changed: occurrences, + }) +} + +/// Parse a hex color like "#FF0000" into Docs API color format. +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(serde_json::json!({ + "color": { + "rgbColor": { + "red": r as f64 / 255.0, + "green": g as f64 / 255.0, + "blue": b as f64 / 255.0, + } + } + })) +} + +/// Parameters for text formatting. +pub struct FormatTextOptions<'a> { + pub document_id: &'a str, + pub start_index: i64, + pub end_index: i64, + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub strikethrough: Option, + pub font_size: Option, + pub font_family: Option<&'a str>, + pub foreground_color: Option<&'a str>, + pub background_color: Option<&'a str>, +} + +/// Format text in a range. +pub fn format_text(opts: FormatTextOptions<'_>) -> Result { + let mut style = serde_json::json!({}); + let mut fields = Vec::new(); + + if let Some(b) = opts.bold { + style["bold"] = serde_json::Value::Bool(b); + fields.push("bold"); + } + if let Some(i) = opts.italic { + style["italic"] = serde_json::Value::Bool(i); + fields.push("italic"); + } + if let Some(u) = opts.underline { + style["underline"] = serde_json::Value::Bool(u); + fields.push("underline"); + } + if let Some(s) = opts.strikethrough { + style["strikethrough"] = serde_json::Value::Bool(s); + fields.push("strikethrough"); + } + if let Some(size) = opts.font_size { + style["fontSize"] = serde_json::json!({ "magnitude": size, "unit": "PT" }); + fields.push("fontSize"); + } + if let Some(family) = opts.font_family { + style["weightedFontFamily"] = serde_json::json!({ "fontFamily": family }); + fields.push("weightedFontFamily"); + } + if let Some(color) = opts.foreground_color { + if let Some(c) = parse_hex_color(color) { + style["foregroundColor"] = c; + fields.push("foregroundColor"); + } + } + if let Some(color) = opts.background_color { + if let Some(c) = parse_hex_color(color) { + style["backgroundColor"] = c; + fields.push("backgroundColor"); + } + } + + if fields.is_empty() { + return Err("No formatting options specified".to_string()); + } + + let request = serde_json::json!({ + "updateTextStyle": { + "range": { + "startIndex": opts.start_index, + "endIndex": opts.end_index, + }, + "textStyle": style, + "fields": fields.join(","), + } + }); + + let parsed = batch_update_raw(opts.document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Format paragraph style. +pub fn format_paragraph( + document_id: &str, + start_index: i64, + end_index: i64, + named_style: Option<&str>, + alignment: Option<&str>, + line_spacing: Option, +) -> Result { + let mut para_style = serde_json::json!({}); + let mut fields = Vec::new(); + + if let Some(style) = named_style { + para_style["namedStyleType"] = serde_json::Value::String(style.to_string()); + fields.push("namedStyleType"); + } + if let Some(align) = alignment { + para_style["alignment"] = serde_json::Value::String(align.to_string()); + fields.push("alignment"); + } + if let Some(spacing) = line_spacing { + para_style["lineSpacing"] = serde_json::json!(spacing); + fields.push("lineSpacing"); + } + + if fields.is_empty() { + return Err("No paragraph style options specified".to_string()); + } + + let request = serde_json::json!({ + "updateParagraphStyle": { + "range": { + "startIndex": start_index, + "endIndex": end_index, + }, + "paragraphStyle": para_style, + "fields": fields.join(","), + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Insert a table at a position. +pub fn insert_table( + document_id: &str, + rows: i64, + columns: i64, + index: i64, +) -> Result { + let request = serde_json::json!({ + "insertTable": { + "rows": rows, + "columns": columns, + "location": { "index": index }, + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Create a bulleted or numbered list from paragraphs in a range. +pub fn create_list( + document_id: &str, + start_index: i64, + end_index: i64, + bullet_preset: &str, +) -> Result { + let request = serde_json::json!({ + "createParagraphBullets": { + "range": { + "startIndex": start_index, + "endIndex": end_index, + }, + "bulletPreset": bullet_preset, + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Execute a raw batch update with arbitrary requests. +pub fn batch_update( + document_id: &str, + requests: Vec, +) -> Result { + let parsed = batch_update_raw(document_id, requests)?; + + let replies = parsed["replies"] + .as_array() + .map(|arr| arr.to_vec()) + .unwrap_or_default(); + + Ok(BatchUpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + replies, + }) +} + +/// Minimal percent-encoding for URL path segments. +fn url_encode(s: &str) -> String { + let mut encoded = 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'~' => { + encoded.push(b as char); + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[(b >> 4) as usize])); + encoded.push(char::from(HEX[(b & 0x0F) as usize])); + } + } + } + encoded +} + +const HEX: [u8; 16] = *b"0123456789ABCDEF"; diff --git a/tools-src/google-docs/src/lib.rs b/tools-src/google-docs/src/lib.rs new file mode 100644 index 00000000..f75be91b --- /dev/null +++ b/tools-src/google-docs/src/lib.rs @@ -0,0 +1,481 @@ +//! Google Docs WASM Tool for IronClaw. +//! +//! Provides Google Docs integration for creating, reading, editing, +//! and formatting documents. Use Google Drive tool to search for +//! existing documents by name. +//! +//! # Capabilities Required +//! +//! - HTTP: `docs.googleapis.com/v1/documents*` +//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically) +//! +//! # Supported Actions +//! +//! - `create_document`: Create a new blank document +//! - `get_document`: Get document metadata (title, length, named ranges) +//! - `read_content`: Read entire document body as plain text +//! - `insert_text`: Insert text at a position (or append at end) +//! - `delete_content`: Delete text in a range +//! - `replace_text`: Find and replace all occurrences +//! - `format_text`: Format text (bold, italic, font, color, size) +//! - `format_paragraph`: Set heading level, alignment, spacing +//! - `insert_table`: Insert a table at a position +//! - `create_list`: Create bulleted/numbered list from paragraphs +//! - `batch_update`: Execute multiple raw Docs API operations atomically +//! +//! # Tips +//! +//! - Document IDs are the same as Google Drive file IDs. Use google-drive +//! tool's list_files to find documents. +//! - Indexes are 0-based character offsets. An empty document body starts +//! with a newline at index 0, so insert at index 1 to prepend text. +//! - Use index -1 to append at the end of the document. +//! - When doing multiple edits, process from highest index to lowest +//! to avoid index shifting issues. +//! +//! # Example Usage +//! +//! ```json +//! {"action": "create_document", "title": "Meeting Notes"} +//! {"action": "read_content", "document_id": "abc123"} +//! {"action": "insert_text", "document_id": "abc123", "text": "Hello World\n", "index": 1} +//! {"action": "replace_text", "document_id": "abc123", "find": "Hello", "replace": "Hi"} +//! {"action": "format_text", "document_id": "abc123", "start_index": 1, "end_index": 12, "bold": true, "font_size": 18} +//! {"action": "format_paragraph", "document_id": "abc123", "start_index": 1, "end_index": 12, "named_style": "HEADING_1"} +//! ``` + +mod api; +mod types; + +use types::GoogleDocsAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct GoogleDocsTool; + +impl exports::near::agent::tool::Guest for GoogleDocsTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "create_document" }, + "title": { + "type": "string", + "description": "Document title" + } + }, + "required": ["action", "title"] + }, + { + "properties": { + "action": { "const": "get_document" }, + "document_id": { + "type": "string", + "description": "The document ID (same as Google Drive file ID)" + } + }, + "required": ["action", "document_id"] + }, + { + "properties": { + "action": { "const": "read_content" }, + "document_id": { + "type": "string", + "description": "The document ID" + } + }, + "required": ["action", "document_id"] + }, + { + "properties": { + "action": { "const": "insert_text" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "index": { + "type": "integer", + "description": "Character index to insert at (1 for start of body). Use -1 to append at end.", + "default": -1 + }, + "segment_id": { + "type": "string", + "description": "Segment ID (empty string for body, or a header/footer ID)", + "default": "" + } + }, + "required": ["action", "document_id", "text"] + }, + { + "properties": { + "action": { "const": "delete_content" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive)" + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive)" + }, + "segment_id": { + "type": "string", + "description": "Segment ID (empty for body)", + "default": "" + } + }, + "required": ["action", "document_id", "start_index", "end_index"] + }, + { + "properties": { + "action": { "const": "replace_text" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "find": { + "type": "string", + "description": "Text to search for" + }, + "replace": { + "type": "string", + "description": "Replacement text" + }, + "match_case": { + "type": "boolean", + "description": "Case-sensitive match (default: true)", + "default": true + } + }, + "required": ["action", "document_id", "find", "replace"] + }, + { + "properties": { + "action": { "const": "format_text" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive)" + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive)" + }, + "bold": { + "type": "boolean", + "description": "Make text bold" + }, + "italic": { + "type": "boolean", + "description": "Make text italic" + }, + "underline": { + "type": "boolean", + "description": "Underline text" + }, + "strikethrough": { + "type": "boolean", + "description": "Strikethrough text" + }, + "font_size": { + "type": "number", + "description": "Font size in points (e.g., 12, 14, 18)" + }, + "font_family": { + "type": "string", + "description": "Font family (e.g., 'Arial', 'Times New Roman', 'Courier New')" + }, + "foreground_color": { + "type": "string", + "description": "Text color as hex (e.g., '#FF0000' for red)" + }, + "background_color": { + "type": "string", + "description": "Text background/highlight color as hex" + } + }, + "required": ["action", "document_id", "start_index", "end_index"] + }, + { + "properties": { + "action": { "const": "format_paragraph" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive)" + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive)" + }, + "named_style": { + "type": "string", + "enum": ["NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1", "HEADING_2", "HEADING_3", "HEADING_4", "HEADING_5", "HEADING_6"], + "description": "Paragraph style (heading level)" + }, + "alignment": { + "type": "string", + "enum": ["START", "CENTER", "END", "JUSTIFIED"], + "description": "Text alignment" + }, + "line_spacing": { + "type": "number", + "description": "Line spacing as percentage (e.g., 100 for single, 150 for 1.5x, 200 for double)" + } + }, + "required": ["action", "document_id", "start_index", "end_index"] + }, + { + "properties": { + "action": { "const": "insert_table" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "rows": { + "type": "integer", + "description": "Number of rows" + }, + "columns": { + "type": "integer", + "description": "Number of columns" + }, + "index": { + "type": "integer", + "description": "Character index to insert the table at" + } + }, + "required": ["action", "document_id", "rows", "columns", "index"] + }, + { + "properties": { + "action": { "const": "create_list" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive)" + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive)" + }, + "bullet_preset": { + "type": "string", + "enum": ["BULLET_DISC_CIRCLE_SQUARE", "BULLET_CHECKBOX", "BULLET_ARROW_DIAMOND_DISC", "NUMBERED_DECIMAL_ALPHA_ROMAN", "NUMBERED_DECIMAL_NESTED", "NUMBERED_UPPERALPHA_ALPHA_ROMAN"], + "description": "Bullet style preset (default: BULLET_DISC_CIRCLE_SQUARE)", + "default": "BULLET_DISC_CIRCLE_SQUARE" + } + }, + "required": ["action", "document_id", "start_index", "end_index"] + }, + { + "properties": { + "action": { "const": "batch_update" }, + "document_id": { + "type": "string", + "description": "The document ID" + }, + "requests": { + "type": "array", + "items": { "type": "object" }, + "description": "Array of raw Docs API batchUpdate request objects" + } + }, + "required": ["action", "document_id", "requests"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Google Docs integration for creating, reading, editing, and formatting documents. \ + Supports text operations (insert, delete, find-replace), text formatting (bold, italic, \ + font, color, size), paragraph styling (headings, alignment, spacing), tables, and \ + bulleted/numbered lists. Also provides a batch_update action for complex multi-step \ + edits executed atomically. Document IDs are the same as Google Drive file IDs, so use \ + the google-drive tool to search for existing documents. Requires a Google OAuth token \ + with the documents scope." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("google_oauth_token") { + return Err( + "Google OAuth token not configured. Run `ironclaw tool auth google-docs` to set up \ + OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: GoogleDocsAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Google Docs action: {:?}", action), + ); + + let result = match action { + GoogleDocsAction::CreateDocument { title } => { + let result = api::create_document(&title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::GetDocument { document_id } => { + let result = api::get_document(&document_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::ReadContent { document_id } => { + let result = api::read_content(&document_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::InsertText { + document_id, + text, + index, + segment_id, + } => { + let result = api::insert_text(&document_id, &text, index, &segment_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::DeleteContent { + document_id, + start_index, + end_index, + segment_id, + } => { + let result = api::delete_content(&document_id, start_index, end_index, &segment_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::ReplaceText { + document_id, + find, + replace, + match_case, + } => { + let result = api::replace_text(&document_id, &find, &replace, match_case)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::FormatText { + document_id, + start_index, + end_index, + bold, + italic, + underline, + strikethrough, + font_size, + font_family, + foreground_color, + background_color, + } => { + let result = api::format_text(api::FormatTextOptions { + document_id: &document_id, + start_index, + end_index, + bold, + italic, + underline, + strikethrough, + font_size, + font_family: font_family.as_deref(), + foreground_color: foreground_color.as_deref(), + background_color: background_color.as_deref(), + })?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::FormatParagraph { + document_id, + start_index, + end_index, + named_style, + alignment, + line_spacing, + } => { + let result = api::format_paragraph( + &document_id, + start_index, + end_index, + named_style.as_deref(), + alignment.as_deref(), + line_spacing, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::InsertTable { + document_id, + rows, + columns, + index, + } => { + let result = api::insert_table(&document_id, rows, columns, index)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::CreateList { + document_id, + start_index, + end_index, + bullet_preset, + } => { + let result = api::create_list(&document_id, start_index, end_index, &bullet_preset)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::BatchUpdate { + document_id, + requests, + } => { + let result = api::batch_update(&document_id, requests)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +export!(GoogleDocsTool); diff --git a/tools-src/google-docs/src/types.rs b/tools-src/google-docs/src/types.rs new file mode 100644 index 00000000..a7fe2963 --- /dev/null +++ b/tools-src/google-docs/src/types.rs @@ -0,0 +1,226 @@ +//! Types for Google Docs API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Google Docs tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleDocsAction { + /// Create a new document. + CreateDocument { + /// Document title. + title: String, + }, + + /// Get document metadata and structure (title, body text, named ranges). + GetDocument { + /// The document ID (same as Google Drive file ID). + document_id: String, + }, + + /// Read the document body as plain text. + ReadContent { + /// The document ID. + document_id: String, + }, + + /// Insert text at a position. + InsertText { + /// The document ID. + document_id: String, + /// Text to insert. + text: String, + /// Character index to insert at (1-based, since 0 is before the body). + /// Use -1 to append at end. + #[serde(default = "default_insert_index")] + index: i64, + /// Segment ID ("" for body, or a header/footer ID). + #[serde(default)] + segment_id: String, + }, + + /// Delete content in a range. + DeleteContent { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Segment ID ("" for body). + #[serde(default)] + segment_id: String, + }, + + /// Find and replace all occurrences of text. + ReplaceText { + /// The document ID. + document_id: String, + /// Text to search for. + find: String, + /// Replacement text. + replace: String, + /// Case-sensitive match (default: true). + #[serde(default = "default_true")] + match_case: bool, + }, + + /// Format text in a range (bold, italic, font size, color, etc.). + FormatText { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Make text bold. + #[serde(default)] + bold: Option, + /// Make text italic. + #[serde(default)] + italic: Option, + /// Underline text. + #[serde(default)] + underline: Option, + /// Strikethrough text. + #[serde(default)] + strikethrough: Option, + /// Font size in points. + #[serde(default)] + font_size: Option, + /// Font family name (e.g., "Arial", "Times New Roman"). + #[serde(default)] + font_family: Option, + /// Text color as hex (e.g., "#FF0000"). + #[serde(default)] + foreground_color: Option, + /// Text background color as hex. + #[serde(default)] + background_color: Option, + }, + + /// Set paragraph style (heading level, alignment, spacing). + FormatParagraph { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Named style: "NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1" through "HEADING_6". + #[serde(default)] + named_style: Option, + /// Alignment: "START", "CENTER", "END", "JUSTIFIED". + #[serde(default)] + alignment: Option, + /// Line spacing as percentage (e.g., 115 for 1.15x). + #[serde(default)] + line_spacing: Option, + }, + + /// Insert a table at a position. + InsertTable { + /// The document ID. + document_id: String, + /// Number of rows. + rows: i64, + /// Number of columns. + columns: i64, + /// Character index to insert at. + index: i64, + }, + + /// Create a bulleted or numbered list from a range of paragraphs. + CreateList { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Bullet preset. Bulleted: "BULLET_DISC_CIRCLE_SQUARE" (default). + /// Numbered: "NUMBERED_DECIMAL_ALPHA_ROMAN". + #[serde(default = "default_bullet_preset")] + bullet_preset: String, + }, + + /// Execute multiple operations in a single atomic batch. + /// Each operation is an object with one key (the request type name) + /// and a value matching the Docs API batchUpdate request format. + BatchUpdate { + /// The document ID. + document_id: String, + /// Array of raw request objects as per Google Docs API. + requests: Vec, + }, +} + +fn default_insert_index() -> i64 { + -1 +} + +fn default_true() -> bool { + true +} + +fn default_bullet_preset() -> String { + "BULLET_DISC_CIRCLE_SQUARE".to_string() +} + +/// Result from create_document. +#[derive(Debug, Serialize)] +pub struct CreateDocumentResult { + pub document_id: String, + pub title: String, +} + +/// Result from get_document. +#[derive(Debug, Serialize)] +pub struct DocumentMetadata { + pub document_id: String, + pub title: String, + pub revision_id: String, + pub body_length: i64, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub named_ranges: Vec, +} + +/// Named range within a document. +#[derive(Debug, Serialize)] +pub struct DocumentNamedRange { + pub name: String, + pub named_range_id: String, + pub start_index: i64, + pub end_index: i64, +} + +/// Result from read_content. +#[derive(Debug, Serialize)] +pub struct ReadContentResult { + pub document_id: String, + pub title: String, + pub content: String, +} + +/// Result from insert_text, delete_content, replace_text. +#[derive(Debug, Serialize)] +pub struct UpdateResult { + pub document_id: String, + pub revision_id: String, +} + +/// Result from replace_text with occurrence count. +#[derive(Debug, Serialize)] +pub struct ReplaceResult { + pub document_id: String, + pub revision_id: String, + pub occurrences_changed: i64, +} + +/// Result from batch_update. +#[derive(Debug, Serialize)] +pub struct BatchUpdateResult { + pub document_id: String, + pub revision_id: String, + pub replies: Vec, +} diff --git a/tools-src/google-drive/Cargo.toml b/tools-src/google-drive/Cargo.toml new file mode 100644 index 00000000..2b07f666 --- /dev/null +++ b/tools-src/google-drive/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "google-drive-tool" +version = "0.1.0" +edition = "2021" +description = "Google Drive integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/google-drive/google-drive-tool.capabilities.json b/tools-src/google-drive/google-drive-tool.capabilities.json new file mode 100644 index 00000000..54c1735c --- /dev/null +++ b/tools-src/google-drive/google-drive-tool.capabilities.json @@ -0,0 +1,50 @@ +{ + "http": { + "allowlist": [ + { + "host": "www.googleapis.com", + "path_prefix": "/drive/v3/", + "methods": ["GET", "POST", "PATCH", "DELETE"] + }, + { + "host": "www.googleapis.com", + "path_prefix": "/upload/drive/v3/", + "methods": ["POST", "PUT"] + } + ], + "credentials": { + "google_oauth_token": { + "secret_name": "google_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["www.googleapis.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 500 + }, + "timeout_secs": 60 + }, + "secrets": { + "allowed_names": ["google_oauth_token"] + }, + "auth": { + "secret_name": "google_oauth_token", + "display_name": "Google", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "client_id_env": "GOOGLE_OAUTH_CLIENT_ID", + "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", + "scopes": [ + "https://www.googleapis.com/auth/drive" + ], + "use_pkce": false, + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "env_var": "GOOGLE_OAUTH_TOKEN" + } +} diff --git a/tools-src/google-drive/src/api.rs b/tools-src/google-drive/src/api.rs new file mode 100644 index 00000000..d8e2d3f3 --- /dev/null +++ b/tools-src/google-drive/src/api.rs @@ -0,0 +1,514 @@ +//! Google Drive API v3 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 OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const DRIVE_API_BASE: &str = "https://www.googleapis.com/drive/v3"; +const UPLOAD_API_BASE: &str = "https://www.googleapis.com/upload/drive/v3"; + +/// Standard fields to request for file metadata. +const FILE_FIELDS: &str = "id,name,mimeType,description,size,createdTime,modifiedTime,\ + webViewLink,parents,shared,starred,trashed,ownedByMe,driveId,\ + owners(emailAddress,displayName)"; + +/// Make a Drive API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = format!("{}/{}", DRIVE_API_BASE, path); + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Drive API: {} {}", method, path), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Drive API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Make a raw API call that returns bytes (for file downloads). +fn api_call_raw(method: &str, url: &str) -> Result, String> { + host::log( + host::LogLevel::Debug, + &format!("Drive API raw: {} {}", method, url), + ); + + let response = host::http_request(method, url, "{}", None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Drive API returned status {}: {}", + response.status, body_text + )); + } + + Ok(response.body) +} + +/// Parse a file resource from the API response. +fn parse_file(v: &serde_json::Value) -> DriveFile { + let mime_type = v["mimeType"].as_str().unwrap_or("").to_string(); + DriveFile { + id: v["id"].as_str().unwrap_or("").to_string(), + name: v["name"].as_str().unwrap_or("").to_string(), + is_folder: mime_type == "application/vnd.google-apps.folder", + mime_type, + description: v["description"].as_str().map(|s| s.to_string()), + size: v["size"].as_str().map(|s| s.to_string()), + created_time: v["createdTime"].as_str().map(|s| s.to_string()), + modified_time: v["modifiedTime"].as_str().map(|s| s.to_string()), + web_view_link: v["webViewLink"].as_str().map(|s| s.to_string()), + parents: v["parents"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|p| p.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + shared: v["shared"].as_bool().unwrap_or(false), + starred: v["starred"].as_bool().unwrap_or(false), + trashed: v["trashed"].as_bool().unwrap_or(false), + owned_by_me: v["ownedByMe"].as_bool().unwrap_or(false), + drive_id: v["driveId"].as_str().map(|s| s.to_string()), + owners: v["owners"] + .as_array() + .map(|arr| { + arr.iter() + .map(|o| Owner { + email: o["emailAddress"].as_str().unwrap_or("").to_string(), + display_name: o["displayName"].as_str().map(|s| s.to_string()), + }) + .collect() + }) + .unwrap_or_default(), + } +} + +/// List/search files. +pub fn list_files( + query: Option<&str>, + page_size: u32, + order_by: Option<&str>, + corpora: &str, + drive_id: Option<&str>, + page_token: Option<&str>, +) -> Result { + let mut params = vec![ + format!("pageSize={}", page_size), + format!("fields=nextPageToken,files({})", FILE_FIELDS), + format!("corpora={}", corpora), + "supportsAllDrives=true".to_string(), + "includeItemsFromAllDrives=true".to_string(), + ]; + + if let Some(q) = query { + params.push(format!("q={}", url_encode(q))); + } + if let Some(ob) = order_by { + params.push(format!("orderBy={}", url_encode(ob))); + } + if let Some(did) = drive_id { + params.push(format!("driveId={}", url_encode(did))); + } + if let Some(pt) = page_token { + params.push(format!("pageToken={}", url_encode(pt))); + } + + let path = format!("files?{}", params.join("&")); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let files = parsed["files"] + .as_array() + .map(|arr| arr.iter().map(parse_file).collect()) + .unwrap_or_default(); + + Ok(ListFilesResult { + files, + next_page_token: parsed["nextPageToken"].as_str().map(|s| s.to_string()), + }) +} + +/// Get file metadata. +pub fn get_file(file_id: &str) -> Result { + let path = format!( + "files/{}?fields={}&supportsAllDrives=true", + url_encode(file_id), + FILE_FIELDS + ); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Download file content as text. +pub fn download_file( + file_id: &str, + export_mime_type: Option<&str>, +) -> Result { + // First get metadata to know the file type and name + let meta = get_file(file_id)?; + let mime = &meta.file.mime_type; + + let bytes = if mime.starts_with("application/vnd.google-apps.") { + // Google Workspace file, must export + let export_type = export_mime_type.unwrap_or(match mime.as_str() { + "application/vnd.google-apps.document" => "text/plain", + "application/vnd.google-apps.spreadsheet" => "text/csv", + "application/vnd.google-apps.presentation" => "text/plain", + "application/vnd.google-apps.drawing" => "image/svg+xml", + _ => "text/plain", + }); + let url = format!( + "{}/files/{}/export?mimeType={}", + DRIVE_API_BASE, + url_encode(file_id), + url_encode(export_type) + ); + api_call_raw("GET", &url)? + } else { + // Regular file, download directly + let url = format!("{}/files/{}?alt=media", DRIVE_API_BASE, url_encode(file_id)); + api_call_raw("GET", &url)? + }; + + let content = String::from_utf8(bytes).map_err(|_| { + "File content is binary, cannot display as text. Use get_file for metadata only." + .to_string() + })?; + + Ok(DownloadResult { + file_id: file_id.to_string(), + name: meta.file.name, + mime_type: meta.file.mime_type, + content, + }) +} + +/// Upload a text file using multipart upload. +pub fn upload_file( + name: &str, + content: &str, + mime_type: &str, + parent_id: Option<&str>, + description: Option<&str>, +) -> Result { + let boundary = "ironclaw_upload_boundary_42"; + + let mut metadata = serde_json::json!({ + "name": name, + "mimeType": mime_type, + }); + if let Some(pid) = parent_id { + metadata["parents"] = serde_json::json!([pid]); + } + if let Some(desc) = description { + metadata["description"] = serde_json::Value::String(desc.to_string()); + } + + let metadata_str = serde_json::to_string(&metadata).map_err(|e| e.to_string())?; + + // Build multipart body + let mut body = String::new(); + body.push_str(&format!("--{}\r\n", boundary)); + body.push_str("Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.push_str(&metadata_str); + body.push_str(&format!("\r\n--{}\r\n", boundary)); + body.push_str(&format!("Content-Type: {}\r\n\r\n", mime_type)); + body.push_str(content); + body.push_str(&format!("\r\n--{}--", boundary)); + + let url = format!( + "{}/files?uploadType=multipart&fields={}&supportsAllDrives=true", + UPLOAD_API_BASE, FILE_FIELDS + ); + let headers = format!( + r#"{{"Content-Type": "multipart/related; boundary={}"}}"#, + boundary + ); + + host::log( + host::LogLevel::Debug, + "Drive API: POST upload/files (multipart)", + ); + + let response = host::http_request("POST", &url, &headers, Some(body.as_bytes()))?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Upload failed with status {}: {}", + response.status, body_text + )); + } + + let parsed: serde_json::Value = serde_json::from_str( + &String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))?, + ) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Update file metadata. +pub fn update_file( + file_id: &str, + name: Option<&str>, + description: Option<&str>, + move_to_parent: Option<&str>, + starred: Option, +) -> Result { + let mut patch = serde_json::json!({}); + + if let Some(n) = name { + patch["name"] = serde_json::Value::String(n.to_string()); + } + if let Some(d) = description { + patch["description"] = serde_json::Value::String(d.to_string()); + } + if let Some(s) = starred { + patch["starred"] = serde_json::Value::Bool(s); + } + + let mut params = vec![ + format!("fields={}", FILE_FIELDS), + "supportsAllDrives=true".to_string(), + ]; + + if let Some(new_parent) = move_to_parent { + // To move, we need to know current parents first + let current = get_file(file_id)?; + let remove_parents = current + .file + .parents + .iter() + .map(|p| p.as_str()) + .collect::>() + .join(","); + params.push(format!("addParents={}", url_encode(new_parent))); + if !remove_parents.is_empty() { + params.push(format!("removeParents={}", url_encode(&remove_parents))); + } + } + + let body = serde_json::to_string(&patch).map_err(|e| e.to_string())?; + let path = format!("files/{}?{}", url_encode(file_id), params.join("&")); + + let response = api_call("PATCH", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Create a folder. +pub fn create_folder( + name: &str, + parent_id: Option<&str>, + description: Option<&str>, +) -> Result { + let mut metadata = serde_json::json!({ + "name": name, + "mimeType": "application/vnd.google-apps.folder", + }); + if let Some(pid) = parent_id { + metadata["parents"] = serde_json::json!([pid]); + } + if let Some(desc) = description { + metadata["description"] = serde_json::Value::String(desc.to_string()); + } + + let body = serde_json::to_string(&metadata).map_err(|e| e.to_string())?; + let path = format!("files?fields={}&supportsAllDrives=true", FILE_FIELDS); + + let response = api_call("POST", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Delete a file permanently. +pub fn delete_file(file_id: &str) -> Result { + let path = format!("files/{}?supportsAllDrives=true", url_encode(file_id)); + api_call("DELETE", &path, None)?; + + Ok(DeleteResult { + file_id: file_id.to_string(), + deleted: true, + }) +} + +/// Move a file to trash. +pub fn trash_file(file_id: &str) -> Result { + let body = r#"{"trashed": true}"#; + let path = format!( + "files/{}?fields={}&supportsAllDrives=true", + url_encode(file_id), + FILE_FIELDS + ); + + api_call("PATCH", &path, Some(body))?; + + Ok(DeleteResult { + file_id: file_id.to_string(), + deleted: true, + }) +} + +/// Share a file with someone. +pub fn share_file( + file_id: &str, + email: &str, + role: &str, + message: Option<&str>, +) -> Result { + let permission = serde_json::json!({ + "type": "user", + "role": role, + "emailAddress": email, + }); + + let body = serde_json::to_string(&permission).map_err(|e| e.to_string())?; + + let mut path = format!( + "files/{}/permissions?supportsAllDrives=true", + url_encode(file_id) + ); + if let Some(msg) = message { + path.push_str(&format!("&emailMessage={}", url_encode(msg))); + } + + let response = api_call("POST", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ShareResult { + permission_id: parsed["id"].as_str().unwrap_or("").to_string(), + role: parsed["role"].as_str().unwrap_or(role).to_string(), + email: email.to_string(), + }) +} + +/// List permissions on a file. +pub fn list_permissions(file_id: &str) -> Result { + let path = format!( + "files/{}/permissions?fields=permissions(id,role,type,emailAddress,displayName)&supportsAllDrives=true", + url_encode(file_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let permissions = parsed["permissions"] + .as_array() + .map(|arr| { + arr.iter() + .map(|p| Permission { + id: p["id"].as_str().unwrap_or("").to_string(), + role: p["role"].as_str().unwrap_or("").to_string(), + permission_type: p["type"].as_str().unwrap_or("").to_string(), + email_address: p["emailAddress"].as_str().map(|s| s.to_string()), + display_name: p["displayName"].as_str().map(|s| s.to_string()), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ListPermissionsResult { permissions }) +} + +/// Remove a sharing permission. +pub fn remove_permission(file_id: &str, permission_id: &str) -> Result { + let path = format!( + "files/{}/permissions/{}?supportsAllDrives=true", + url_encode(file_id), + url_encode(permission_id) + ); + + api_call("DELETE", &path, None)?; + + Ok(DeleteResult { + file_id: file_id.to_string(), + deleted: true, + }) +} + +/// List shared drives. +pub fn list_shared_drives(page_size: u32) -> Result { + let path = format!("drives?pageSize={}", page_size); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let drives = parsed["drives"] + .as_array() + .map(|arr| { + arr.iter() + .map(|d| SharedDrive { + id: d["id"].as_str().unwrap_or("").to_string(), + name: d["name"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ListSharedDrivesResult { drives }) +} + +/// Minimal percent-encoding for URL path segments and query values. +fn url_encode(s: &str) -> String { + let mut encoded = 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'~' => { + encoded.push(b as char); + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[(b >> 4) as usize])); + encoded.push(char::from(HEX[(b & 0x0F) as usize])); + } + } + } + encoded +} + +const HEX: [u8; 16] = *b"0123456789ABCDEF"; diff --git a/tools-src/google-drive/src/lib.rs b/tools-src/google-drive/src/lib.rs new file mode 100644 index 00000000..5672558c --- /dev/null +++ b/tools-src/google-drive/src/lib.rs @@ -0,0 +1,423 @@ +//! Google Drive WASM Tool for IronClaw. +//! +//! Provides Google Drive integration for searching, accessing, uploading, +//! sharing, and organizing files and folders. Supports both personal and +//! shared (organizational) drives. +//! +//! # Capabilities Required +//! +//! - HTTP: `www.googleapis.com/drive/v3/*` and `www.googleapis.com/upload/drive/v3/*` +//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically) +//! +//! # Supported Actions +//! +//! - `list_files`: Search/list files with Drive query syntax and corpora selection +//! - `get_file`: Get file metadata +//! - `download_file`: Download file content as text (exports Google Docs/Sheets) +//! - `upload_file`: Upload a text file (multipart) +//! - `update_file`: Rename, move, star, or update description +//! - `create_folder`: Create a new folder +//! - `delete_file`: Permanently delete a file +//! - `trash_file`: Move to trash +//! - `share_file`: Share with a user (reader, commenter, writer, organizer) +//! - `list_permissions`: See who has access +//! - `remove_permission`: Revoke access +//! - `list_shared_drives`: List organizational shared drives +//! +//! # Example Usage +//! +//! ```json +//! {"action": "list_files", "query": "name contains 'report' and mimeType = 'application/pdf'"} +//! {"action": "list_files", "corpora": "drive", "drive_id": "0ABcd...", "query": "trashed = false"} +//! {"action": "share_file", "file_id": "abc123", "email": "alice@company.com", "role": "writer"} +//! ``` + +mod api; +mod types; + +use types::GoogleDriveAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct GoogleDriveTool; + +impl exports::near::agent::tool::Guest for GoogleDriveTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "list_files" }, + "query": { + "type": "string", + "description": "Drive search query. Examples: \"name contains 'report'\", \"mimeType = 'application/pdf'\", \"'folderId' in parents\", \"sharedWithMe = true\"" + }, + "page_size": { + "type": "integer", + "description": "Max results (default: 25, max: 1000)", + "default": 25 + }, + "order_by": { + "type": "string", + "description": "Sort order (e.g., 'modifiedTime desc', 'name')" + }, + "corpora": { + "type": "string", + "enum": ["user", "drive", "domain", "allDrives"], + "description": "Search scope: 'user' (personal, default), 'drive' (specific shared drive), 'domain' (org-wide), 'allDrives' (everything)", + "default": "user" + }, + "drive_id": { + "type": "string", + "description": "Shared drive ID (required when corpora is 'drive')" + }, + "page_token": { + "type": "string", + "description": "Token for next page of results" + } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_file" }, + "file_id": { + "type": "string", + "description": "The file ID" + } + }, + "required": ["action", "file_id"] + }, + { + "properties": { + "action": { "const": "download_file" }, + "file_id": { + "type": "string", + "description": "The file ID to download" + }, + "export_mime_type": { + "type": "string", + "description": "Export format for Google Workspace files (e.g., 'text/plain', 'text/csv', 'application/pdf')" + } + }, + "required": ["action", "file_id"] + }, + { + "properties": { + "action": { "const": "upload_file" }, + "name": { + "type": "string", + "description": "File name" + }, + "content": { + "type": "string", + "description": "File content (text)" + }, + "mime_type": { + "type": "string", + "description": "MIME type (default: 'text/plain')", + "default": "text/plain" + }, + "parent_id": { + "type": "string", + "description": "Parent folder ID (omit for root)" + }, + "description": { + "type": "string", + "description": "File description" + } + }, + "required": ["action", "name", "content"] + }, + { + "properties": { + "action": { "const": "update_file" }, + "file_id": { + "type": "string", + "description": "The file ID to update" + }, + "name": { + "type": "string", + "description": "New file name" + }, + "description": { + "type": "string", + "description": "New description" + }, + "move_to_parent": { + "type": "string", + "description": "Move file to this folder ID" + }, + "starred": { + "type": "boolean", + "description": "Star or unstar the file" + } + }, + "required": ["action", "file_id"] + }, + { + "properties": { + "action": { "const": "create_folder" }, + "name": { + "type": "string", + "description": "Folder name" + }, + "parent_id": { + "type": "string", + "description": "Parent folder ID (omit for root)" + }, + "description": { + "type": "string", + "description": "Folder description" + } + }, + "required": ["action", "name"] + }, + { + "properties": { + "action": { "const": "delete_file" }, + "file_id": { + "type": "string", + "description": "The file ID to permanently delete" + } + }, + "required": ["action", "file_id"] + }, + { + "properties": { + "action": { "const": "trash_file" }, + "file_id": { + "type": "string", + "description": "The file ID to move to trash" + } + }, + "required": ["action", "file_id"] + }, + { + "properties": { + "action": { "const": "share_file" }, + "file_id": { + "type": "string", + "description": "The file ID to share" + }, + "email": { + "type": "string", + "description": "Recipient email address" + }, + "role": { + "type": "string", + "enum": ["reader", "commenter", "writer", "organizer"], + "description": "Permission level (default: 'reader')", + "default": "reader" + }, + "message": { + "type": "string", + "description": "Optional message in sharing notification" + } + }, + "required": ["action", "file_id", "email"] + }, + { + "properties": { + "action": { "const": "list_permissions" }, + "file_id": { + "type": "string", + "description": "The file ID to check permissions for" + } + }, + "required": ["action", "file_id"] + }, + { + "properties": { + "action": { "const": "remove_permission" }, + "file_id": { + "type": "string", + "description": "The file ID" + }, + "permission_id": { + "type": "string", + "description": "The permission ID to remove (get from list_permissions)" + } + }, + "required": ["action", "file_id", "permission_id"] + }, + { + "properties": { + "action": { "const": "list_shared_drives" }, + "page_size": { + "type": "integer", + "description": "Max results (default: 25)", + "default": 25 + } + }, + "required": ["action"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Google Drive integration for searching, accessing, uploading, sharing, and organizing \ + files and folders. Supports personal drives and shared (organizational) drives via the \ + corpora parameter. Can search with Drive query syntax, download text files, upload new \ + files, manage folder structure, and control sharing permissions. Requires a Google OAuth \ + token with the drive scope." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("google_oauth_token") { + return Err( + "Google OAuth token not configured. Run `ironclaw tool auth google-drive` to set up \ + OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: GoogleDriveAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Google Drive action: {:?}", action), + ); + + let result = match action { + GoogleDriveAction::ListFiles { + query, + page_size, + order_by, + corpora, + drive_id, + page_token, + } => { + let result = api::list_files( + query.as_deref(), + page_size, + order_by.as_deref(), + &corpora, + drive_id.as_deref(), + page_token.as_deref(), + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::GetFile { file_id } => { + let result = api::get_file(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::DownloadFile { + file_id, + export_mime_type, + } => { + let result = api::download_file(&file_id, export_mime_type.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::UploadFile { + name, + content, + mime_type, + parent_id, + description, + } => { + let result = api::upload_file( + &name, + &content, + &mime_type, + parent_id.as_deref(), + description.as_deref(), + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::UpdateFile { + file_id, + name, + description, + move_to_parent, + starred, + } => { + let result = api::update_file( + &file_id, + name.as_deref(), + description.as_deref(), + move_to_parent.as_deref(), + starred, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::CreateFolder { + name, + parent_id, + description, + } => { + let result = api::create_folder(&name, parent_id.as_deref(), description.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::DeleteFile { file_id } => { + let result = api::delete_file(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::TrashFile { file_id } => { + let result = api::trash_file(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::ShareFile { + file_id, + email, + role, + message, + } => { + let result = api::share_file(&file_id, &email, &role, message.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::ListPermissions { file_id } => { + let result = api::list_permissions(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::RemovePermission { + file_id, + permission_id, + } => { + let result = api::remove_permission(&file_id, &permission_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::ListSharedDrives { page_size } => { + let result = api::list_shared_drives(page_size)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +export!(GoogleDriveTool); diff --git a/tools-src/google-drive/src/types.rs b/tools-src/google-drive/src/types.rs new file mode 100644 index 00000000..ebbe26e0 --- /dev/null +++ b/tools-src/google-drive/src/types.rs @@ -0,0 +1,269 @@ +//! Types for Google Drive API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Google Drive tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleDriveAction { + /// Search/list files and folders. + ListFiles { + /// Drive search query (same syntax as Drive search). + /// Examples: "name contains 'report'", "mimeType = 'application/pdf'", + /// "'folderId' in parents", "sharedWithMe = true". + #[serde(default)] + query: Option, + /// Maximum number of results (default: 25, max: 1000). + #[serde(default = "default_page_size")] + page_size: u32, + /// Sort order (e.g., "modifiedTime desc", "name"). + #[serde(default)] + order_by: Option, + /// Search corpus: "user" (personal, default), "drive" (specific shared drive), + /// "domain" (org-wide), "allDrives" (everything accessible). + #[serde(default = "default_corpora")] + corpora: String, + /// Shared drive ID (required when corpora is "drive"). + #[serde(default)] + drive_id: Option, + /// Page token for pagination. + #[serde(default)] + page_token: Option, + }, + + /// Get file metadata. + GetFile { + /// The file ID. + file_id: String, + }, + + /// Download file content as text. + /// Only works for text-based files. For Google Docs/Sheets/Slides, + /// exports as plain text / CSV / plain text respectively. + DownloadFile { + /// The file ID. + file_id: String, + /// Export MIME type for Google Workspace files. + /// Defaults: Docs -> "text/plain", Sheets -> "text/csv", + /// Slides -> "text/plain", Drawings -> "image/svg+xml". + #[serde(default)] + export_mime_type: Option, + }, + + /// Upload a new file (text content). + UploadFile { + /// File name. + name: String, + /// File content (text). + content: String, + /// MIME type (default: "text/plain"). + #[serde(default = "default_mime_type")] + mime_type: String, + /// Parent folder ID. Omit for root. + #[serde(default)] + parent_id: Option, + /// File description. + #[serde(default)] + description: Option, + }, + + /// Update file metadata (rename, move, change description). + UpdateFile { + /// The file ID. + file_id: String, + /// New file name. + #[serde(default)] + name: Option, + /// New description. + #[serde(default)] + description: Option, + /// Move to this parent folder (removes from current parents). + #[serde(default)] + move_to_parent: Option, + /// Star or unstar the file. + #[serde(default)] + starred: Option, + }, + + /// Create a folder. + CreateFolder { + /// Folder name. + name: String, + /// Parent folder ID. Omit for root. + #[serde(default)] + parent_id: Option, + /// Folder description. + #[serde(default)] + description: Option, + }, + + /// Delete a file or folder (permanent). + DeleteFile { + /// The file ID to delete. + file_id: String, + }, + + /// Move a file to trash. + TrashFile { + /// The file ID to trash. + file_id: String, + }, + + /// Share a file or folder with someone. + ShareFile { + /// The file ID to share. + file_id: String, + /// Recipient email address. + email: String, + /// Permission role: "reader", "commenter", "writer", "organizer". + #[serde(default = "default_role")] + role: String, + /// Optional message to include in the sharing notification. + #[serde(default)] + message: Option, + }, + + /// List who a file is shared with. + ListPermissions { + /// The file ID. + file_id: String, + }, + + /// Remove sharing (revoke a permission). + RemovePermission { + /// The file ID. + file_id: String, + /// The permission ID to remove. + permission_id: String, + }, + + /// List shared drives the user has access to. + ListSharedDrives { + /// Maximum results (default: 25). + #[serde(default = "default_page_size")] + page_size: u32, + }, +} + +fn default_page_size() -> u32 { + 25 +} + +fn default_corpora() -> String { + "user".to_string() +} + +fn default_mime_type() -> String { + "text/plain".to_string() +} + +fn default_role() -> String { + "reader".to_string() +} + +/// A Google Drive file or folder. +#[derive(Debug, Serialize)] +pub struct DriveFile { + pub id: String, + pub name: String, + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_view_link: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub parents: Vec, + pub shared: bool, + pub starred: bool, + pub trashed: bool, + pub owned_by_me: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub drive_id: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub owners: Vec, + pub is_folder: bool, +} + +/// File owner info. +#[derive(Debug, Serialize)] +pub struct Owner { + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +/// A sharing permission. +#[derive(Debug, Serialize)] +pub struct Permission { + pub id: String, + pub role: String, + #[serde(rename = "type")] + pub permission_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +/// A shared drive. +#[derive(Debug, Serialize)] +pub struct SharedDrive { + pub id: String, + pub name: String, +} + +/// Result from list_files. +#[derive(Debug, Serialize)] +pub struct ListFilesResult { + pub files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +/// Result from get_file or upload/update. +#[derive(Debug, Serialize)] +pub struct FileResult { + pub file: DriveFile, +} + +/// Result from download_file. +#[derive(Debug, Serialize)] +pub struct DownloadResult { + pub file_id: String, + pub name: String, + pub mime_type: String, + pub content: String, +} + +/// Result from delete/trash. +#[derive(Debug, Serialize)] +pub struct DeleteResult { + pub file_id: String, + pub deleted: bool, +} + +/// Result from share_file. +#[derive(Debug, Serialize)] +pub struct ShareResult { + pub permission_id: String, + pub role: String, + pub email: String, +} + +/// Result from list_permissions. +#[derive(Debug, Serialize)] +pub struct ListPermissionsResult { + pub permissions: Vec, +} + +/// Result from list_shared_drives. +#[derive(Debug, Serialize)] +pub struct ListSharedDrivesResult { + pub drives: Vec, +} diff --git a/tools-src/google-sheets/Cargo.toml b/tools-src/google-sheets/Cargo.toml new file mode 100644 index 00000000..39a52e18 --- /dev/null +++ b/tools-src/google-sheets/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "google-sheets-tool" +version = "0.1.0" +edition = "2021" +description = "Google Sheets integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/google-sheets/google-sheets-tool.capabilities.json b/tools-src/google-sheets/google-sheets-tool.capabilities.json new file mode 100644 index 00000000..0e64fb1e --- /dev/null +++ b/tools-src/google-sheets/google-sheets-tool.capabilities.json @@ -0,0 +1,45 @@ +{ + "http": { + "allowlist": [ + { + "host": "sheets.googleapis.com", + "path_prefix": "/v4/spreadsheets", + "methods": ["GET", "POST", "PUT"] + } + ], + "credentials": { + "google_oauth_token": { + "secret_name": "google_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["sheets.googleapis.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 500 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["google_oauth_token"] + }, + "auth": { + "secret_name": "google_oauth_token", + "display_name": "Google", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "client_id_env": "GOOGLE_OAUTH_CLIENT_ID", + "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "use_pkce": false, + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "env_var": "GOOGLE_OAUTH_TOKEN" + } +} diff --git a/tools-src/google-sheets/src/api.rs b/tools-src/google-sheets/src/api.rs new file mode 100644 index 00000000..934388b2 --- /dev/null +++ b/tools-src/google-sheets/src/api.rs @@ -0,0 +1,524 @@ +//! Google Sheets API v4 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 OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const SHEETS_API_BASE: &str = "https://sheets.googleapis.com/v4/spreadsheets"; + +/// Make a Google Sheets API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = if path.is_empty() { + SHEETS_API_BASE.to_string() + } else { + format!("{}/{}", SHEETS_API_BASE, path) + }; + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Sheets API: {} {}", method, url), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Sheets API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Parse sheet info from the API's JSON. +fn parse_sheet_info(v: &serde_json::Value) -> SheetInfo { + let props = &v["properties"]; + let grid = &props["gridProperties"]; + SheetInfo { + sheet_id: props["sheetId"].as_i64().unwrap_or(0), + title: props["title"].as_str().unwrap_or("").to_string(), + index: props["index"].as_i64().unwrap_or(0), + row_count: grid["rowCount"].as_i64().unwrap_or(0), + column_count: grid["columnCount"].as_i64().unwrap_or(0), + } +} + +/// Parse a named range from the API's JSON. +fn parse_named_range(v: &serde_json::Value) -> NamedRange { + let range = &v["range"]; + let range_str = format_grid_range(range); + NamedRange { + named_range_id: v["namedRangeId"].as_str().unwrap_or("").to_string(), + name: v["name"].as_str().unwrap_or("").to_string(), + range: range_str, + } +} + +/// Format a GridRange into a human-readable string. +fn format_grid_range(v: &serde_json::Value) -> String { + let sheet_id = v["sheetId"].as_i64().unwrap_or(0); + let start_row = v["startRowIndex"].as_i64().unwrap_or(0); + let end_row = v["endRowIndex"].as_i64().unwrap_or(0); + let start_col = v["startColumnIndex"].as_i64().unwrap_or(0); + let end_col = v["endColumnIndex"].as_i64().unwrap_or(0); + format!( + "sheetId={}, rows {}:{}, cols {}:{}", + sheet_id, start_row, end_row, start_col, end_col + ) +} + +/// Create a new spreadsheet. +pub fn create_spreadsheet( + title: &str, + sheet_names: &[String], +) -> Result { + let sheets: Vec = if sheet_names.is_empty() { + vec![serde_json::json!({"properties": {"title": "Sheet1"}})] + } else { + sheet_names + .iter() + .map(|name| serde_json::json!({"properties": {"title": name}})) + .collect() + }; + + let body = serde_json::json!({ + "properties": {"title": title}, + "sheets": sheets, + }); + + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + let response = api_call("POST", "", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(CreateSpreadsheetResult { + spreadsheet_id: parsed["spreadsheetId"].as_str().unwrap_or("").to_string(), + title: parsed["properties"]["title"] + .as_str() + .unwrap_or("") + .to_string(), + url: parsed["spreadsheetUrl"].as_str().unwrap_or("").to_string(), + sheets: parsed["sheets"] + .as_array() + .map(|arr| arr.iter().map(parse_sheet_info).collect()) + .unwrap_or_default(), + }) +} + +/// Get spreadsheet metadata. +pub fn get_spreadsheet(spreadsheet_id: &str) -> Result { + let path = format!( + "{}?fields=spreadsheetId,properties.title,spreadsheetUrl,sheets.properties,namedRanges", + url_encode(spreadsheet_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(SpreadsheetMetadata { + spreadsheet_id: parsed["spreadsheetId"].as_str().unwrap_or("").to_string(), + title: parsed["properties"]["title"] + .as_str() + .unwrap_or("") + .to_string(), + url: parsed["spreadsheetUrl"].as_str().unwrap_or("").to_string(), + sheets: parsed["sheets"] + .as_array() + .map(|arr| arr.iter().map(parse_sheet_info).collect()) + .unwrap_or_default(), + named_ranges: parsed["namedRanges"] + .as_array() + .map(|arr| arr.iter().map(parse_named_range).collect()) + .unwrap_or_default(), + }) +} + +/// Read values from a single range. +pub fn read_values(spreadsheet_id: &str, range: &str) -> Result { + let path = format!( + "{}/values/{}", + url_encode(spreadsheet_id), + url_encode(range) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ValuesResult { + range: parsed["range"].as_str().unwrap_or("").to_string(), + values: parsed["values"] + .as_array() + .map(|rows| { + rows.iter() + .map(|row| row.as_array().map(|cols| cols.to_vec()).unwrap_or_default()) + .collect() + }) + .unwrap_or_default(), + }) +} + +/// Read values from multiple ranges at once. +pub fn batch_read_values( + spreadsheet_id: &str, + ranges: &[String], +) -> Result { + let range_params: Vec = ranges + .iter() + .map(|r| format!("ranges={}", url_encode(r))) + .collect(); + + let path = format!( + "{}/values:batchGet?{}", + url_encode(spreadsheet_id), + range_params.join("&") + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let value_ranges = parsed["valueRanges"] + .as_array() + .map(|arr| { + arr.iter() + .map(|vr| ValuesResult { + range: vr["range"].as_str().unwrap_or("").to_string(), + values: vr["values"] + .as_array() + .map(|rows| { + rows.iter() + .map(|row| { + row.as_array().map(|cols| cols.to_vec()).unwrap_or_default() + }) + .collect() + }) + .unwrap_or_default(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(BatchValuesResult { value_ranges }) +} + +/// Write values to a range. +pub fn write_values( + spreadsheet_id: &str, + range: &str, + values: &[Vec], + value_input_option: &str, +) -> Result { + let path = format!( + "{}/values/{}?valueInputOption={}", + url_encode(spreadsheet_id), + url_encode(range), + url_encode(value_input_option) + ); + + let body = serde_json::json!({ + "range": range, + "majorDimension": "ROWS", + "values": values, + }); + + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + let response = api_call("PUT", &path, Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(UpdateResult { + updated_range: parsed["updatedRange"].as_str().unwrap_or("").to_string(), + updated_rows: parsed["updatedRows"].as_i64().unwrap_or(0), + updated_columns: parsed["updatedColumns"].as_i64().unwrap_or(0), + updated_cells: parsed["updatedCells"].as_i64().unwrap_or(0), + }) +} + +/// Append rows after existing data. +pub fn append_values( + spreadsheet_id: &str, + range: &str, + values: &[Vec], + value_input_option: &str, +) -> Result { + let path = format!( + "{}/values/{}:append?valueInputOption={}&insertDataOption=INSERT_ROWS", + url_encode(spreadsheet_id), + url_encode(range), + url_encode(value_input_option) + ); + + let body = serde_json::json!({ + "range": range, + "majorDimension": "ROWS", + "values": values, + }); + + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + let response = api_call("POST", &path, Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let updates = &parsed["updates"]; + Ok(UpdateResult { + updated_range: updates["updatedRange"].as_str().unwrap_or("").to_string(), + updated_rows: updates["updatedRows"].as_i64().unwrap_or(0), + updated_columns: updates["updatedColumns"].as_i64().unwrap_or(0), + updated_cells: updates["updatedCells"].as_i64().unwrap_or(0), + }) +} + +/// Clear values from a range. +pub fn clear_values(spreadsheet_id: &str, range: &str) -> Result { + let path = format!( + "{}/values/{}:clear", + url_encode(spreadsheet_id), + url_encode(range) + ); + + let response = api_call("POST", &path, Some("{}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ClearResult { + cleared_range: parsed["clearedRange"].as_str().unwrap_or("").to_string(), + }) +} + +/// Send a batchUpdate request to the spreadsheet. +fn batch_update( + spreadsheet_id: &str, + requests: Vec, +) -> Result { + let path = format!("{}:batchUpdate", url_encode(spreadsheet_id)); + + let body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", &path, Some(&body_str))?; + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// Add a new sheet (tab) to the spreadsheet. +pub fn add_sheet(spreadsheet_id: &str, title: &str) -> Result { + let requests = vec![serde_json::json!({ + "addSheet": { + "properties": { + "title": title + } + } + })]; + + let parsed = batch_update(spreadsheet_id, requests)?; + + let reply = &parsed["replies"][0]["addSheet"]["properties"]; + Ok(AddSheetResult { + sheet: SheetInfo { + sheet_id: reply["sheetId"].as_i64().unwrap_or(0), + title: reply["title"].as_str().unwrap_or("").to_string(), + index: reply["index"].as_i64().unwrap_or(0), + row_count: reply["gridProperties"]["rowCount"].as_i64().unwrap_or(1000), + column_count: reply["gridProperties"]["columnCount"] + .as_i64() + .unwrap_or(26), + }, + }) +} + +/// Delete a sheet (tab) from the spreadsheet. +pub fn delete_sheet(spreadsheet_id: &str, sheet_id: i64) -> Result { + let requests = vec![serde_json::json!({ + "deleteSheet": { + "sheetId": sheet_id + } + })]; + + batch_update(spreadsheet_id, requests)?; + + Ok(SheetOperationResult { + spreadsheet_id: spreadsheet_id.to_string(), + success: true, + }) +} + +/// Rename a sheet (tab). +pub fn rename_sheet( + spreadsheet_id: &str, + sheet_id: i64, + title: &str, +) -> Result { + let requests = vec![serde_json::json!({ + "updateSheetProperties": { + "properties": { + "sheetId": sheet_id, + "title": title + }, + "fields": "title" + } + })]; + + batch_update(spreadsheet_id, requests)?; + + Ok(SheetOperationResult { + spreadsheet_id: spreadsheet_id.to_string(), + success: true, + }) +} + +/// Parse a hex color like "#FF0000" into Sheets API color (0.0-1.0 floats). +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(serde_json::json!({ + "red": r as f64 / 255.0, + "green": g as f64 / 255.0, + "blue": b as f64 / 255.0, + })) +} + +/// Parameters for cell formatting. +pub struct FormatOptions<'a> { + pub spreadsheet_id: &'a str, + pub sheet_id: i64, + pub start_row: i64, + pub end_row: i64, + pub start_column: i64, + pub end_column: i64, + pub bold: Option, + pub italic: Option, + pub font_size: Option, + pub text_color: Option<&'a str>, + pub background_color: Option<&'a str>, + pub horizontal_alignment: Option<&'a str>, + pub number_format: Option<&'a str>, + pub number_format_type: Option<&'a str>, +} + +/// Format cells in a range. +pub fn format_cells(opts: FormatOptions<'_>) -> Result { + let mut format = serde_json::json!({}); + let mut fields = Vec::new(); + + // Text format + let mut text_format = serde_json::json!({}); + let mut has_text_format = false; + + if let Some(b) = opts.bold { + text_format["bold"] = serde_json::Value::Bool(b); + has_text_format = true; + } + if let Some(i) = opts.italic { + text_format["italic"] = serde_json::Value::Bool(i); + has_text_format = true; + } + if let Some(size) = opts.font_size { + text_format["fontSize"] = serde_json::json!(size); + has_text_format = true; + } + if let Some(color) = opts.text_color { + if let Some(c) = parse_hex_color(color) { + text_format["foregroundColor"] = c; + has_text_format = true; + } + } + + if has_text_format { + format["textFormat"] = text_format; + fields.push("userEnteredFormat.textFormat"); + } + + // Background color + if let Some(color) = opts.background_color { + if let Some(c) = parse_hex_color(color) { + format["backgroundColor"] = c; + fields.push("userEnteredFormat.backgroundColor"); + } + } + + // Horizontal alignment + if let Some(align) = opts.horizontal_alignment { + format["horizontalAlignment"] = serde_json::Value::String(align.to_string()); + fields.push("userEnteredFormat.horizontalAlignment"); + } + + // Number format + if let Some(pattern) = opts.number_format { + let fmt_type = opts.number_format_type.unwrap_or("NUMBER"); + format["numberFormat"] = serde_json::json!({ + "type": fmt_type, + "pattern": pattern, + }); + fields.push("userEnteredFormat.numberFormat"); + } + + if fields.is_empty() { + return Err("No formatting options specified".to_string()); + } + + let requests = vec![serde_json::json!({ + "repeatCell": { + "range": { + "sheetId": opts.sheet_id, + "startRowIndex": opts.start_row, + "endRowIndex": opts.end_row, + "startColumnIndex": opts.start_column, + "endColumnIndex": opts.end_column, + }, + "cell": { + "userEnteredFormat": format, + }, + "fields": fields.join(","), + } + })]; + + batch_update(opts.spreadsheet_id, requests)?; + + Ok(FormatResult { + spreadsheet_id: opts.spreadsheet_id.to_string(), + success: true, + }) +} + +/// Minimal percent-encoding for URL path segments and query values. +fn url_encode(s: &str) -> String { + let mut encoded = 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'~' => { + encoded.push(b as char); + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[(b >> 4) as usize])); + encoded.push(char::from(HEX[(b & 0x0F) as usize])); + } + } + } + encoded +} + +const HEX: [u8; 16] = *b"0123456789ABCDEF"; diff --git a/tools-src/google-sheets/src/lib.rs b/tools-src/google-sheets/src/lib.rs new file mode 100644 index 00000000..c092212e --- /dev/null +++ b/tools-src/google-sheets/src/lib.rs @@ -0,0 +1,454 @@ +//! Google Sheets WASM Tool for IronClaw. +//! +//! Provides Google Sheets integration for creating, reading, writing, +//! and formatting spreadsheets. Use Google Drive tool to search for +//! existing spreadsheets by name. +//! +//! # Capabilities Required +//! +//! - HTTP: `sheets.googleapis.com/v4/spreadsheets*` +//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically) +//! +//! # Supported Actions +//! +//! - `create_spreadsheet`: Create a new spreadsheet with optional sheet names +//! - `get_spreadsheet`: Get metadata (title, sheets, named ranges) +//! - `read_values`: Read cell values from a range (A1 notation) +//! - `batch_read_values`: Read from multiple ranges at once +//! - `write_values`: Write values to a range (overwrites) +//! - `append_values`: Append rows after existing data +//! - `clear_values`: Clear values from a range (keeps formatting) +//! - `add_sheet`: Add a new sheet (tab) +//! - `delete_sheet`: Delete a sheet (tab) +//! - `rename_sheet`: Rename a sheet (tab) +//! - `format_cells`: Format cells (bold, colors, alignment, number format) +//! +//! # Tips +//! +//! - Spreadsheet IDs are the same as Google Drive file IDs. Use google-drive +//! tool's list_files to find spreadsheets. +//! - Use A1 notation for ranges: "Sheet1!A1:D10", "A1:B5", "Sheet1!A:E" +//! - Sheet IDs (numeric) are different from sheet names. Get them via get_spreadsheet. +//! +//! # Example Usage +//! +//! ```json +//! {"action": "create_spreadsheet", "title": "Q1 Report", "sheet_names": ["Revenue", "Expenses"]} +//! {"action": "read_values", "spreadsheet_id": "abc123", "range": "Sheet1!A1:D10"} +//! {"action": "write_values", "spreadsheet_id": "abc123", "range": "Sheet1!A1", "values": [["Name", "Age"], ["Alice", 30]]} +//! {"action": "append_values", "spreadsheet_id": "abc123", "range": "Sheet1!A:B", "values": [["Bob", 25]]} +//! {"action": "format_cells", "spreadsheet_id": "abc123", "sheet_id": 0, "start_row": 0, "end_row": 1, "start_column": 0, "end_column": 4, "bold": true, "background_color": "#4285F4", "text_color": "#FFFFFF"} +//! ``` + +mod api; +mod types; + +use types::GoogleSheetsAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct GoogleSheetsTool; + +impl exports::near::agent::tool::Guest for GoogleSheetsTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "create_spreadsheet" }, + "title": { + "type": "string", + "description": "Spreadsheet title" + }, + "sheet_names": { + "type": "array", + "items": { "type": "string" }, + "description": "Names for sheets (tabs). Defaults to ['Sheet1'] if omitted." + } + }, + "required": ["action", "title"] + }, + { + "properties": { + "action": { "const": "get_spreadsheet" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID (same as Google Drive file ID)" + } + }, + "required": ["action", "spreadsheet_id"] + }, + { + "properties": { + "action": { "const": "read_values" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range (e.g., 'Sheet1!A1:D10', 'A1:B5')" + } + }, + "required": ["action", "spreadsheet_id", "range"] + }, + { + "properties": { + "action": { "const": "batch_read_values" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "ranges": { + "type": "array", + "items": { "type": "string" }, + "description": "List of A1 notation ranges to read" + } + }, + "required": ["action", "spreadsheet_id", "ranges"] + }, + { + "properties": { + "action": { "const": "write_values" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range (e.g., 'Sheet1!A1')" + }, + "values": { + "type": "array", + "items": { "type": "array" }, + "description": "2D array of values (rows of columns)" + }, + "value_input_option": { + "type": "string", + "enum": ["RAW", "USER_ENTERED"], + "description": "How to interpret input. USER_ENTERED (default) parses like typing in the UI. RAW stores as-is.", + "default": "USER_ENTERED" + } + }, + "required": ["action", "spreadsheet_id", "range", "values"] + }, + { + "properties": { + "action": { "const": "append_values" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range to find the table (e.g., 'Sheet1!A:E')" + }, + "values": { + "type": "array", + "items": { "type": "array" }, + "description": "Rows to append (2D array)" + }, + "value_input_option": { + "type": "string", + "enum": ["RAW", "USER_ENTERED"], + "description": "How to interpret input (default: USER_ENTERED)", + "default": "USER_ENTERED" + } + }, + "required": ["action", "spreadsheet_id", "range", "values"] + }, + { + "properties": { + "action": { "const": "clear_values" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range to clear" + } + }, + "required": ["action", "spreadsheet_id", "range"] + }, + { + "properties": { + "action": { "const": "add_sheet" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "title": { + "type": "string", + "description": "Name for the new sheet (tab)" + } + }, + "required": ["action", "spreadsheet_id", "title"] + }, + { + "properties": { + "action": { "const": "delete_sheet" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "sheet_id": { + "type": "integer", + "description": "Numeric sheet ID (get from get_spreadsheet, NOT the sheet name)" + } + }, + "required": ["action", "spreadsheet_id", "sheet_id"] + }, + { + "properties": { + "action": { "const": "rename_sheet" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "sheet_id": { + "type": "integer", + "description": "Numeric sheet ID" + }, + "title": { + "type": "string", + "description": "New name for the sheet" + } + }, + "required": ["action", "spreadsheet_id", "sheet_id", "title"] + }, + { + "properties": { + "action": { "const": "format_cells" }, + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID" + }, + "sheet_id": { + "type": "integer", + "description": "Numeric sheet ID" + }, + "start_row": { + "type": "integer", + "description": "Start row (0-indexed, inclusive)" + }, + "end_row": { + "type": "integer", + "description": "End row (0-indexed, exclusive)" + }, + "start_column": { + "type": "integer", + "description": "Start column (0-indexed, inclusive)" + }, + "end_column": { + "type": "integer", + "description": "End column (0-indexed, exclusive)" + }, + "bold": { + "type": "boolean", + "description": "Make text bold" + }, + "italic": { + "type": "boolean", + "description": "Make text italic" + }, + "font_size": { + "type": "integer", + "description": "Font size in points" + }, + "text_color": { + "type": "string", + "description": "Text color as hex (e.g., '#FF0000' for red)" + }, + "background_color": { + "type": "string", + "description": "Cell background color as hex (e.g., '#FFFF00' for yellow)" + }, + "horizontal_alignment": { + "type": "string", + "enum": ["LEFT", "CENTER", "RIGHT"], + "description": "Horizontal text alignment" + }, + "number_format": { + "type": "string", + "description": "Number format pattern (e.g., '#,##0.00', 'yyyy-mm-dd', '$#,##0')" + }, + "number_format_type": { + "type": "string", + "enum": ["NUMBER", "CURRENCY", "PERCENT", "DATE", "TIME", "TEXT"], + "description": "Type of number format (default: NUMBER)" + } + }, + "required": ["action", "spreadsheet_id", "sheet_id", "start_row", "end_row", "start_column", "end_column"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Google Sheets integration for creating, reading, writing, and formatting spreadsheets. \ + Supports cell value operations (read, write, append, clear) using A1 notation, sheet \ + (tab) management (add, delete, rename), and cell formatting (bold, colors, alignment, \ + number formats). Spreadsheet IDs are the same as Google Drive file IDs, so use the \ + google-drive tool to search for existing spreadsheets. Requires a Google OAuth token \ + with the spreadsheets scope." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("google_oauth_token") { + return Err( + "Google OAuth token not configured. Run `ironclaw tool auth google-sheets` to set up \ + OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: GoogleSheetsAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Google Sheets action: {:?}", action), + ); + + let result = match action { + GoogleSheetsAction::CreateSpreadsheet { title, sheet_names } => { + let result = api::create_spreadsheet(&title, &sheet_names)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::GetSpreadsheet { spreadsheet_id } => { + let result = api::get_spreadsheet(&spreadsheet_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::ReadValues { + spreadsheet_id, + range, + } => { + let result = api::read_values(&spreadsheet_id, &range)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::BatchReadValues { + spreadsheet_id, + ranges, + } => { + let result = api::batch_read_values(&spreadsheet_id, &ranges)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::WriteValues { + spreadsheet_id, + range, + values, + value_input_option, + } => { + let result = api::write_values(&spreadsheet_id, &range, &values, &value_input_option)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::AppendValues { + spreadsheet_id, + range, + values, + value_input_option, + } => { + let result = api::append_values(&spreadsheet_id, &range, &values, &value_input_option)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::ClearValues { + spreadsheet_id, + range, + } => { + let result = api::clear_values(&spreadsheet_id, &range)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::AddSheet { + spreadsheet_id, + title, + } => { + let result = api::add_sheet(&spreadsheet_id, &title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::DeleteSheet { + spreadsheet_id, + sheet_id, + } => { + let result = api::delete_sheet(&spreadsheet_id, sheet_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::RenameSheet { + spreadsheet_id, + sheet_id, + title, + } => { + let result = api::rename_sheet(&spreadsheet_id, sheet_id, &title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::FormatCells { + spreadsheet_id, + sheet_id, + start_row, + end_row, + start_column, + end_column, + bold, + italic, + font_size, + text_color, + background_color, + horizontal_alignment, + number_format, + number_format_type, + } => { + let result = api::format_cells(api::FormatOptions { + spreadsheet_id: &spreadsheet_id, + sheet_id, + start_row, + end_row, + start_column, + end_column, + bold, + italic, + font_size, + text_color: text_color.as_deref(), + background_color: background_color.as_deref(), + horizontal_alignment: horizontal_alignment.as_deref(), + number_format: number_format.as_deref(), + number_format_type: number_format_type.as_deref(), + })?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +export!(GoogleSheetsTool); diff --git a/tools-src/google-sheets/src/types.rs b/tools-src/google-sheets/src/types.rs new file mode 100644 index 00000000..96ed5f82 --- /dev/null +++ b/tools-src/google-sheets/src/types.rs @@ -0,0 +1,229 @@ +//! Types for Google Sheets API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Google Sheets tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleSheetsAction { + /// Create a new spreadsheet. + CreateSpreadsheet { + /// Spreadsheet title. + title: String, + /// Names of sheets (tabs) to create. Defaults to one sheet named "Sheet1". + #[serde(default)] + sheet_names: Vec, + }, + + /// Get spreadsheet metadata (title, sheets, named ranges). + GetSpreadsheet { + /// The spreadsheet ID (same as Google Drive file ID). + spreadsheet_id: String, + }, + + /// Read cell values from a range. + ReadValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range (e.g., "Sheet1!A1:D10", "A1:B5"). + range: String, + }, + + /// Read values from multiple ranges at once. + BatchReadValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// List of A1 notation ranges. + ranges: Vec, + }, + + /// Write values to a range (overwrites existing data). + WriteValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range (e.g., "Sheet1!A1:D10"). + range: String, + /// 2D array of values (rows of columns). + values: Vec>, + /// How to interpret input: "RAW" or "USER_ENTERED" (default). + #[serde(default = "default_value_input_option")] + value_input_option: String, + }, + + /// Append rows after existing data in a range. + AppendValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range to search for a table (e.g., "Sheet1!A:E"). + range: String, + /// Rows to append (2D array). + values: Vec>, + /// How to interpret input: "RAW" or "USER_ENTERED" (default). + #[serde(default = "default_value_input_option")] + value_input_option: String, + }, + + /// Clear values from a range (keeps formatting). + ClearValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range to clear. + range: String, + }, + + /// Add a new sheet (tab) to the spreadsheet. + AddSheet { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Name for the new sheet. + title: String, + }, + + /// Delete a sheet (tab) from the spreadsheet. + DeleteSheet { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Numeric sheet ID (from get_spreadsheet, NOT the sheet name). + sheet_id: i64, + }, + + /// Rename a sheet (tab). + RenameSheet { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Numeric sheet ID. + sheet_id: i64, + /// New name for the sheet. + title: String, + }, + + /// Format cells in a range (bold, colors, number format, borders, alignment). + FormatCells { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Numeric sheet ID. + sheet_id: i64, + /// Start row (0-indexed, inclusive). + start_row: i64, + /// End row (0-indexed, exclusive). + end_row: i64, + /// Start column (0-indexed, inclusive). + start_column: i64, + /// End column (0-indexed, exclusive). + end_column: i64, + /// Bold text. + #[serde(default)] + bold: Option, + /// Italic text. + #[serde(default)] + italic: Option, + /// Font size. + #[serde(default)] + font_size: Option, + /// Text color as hex (e.g., "#FF0000"). + #[serde(default)] + text_color: Option, + /// Background color as hex (e.g., "#FFFF00"). + #[serde(default)] + background_color: Option, + /// Horizontal alignment: "LEFT", "CENTER", "RIGHT". + #[serde(default)] + horizontal_alignment: Option, + /// Number format pattern (e.g., "#,##0.00", "yyyy-mm-dd"). + #[serde(default)] + number_format: Option, + /// Number format type: "NUMBER", "CURRENCY", "PERCENT", "DATE", "TIME", "TEXT". + #[serde(default)] + number_format_type: Option, + }, +} + +fn default_value_input_option() -> String { + "USER_ENTERED".to_string() +} + +/// Sheet (tab) info within a spreadsheet. +#[derive(Debug, Serialize)] +pub struct SheetInfo { + pub sheet_id: i64, + pub title: String, + pub index: i64, + pub row_count: i64, + pub column_count: i64, +} + +/// Named range within a spreadsheet. +#[derive(Debug, Serialize)] +pub struct NamedRange { + pub named_range_id: String, + pub name: String, + pub range: String, +} + +/// Result from create_spreadsheet. +#[derive(Debug, Serialize)] +pub struct CreateSpreadsheetResult { + pub spreadsheet_id: String, + pub title: String, + pub url: String, + pub sheets: Vec, +} + +/// Result from get_spreadsheet. +#[derive(Debug, Serialize)] +pub struct SpreadsheetMetadata { + pub spreadsheet_id: String, + pub title: String, + pub url: String, + pub sheets: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub named_ranges: Vec, +} + +/// Result from read_values. +#[derive(Debug, Serialize)] +pub struct ValuesResult { + pub range: String, + pub values: Vec>, +} + +/// Result from batch_read_values. +#[derive(Debug, Serialize)] +pub struct BatchValuesResult { + pub value_ranges: Vec, +} + +/// Result from write_values or append_values. +#[derive(Debug, Serialize)] +pub struct UpdateResult { + pub updated_range: String, + pub updated_rows: i64, + pub updated_columns: i64, + pub updated_cells: i64, +} + +/// Result from clear_values. +#[derive(Debug, Serialize)] +pub struct ClearResult { + pub cleared_range: String, +} + +/// Result from add_sheet. +#[derive(Debug, Serialize)] +pub struct AddSheetResult { + pub sheet: SheetInfo, +} + +/// Result from delete_sheet or rename_sheet. +#[derive(Debug, Serialize)] +pub struct SheetOperationResult { + pub spreadsheet_id: String, + pub success: bool, +} + +/// Result from format_cells. +#[derive(Debug, Serialize)] +pub struct FormatResult { + pub spreadsheet_id: String, + pub success: bool, +} diff --git a/tools-src/google-slides/Cargo.toml b/tools-src/google-slides/Cargo.toml new file mode 100644 index 00000000..f6a3bfe0 --- /dev/null +++ b/tools-src/google-slides/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "google-slides-tool" +version = "0.1.0" +edition = "2021" +description = "Google Slides integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/google-slides/google-slides-tool.capabilities.json b/tools-src/google-slides/google-slides-tool.capabilities.json new file mode 100644 index 00000000..ce99d7a3 --- /dev/null +++ b/tools-src/google-slides/google-slides-tool.capabilities.json @@ -0,0 +1,45 @@ +{ + "http": { + "allowlist": [ + { + "host": "slides.googleapis.com", + "path_prefix": "/v1/presentations", + "methods": ["GET", "POST"] + } + ], + "credentials": { + "google_oauth_token": { + "secret_name": "google_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["slides.googleapis.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 500 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["google_oauth_token"] + }, + "auth": { + "secret_name": "google_oauth_token", + "display_name": "Google", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "client_id_env": "GOOGLE_OAUTH_CLIENT_ID", + "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", + "scopes": [ + "https://www.googleapis.com/auth/presentations" + ], + "use_pkce": false, + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "env_var": "GOOGLE_OAUTH_TOKEN" + } +} diff --git a/tools-src/google-slides/src/api.rs b/tools-src/google-slides/src/api.rs new file mode 100644 index 00000000..4f85adf6 --- /dev/null +++ b/tools-src/google-slides/src/api.rs @@ -0,0 +1,619 @@ +//! Google Slides API v1 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 OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const SLIDES_API_BASE: &str = "https://slides.googleapis.com/v1/presentations"; + +/// Make a Google Slides API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = if path.is_empty() { + SLIDES_API_BASE.to_string() + } else { + format!("{}/{}", SLIDES_API_BASE, path) + }; + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Slides API: {} {}", method, url), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Slides API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Send a batchUpdate to the presentation. +fn batch_update_raw( + presentation_id: &str, + requests: Vec, +) -> Result { + let path = format!("{}:batchUpdate", url_encode(presentation_id)); + + let body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", &path, Some(&body_str))?; + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// Extract text content from a shape's textElements array. +fn extract_text_from_shape(shape: &serde_json::Value) -> Option { + let text_elements = shape["text"]["textElements"].as_array()?; + let mut text = String::new(); + for el in text_elements { + if let Some(content) = el["textRun"]["content"].as_str() { + text.push_str(content); + } + } + if text.is_empty() { + None + } else { + Some(text) + } +} + +/// Parse a page element into ElementInfo. +fn parse_element(el: &serde_json::Value) -> ElementInfo { + let object_id = el["objectId"].as_str().unwrap_or("").to_string(); + + let (element_type, text_content, placeholder_type) = if el.get("shape").is_some() { + let pt = el["shape"]["placeholder"]["type"] + .as_str() + .map(|s| s.to_string()); + let text = extract_text_from_shape(&el["shape"]); + ("shape".to_string(), text, pt) + } else if el.get("image").is_some() { + ("image".to_string(), None, None) + } else if el.get("table").is_some() { + ("table".to_string(), None, None) + } else if el.get("line").is_some() { + ("line".to_string(), None, None) + } else if el.get("video").is_some() { + ("video".to_string(), None, None) + } else if el.get("elementGroup").is_some() { + ("group".to_string(), None, None) + } else { + ("unknown".to_string(), None, None) + }; + + ElementInfo { + object_id, + element_type, + text_content, + placeholder_type, + } +} + +/// Create a new presentation. +pub fn create_presentation(title: &str) -> Result { + let body = serde_json::json!({ "title": title }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", "", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(CreatePresentationResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + }) +} + +/// Get presentation metadata and slides. +pub fn get_presentation(presentation_id: &str) -> Result { + let path = url_encode(presentation_id); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let slides: Vec = parsed["slides"] + .as_array() + .map(|arr| { + arr.iter() + .map(|slide| { + let elements = slide["pageElements"] + .as_array() + .map(|els| els.iter().map(parse_element).collect()) + .unwrap_or_default(); + + SlideInfo { + object_id: slide["objectId"].as_str().unwrap_or("").to_string(), + layout_object_id: slide["slideProperties"]["layoutObjectId"] + .as_str() + .unwrap_or("") + .to_string(), + elements, + } + }) + .collect() + }) + .unwrap_or_default(); + + let slide_count = slides.len(); + + Ok(PresentationMetadata { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + revision_id: parsed["revisionId"].as_str().unwrap_or("").to_string(), + slide_count, + slides, + }) +} + +/// Get a thumbnail URL for a slide. +pub fn get_thumbnail( + presentation_id: &str, + slide_object_id: &str, +) -> Result { + let path = format!( + "{}/pages/{}/thumbnail", + url_encode(presentation_id), + url_encode(slide_object_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ThumbnailResult { + content_url: parsed["contentUrl"].as_str().unwrap_or("").to_string(), + width: parsed["width"].as_i64().unwrap_or(0), + height: parsed["height"].as_i64().unwrap_or(0), + }) +} + +/// Create a new slide. +pub fn create_slide( + presentation_id: &str, + insertion_index: Option, + layout: &str, +) -> Result { + let mut request = serde_json::json!({ + "createSlide": { + "slideLayoutReference": { + "predefinedLayout": layout, + } + } + }); + + if let Some(idx) = insertion_index { + request["createSlide"]["insertionIndex"] = serde_json::json!(idx); + } + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let created_id = parsed["replies"][0]["createSlide"]["objectId"] + .as_str() + .map(|s| s.to_string()); + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: created_id, + }) +} + +/// Delete a slide or page element. +pub fn delete_object(presentation_id: &str, object_id: &str) -> Result { + let request = serde_json::json!({ + "deleteObject": { "objectId": object_id } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Insert text into a shape. +pub fn insert_text( + presentation_id: &str, + object_id: &str, + text: &str, + insertion_index: i64, +) -> Result { + let request = serde_json::json!({ + "insertText": { + "objectId": object_id, + "text": text, + "insertionIndex": insertion_index, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Delete text from a shape. +pub fn delete_text( + presentation_id: &str, + object_id: &str, + start_index: i64, + end_index: Option, +) -> Result { + let text_range = if let Some(end) = end_index { + serde_json::json!({ + "type": "FIXED_RANGE", + "startIndex": start_index, + "endIndex": end, + }) + } else { + serde_json::json!({ + "type": "FROM_START_INDEX", + "startIndex": start_index, + }) + }; + + let request = serde_json::json!({ + "deleteText": { + "objectId": object_id, + "textRange": text_range, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Find and replace text across the presentation. +pub fn replace_all_text( + presentation_id: &str, + find: &str, + replace: &str, + match_case: bool, +) -> Result { + let request = serde_json::json!({ + "replaceAllText": { + "containsText": { + "text": find, + "matchCase": match_case, + }, + "replaceText": replace, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"] + .as_i64() + .unwrap_or(0); + + Ok(ReplaceResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + occurrences_changed: occurrences, + }) +} + +/// Points to EMU (English Metric Units). 1 point = 12700 EMU. +fn pt_to_emu(pt: f64) -> f64 { + pt * 12700.0 +} + +/// Create a shape on a slide. +pub fn create_shape( + presentation_id: &str, + slide_object_id: &str, + shape_type: &str, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result { + let request = serde_json::json!({ + "createShape": { + "shapeType": shape_type, + "elementProperties": { + "pageObjectId": slide_object_id, + "size": { + "width": { "magnitude": pt_to_emu(width), "unit": "EMU" }, + "height": { "magnitude": pt_to_emu(height), "unit": "EMU" }, + }, + "transform": { + "scaleX": 1.0, + "scaleY": 1.0, + "shearX": 0.0, + "shearY": 0.0, + "translateX": pt_to_emu(x), + "translateY": pt_to_emu(y), + "unit": "EMU", + }, + }, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let created_id = parsed["replies"][0]["createShape"]["objectId"] + .as_str() + .map(|s| s.to_string()); + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: created_id, + }) +} + +/// Insert an image on a slide. +pub fn insert_image( + presentation_id: &str, + slide_object_id: &str, + image_url: &str, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result { + let request = serde_json::json!({ + "createImage": { + "url": image_url, + "elementProperties": { + "pageObjectId": slide_object_id, + "size": { + "width": { "magnitude": pt_to_emu(width), "unit": "EMU" }, + "height": { "magnitude": pt_to_emu(height), "unit": "EMU" }, + }, + "transform": { + "scaleX": 1.0, + "scaleY": 1.0, + "shearX": 0.0, + "shearY": 0.0, + "translateX": pt_to_emu(x), + "translateY": pt_to_emu(y), + "unit": "EMU", + }, + }, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let created_id = parsed["replies"][0]["createImage"]["objectId"] + .as_str() + .map(|s| s.to_string()); + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: created_id, + }) +} + +/// Parse a hex color like "#FF0000" into Slides API color format. +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(serde_json::json!({ + "opaqueColor": { + "rgbColor": { + "red": r as f64 / 255.0, + "green": g as f64 / 255.0, + "blue": b as f64 / 255.0, + } + } + })) +} + +/// Parameters for text formatting. +pub struct FormatTextOptions<'a> { + pub presentation_id: &'a str, + pub object_id: &'a str, + pub start_index: Option, + pub end_index: Option, + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub font_size: Option, + pub font_family: Option<&'a str>, + pub foreground_color: Option<&'a str>, +} + +/// Format text in a shape. +pub fn format_text(opts: FormatTextOptions<'_>) -> Result { + let mut style = serde_json::json!({}); + let mut fields = Vec::new(); + + if let Some(b) = opts.bold { + style["bold"] = serde_json::Value::Bool(b); + fields.push("bold"); + } + if let Some(i) = opts.italic { + style["italic"] = serde_json::Value::Bool(i); + fields.push("italic"); + } + if let Some(u) = opts.underline { + style["underline"] = serde_json::Value::Bool(u); + fields.push("underline"); + } + if let Some(size) = opts.font_size { + style["fontSize"] = serde_json::json!({ "magnitude": size, "unit": "PT" }); + fields.push("fontSize"); + } + if let Some(family) = opts.font_family { + style["fontFamily"] = serde_json::Value::String(family.to_string()); + fields.push("fontFamily"); + } + if let Some(color) = opts.foreground_color { + if let Some(c) = parse_hex_color(color) { + style["foregroundColor"] = c; + fields.push("foregroundColor"); + } + } + + if fields.is_empty() { + return Err("No formatting options specified".to_string()); + } + + let text_range = match (opts.start_index, opts.end_index) { + (Some(start), Some(end)) => serde_json::json!({ + "type": "FIXED_RANGE", + "startIndex": start, + "endIndex": end, + }), + (Some(start), None) => serde_json::json!({ + "type": "FROM_START_INDEX", + "startIndex": start, + }), + _ => serde_json::json!({ "type": "ALL" }), + }; + + let request = serde_json::json!({ + "updateTextStyle": { + "objectId": opts.object_id, + "textRange": text_range, + "style": style, + "fields": fields.join(","), + } + }); + + let parsed = batch_update_raw(opts.presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Format paragraph alignment in a shape. +pub fn format_paragraph( + presentation_id: &str, + object_id: &str, + alignment: &str, + start_index: Option, + end_index: Option, +) -> Result { + let text_range = match (start_index, end_index) { + (Some(start), Some(end)) => serde_json::json!({ + "type": "FIXED_RANGE", + "startIndex": start, + "endIndex": end, + }), + (Some(start), None) => serde_json::json!({ + "type": "FROM_START_INDEX", + "startIndex": start, + }), + _ => serde_json::json!({ "type": "ALL" }), + }; + + let request = serde_json::json!({ + "updateParagraphStyle": { + "objectId": object_id, + "textRange": text_range, + "style": { "alignment": alignment }, + "fields": "alignment", + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Replace all shapes containing text with an image. +pub fn replace_shapes_with_image( + presentation_id: &str, + find: &str, + image_url: &str, + match_case: bool, +) -> Result { + let request = serde_json::json!({ + "replaceAllShapesWithImage": { + "containsText": { + "text": find, + "matchCase": match_case, + }, + "imageUrl": image_url, + "imageReplaceMethod": "CENTER_INSIDE", + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let occurrences = parsed["replies"][0]["replaceAllShapesWithImage"]["occurrencesChanged"] + .as_i64() + .unwrap_or(0); + + Ok(ReplaceResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + occurrences_changed: occurrences, + }) +} + +/// Execute a raw batch update with arbitrary requests. +pub fn batch_update( + presentation_id: &str, + requests: Vec, +) -> Result { + let parsed = batch_update_raw(presentation_id, requests)?; + + let replies = parsed["replies"] + .as_array() + .map(|arr| arr.to_vec()) + .unwrap_or_default(); + + Ok(BatchUpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + replies, + }) +} + +/// Minimal percent-encoding for URL path segments. +fn url_encode(s: &str) -> String { + let mut encoded = 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'~' => { + encoded.push(b as char); + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[(b >> 4) as usize])); + encoded.push(char::from(HEX[(b & 0x0F) as usize])); + } + } + } + encoded +} + +const HEX: [u8; 16] = *b"0123456789ABCDEF"; diff --git a/tools-src/google-slides/src/lib.rs b/tools-src/google-slides/src/lib.rs new file mode 100644 index 00000000..55c71ab4 --- /dev/null +++ b/tools-src/google-slides/src/lib.rs @@ -0,0 +1,611 @@ +//! Google Slides WASM Tool for IronClaw. +//! +//! Provides Google Slides integration for creating, reading, editing, +//! and formatting presentations. Use Google Drive tool to search for +//! existing presentations by name. +//! +//! # Capabilities Required +//! +//! - HTTP: `slides.googleapis.com/v1/presentations*` +//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically) +//! +//! # Supported Actions +//! +//! - `create_presentation`: Create a new blank presentation +//! - `get_presentation`: Get presentation metadata (slides, elements, text) +//! - `get_thumbnail`: Get a thumbnail image URL for a slide +//! - `create_slide`: Add a new slide with a predefined layout +//! - `delete_object`: Delete a slide or page element +//! - `insert_text`: Insert text into a shape or text box +//! - `delete_text`: Delete text from a shape +//! - `replace_all_text`: Find and replace text across the presentation +//! - `create_shape`: Create a text box or shape on a slide +//! - `insert_image`: Insert an image on a slide +//! - `format_text`: Format text (bold, italic, font, color, size) +//! - `format_paragraph`: Set paragraph alignment +//! - `replace_shapes_with_image`: Replace placeholder shapes with an image +//! - `batch_update`: Execute multiple raw Slides API operations atomically +//! +//! # Tips +//! +//! - Presentation IDs are the same as Google Drive file IDs. Use +//! google-drive tool's list_files to find presentations. +//! - Positions and sizes are specified in points (1 inch = 72 points). +//! A standard slide is 720x405 points (10x5.625 inches). +//! - To add text to a slide: first create_shape (TEXT_BOX), then +//! insert_text into the returned object_id. +//! - Use get_presentation to discover object IDs for existing elements. +//! - For template workflows: create shapes with placeholder text, then +//! use replace_all_text or replace_shapes_with_image. +//! +//! # Example Usage +//! +//! ```json +//! {"action": "create_presentation", "title": "Q1 Report"} +//! {"action": "create_slide", "presentation_id": "abc123", "layout": "TITLE_AND_BODY"} +//! {"action": "get_presentation", "presentation_id": "abc123"} +//! {"action": "create_shape", "presentation_id": "abc123", "slide_object_id": "slide1", "shape_type": "TEXT_BOX", "x": 50, "y": 50, "width": 300, "height": 40} +//! {"action": "insert_text", "presentation_id": "abc123", "object_id": "shape1", "text": "Hello World"} +//! {"action": "format_text", "presentation_id": "abc123", "object_id": "shape1", "bold": true, "font_size": 24} +//! ``` + +mod api; +mod types; + +use types::GoogleSlidesAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct GoogleSlidesTool; + +impl exports::near::agent::tool::Guest for GoogleSlidesTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "create_presentation" }, + "title": { + "type": "string", + "description": "Presentation title" + } + }, + "required": ["action", "title"] + }, + { + "properties": { + "action": { "const": "get_presentation" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID (same as Google Drive file ID)" + } + }, + "required": ["action", "presentation_id"] + }, + { + "properties": { + "action": { "const": "get_thumbnail" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "slide_object_id": { + "type": "string", + "description": "The slide's object ID" + } + }, + "required": ["action", "presentation_id", "slide_object_id"] + }, + { + "properties": { + "action": { "const": "create_slide" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "insertion_index": { + "type": "integer", + "description": "Position to insert (0-based). Omit to append at end." + }, + "layout": { + "type": "string", + "enum": ["BLANK", "TITLE", "TITLE_AND_BODY", "TITLE_AND_TWO_COLUMNS", "TITLE_ONLY", "SECTION_HEADER", "CAPTION_ONLY", "BIG_NUMBER", "ONE_COLUMN_TEXT", "MAIN_POINT"], + "description": "Predefined layout (default: BLANK)", + "default": "BLANK" + } + }, + "required": ["action", "presentation_id"] + }, + { + "properties": { + "action": { "const": "delete_object" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "object_id": { + "type": "string", + "description": "Object ID of the slide or element to delete" + } + }, + "required": ["action", "presentation_id", "object_id"] + }, + { + "properties": { + "action": { "const": "insert_text" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "object_id": { + "type": "string", + "description": "Object ID of the shape or text box" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "insertion_index": { + "type": "integer", + "description": "Character index to insert at (0-based). Default: 0.", + "default": 0 + } + }, + "required": ["action", "presentation_id", "object_id", "text"] + }, + { + "properties": { + "action": { "const": "delete_text" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "object_id": { + "type": "string", + "description": "Object ID of the shape" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive, 0-based)", + "default": 0 + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive). Omit to delete from start_index to end." + } + }, + "required": ["action", "presentation_id", "object_id"] + }, + { + "properties": { + "action": { "const": "replace_all_text" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "find": { + "type": "string", + "description": "Text to search for" + }, + "replace": { + "type": "string", + "description": "Replacement text" + }, + "match_case": { + "type": "boolean", + "description": "Case-sensitive match (default: true)", + "default": true + } + }, + "required": ["action", "presentation_id", "find", "replace"] + }, + { + "properties": { + "action": { "const": "create_shape" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "slide_object_id": { + "type": "string", + "description": "Slide object ID to place the shape on" + }, + "shape_type": { + "type": "string", + "enum": ["TEXT_BOX", "RECTANGLE", "ROUND_RECTANGLE", "ELLIPSE"], + "description": "Shape type (default: TEXT_BOX)", + "default": "TEXT_BOX" + }, + "x": { + "type": "number", + "description": "X position in points from left edge" + }, + "y": { + "type": "number", + "description": "Y position in points from top edge" + }, + "width": { + "type": "number", + "description": "Width in points" + }, + "height": { + "type": "number", + "description": "Height in points" + } + }, + "required": ["action", "presentation_id", "slide_object_id", "x", "y", "width", "height"] + }, + { + "properties": { + "action": { "const": "insert_image" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "slide_object_id": { + "type": "string", + "description": "Slide object ID to place the image on" + }, + "image_url": { + "type": "string", + "description": "Publicly accessible image URL" + }, + "x": { + "type": "number", + "description": "X position in points" + }, + "y": { + "type": "number", + "description": "Y position in points" + }, + "width": { + "type": "number", + "description": "Width in points" + }, + "height": { + "type": "number", + "description": "Height in points" + } + }, + "required": ["action", "presentation_id", "slide_object_id", "image_url", "x", "y", "width", "height"] + }, + { + "properties": { + "action": { "const": "format_text" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "object_id": { + "type": "string", + "description": "Object ID of the shape" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive). Omit to format all text." + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive). Omit to format to end." + }, + "bold": { + "type": "boolean", + "description": "Make text bold" + }, + "italic": { + "type": "boolean", + "description": "Make text italic" + }, + "underline": { + "type": "boolean", + "description": "Underline text" + }, + "font_size": { + "type": "number", + "description": "Font size in points (e.g., 12, 18, 24)" + }, + "font_family": { + "type": "string", + "description": "Font family (e.g., 'Arial', 'Roboto', 'Times New Roman')" + }, + "foreground_color": { + "type": "string", + "description": "Text color as hex (e.g., '#FF0000' for red)" + } + }, + "required": ["action", "presentation_id", "object_id"] + }, + { + "properties": { + "action": { "const": "format_paragraph" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "object_id": { + "type": "string", + "description": "Object ID of the shape" + }, + "alignment": { + "type": "string", + "enum": ["START", "CENTER", "END", "JUSTIFIED"], + "description": "Paragraph alignment" + }, + "start_index": { + "type": "integer", + "description": "Start index (inclusive). Omit to format all." + }, + "end_index": { + "type": "integer", + "description": "End index (exclusive). Omit to format to end." + } + }, + "required": ["action", "presentation_id", "object_id", "alignment"] + }, + { + "properties": { + "action": { "const": "replace_shapes_with_image" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "find": { + "type": "string", + "description": "Text to match in shapes" + }, + "image_url": { + "type": "string", + "description": "Image URL to replace matched shapes with" + }, + "match_case": { + "type": "boolean", + "description": "Case-sensitive match (default: true)", + "default": true + } + }, + "required": ["action", "presentation_id", "find", "image_url"] + }, + { + "properties": { + "action": { "const": "batch_update" }, + "presentation_id": { + "type": "string", + "description": "The presentation ID" + }, + "requests": { + "type": "array", + "items": { "type": "object" }, + "description": "Array of raw Slides API batchUpdate request objects" + } + }, + "required": ["action", "presentation_id", "requests"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Google Slides integration for creating, reading, editing, and formatting presentations. \ + Supports slide management (create, delete, reorder), text operations (insert, delete, \ + find-replace), shapes and text boxes, image insertion, text formatting (bold, italic, \ + font, color, size), paragraph alignment, thumbnails, and template-based image replacement. \ + Also provides a batch_update action for complex multi-step edits executed atomically. \ + Positions and sizes use points (standard slide is 720x405 pt). Presentation IDs are the \ + same as Google Drive file IDs, so use the google-drive tool to search for existing \ + presentations. Requires a Google OAuth token with the presentations scope." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("google_oauth_token") { + return Err( + "Google OAuth token not configured. Run `ironclaw tool auth google-slides` to set up \ + OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: GoogleSlidesAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Google Slides action: {:?}", action), + ); + + let result = match action { + GoogleSlidesAction::CreatePresentation { title } => { + let result = api::create_presentation(&title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::GetPresentation { presentation_id } => { + let result = api::get_presentation(&presentation_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::GetThumbnail { + presentation_id, + slide_object_id, + } => { + let result = api::get_thumbnail(&presentation_id, &slide_object_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::CreateSlide { + presentation_id, + insertion_index, + layout, + } => { + let result = api::create_slide(&presentation_id, insertion_index, &layout)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::DeleteObject { + presentation_id, + object_id, + } => { + let result = api::delete_object(&presentation_id, &object_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::InsertText { + presentation_id, + object_id, + text, + insertion_index, + } => { + let result = api::insert_text(&presentation_id, &object_id, &text, insertion_index)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::DeleteText { + presentation_id, + object_id, + start_index, + end_index, + } => { + let result = api::delete_text(&presentation_id, &object_id, start_index, end_index)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::ReplaceAllText { + presentation_id, + find, + replace, + match_case, + } => { + let result = api::replace_all_text(&presentation_id, &find, &replace, match_case)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::CreateShape { + presentation_id, + slide_object_id, + shape_type, + x, + y, + width, + height, + } => { + let result = api::create_shape( + &presentation_id, + &slide_object_id, + &shape_type, + x, + y, + width, + height, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::InsertImage { + presentation_id, + slide_object_id, + image_url, + x, + y, + width, + height, + } => { + let result = api::insert_image( + &presentation_id, + &slide_object_id, + &image_url, + x, + y, + width, + height, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::FormatText { + presentation_id, + object_id, + start_index, + end_index, + bold, + italic, + underline, + font_size, + font_family, + foreground_color, + } => { + let result = api::format_text(api::FormatTextOptions { + presentation_id: &presentation_id, + object_id: &object_id, + start_index, + end_index, + bold, + italic, + underline, + font_size, + font_family: font_family.as_deref(), + foreground_color: foreground_color.as_deref(), + })?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::FormatParagraph { + presentation_id, + object_id, + alignment, + start_index, + end_index, + } => { + let result = api::format_paragraph( + &presentation_id, + &object_id, + &alignment, + start_index, + end_index, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::ReplaceShapesWithImage { + presentation_id, + find, + image_url, + match_case, + } => { + let result = + api::replace_shapes_with_image(&presentation_id, &find, &image_url, match_case)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::BatchUpdate { + presentation_id, + requests, + } => { + let result = api::batch_update(&presentation_id, requests)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +export!(GoogleSlidesTool); diff --git a/tools-src/google-slides/src/types.rs b/tools-src/google-slides/src/types.rs new file mode 100644 index 00000000..493e33f5 --- /dev/null +++ b/tools-src/google-slides/src/types.rs @@ -0,0 +1,275 @@ +//! Types for Google Slides API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Google Slides tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleSlidesAction { + /// Create a new presentation. + CreatePresentation { + /// Presentation title. + title: String, + }, + + /// Get presentation metadata (slides, elements, text content). + GetPresentation { + /// The presentation ID (same as Google Drive file ID). + presentation_id: String, + }, + + /// Get a thumbnail image URL for a specific slide. + GetThumbnail { + /// The presentation ID. + presentation_id: String, + /// The slide's object ID. + slide_object_id: String, + }, + + /// Create a new slide. + CreateSlide { + /// The presentation ID. + presentation_id: String, + /// Position to insert (0-based). Omit to append at end. + #[serde(default)] + insertion_index: Option, + /// Predefined layout: "BLANK", "TITLE", "TITLE_AND_BODY", + /// "TITLE_AND_TWO_COLUMNS", "TITLE_ONLY", "SECTION_HEADER", + /// "CAPTION_ONLY", "BIG_NUMBER", "ONE_COLUMN_TEXT", "MAIN_POINT". + #[serde(default = "default_layout")] + layout: String, + }, + + /// Delete a slide or page element. + DeleteObject { + /// The presentation ID. + presentation_id: String, + /// Object ID of the slide or element to delete. + object_id: String, + }, + + /// Insert text into a shape or text box. + InsertText { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape/text box. + object_id: String, + /// Text to insert. + text: String, + /// Character index to insert at (0-based). Default: 0. + #[serde(default)] + insertion_index: i64, + }, + + /// Delete text from a shape. + DeleteText { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape. + object_id: String, + /// Start index (inclusive). Use 0 for start. + #[serde(default)] + start_index: i64, + /// End index (exclusive). Omit to delete to end. + #[serde(default)] + end_index: Option, + }, + + /// Find and replace text across the entire presentation. + ReplaceAllText { + /// The presentation ID. + presentation_id: String, + /// Text to find. + find: String, + /// Replacement text. + replace: String, + /// Case-sensitive match (default: true). + #[serde(default = "default_true")] + match_case: bool, + }, + + /// Create a text box or shape on a slide. + CreateShape { + /// The presentation ID. + presentation_id: String, + /// Slide object ID to place the shape on. + slide_object_id: String, + /// Shape type: "TEXT_BOX", "RECTANGLE", "ROUND_RECTANGLE", "ELLIPSE". + #[serde(default = "default_shape_type")] + shape_type: String, + /// X position in points from left edge. + x: f64, + /// Y position in points from top edge. + y: f64, + /// Width in points. + width: f64, + /// Height in points. + height: f64, + }, + + /// Insert an image on a slide. + InsertImage { + /// The presentation ID. + presentation_id: String, + /// Slide object ID to place the image on. + slide_object_id: String, + /// Publicly accessible image URL. + image_url: String, + /// X position in points. + x: f64, + /// Y position in points. + y: f64, + /// Width in points. + width: f64, + /// Height in points. + height: f64, + }, + + /// Format text in a shape (bold, italic, font, color, size). + FormatText { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape. + object_id: String, + /// Start index (inclusive). Use 0 for start. + #[serde(default)] + start_index: Option, + /// End index (exclusive). Omit to format all text. + #[serde(default)] + end_index: Option, + /// Make text bold. + #[serde(default)] + bold: Option, + /// Make text italic. + #[serde(default)] + italic: Option, + /// Underline text. + #[serde(default)] + underline: Option, + /// Font size in points. + #[serde(default)] + font_size: Option, + /// Font family name (e.g., "Arial"). + #[serde(default)] + font_family: Option, + /// Text color as hex (e.g., "#FF0000"). + #[serde(default)] + foreground_color: Option, + }, + + /// Set paragraph alignment for text in a shape. + FormatParagraph { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape. + object_id: String, + /// Alignment: "START", "CENTER", "END", "JUSTIFIED". + alignment: String, + /// Start index (inclusive). + #[serde(default)] + start_index: Option, + /// End index (exclusive). Omit to format all. + #[serde(default)] + end_index: Option, + }, + + /// Replace all shapes containing specific text with an image. + ReplaceShapesWithImage { + /// The presentation ID. + presentation_id: String, + /// Text to match in shapes. + find: String, + /// Image URL to replace shapes with. + image_url: String, + /// Case-sensitive match (default: true). + #[serde(default = "default_true")] + match_case: bool, + }, + + /// Execute multiple raw Slides API operations atomically. + BatchUpdate { + /// The presentation ID. + presentation_id: String, + /// Array of raw request objects as per Google Slides API. + requests: Vec, + }, +} + +fn default_layout() -> String { + "BLANK".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_shape_type() -> String { + "TEXT_BOX".to_string() +} + +/// Slide info. +#[derive(Debug, Serialize)] +pub struct SlideInfo { + pub object_id: String, + pub layout_object_id: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub elements: Vec, +} + +/// Page element info. +#[derive(Debug, Serialize)] +pub struct ElementInfo { + pub object_id: String, + pub element_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder_type: Option, +} + +/// Result from create_presentation. +#[derive(Debug, Serialize)] +pub struct CreatePresentationResult { + pub presentation_id: String, + pub title: String, +} + +/// Result from get_presentation. +#[derive(Debug, Serialize)] +pub struct PresentationMetadata { + pub presentation_id: String, + pub title: String, + pub revision_id: String, + pub slide_count: usize, + pub slides: Vec, +} + +/// Result from get_thumbnail. +#[derive(Debug, Serialize)] +pub struct ThumbnailResult { + pub content_url: String, + pub width: i64, + pub height: i64, +} + +/// Result from a batchUpdate operation. +#[derive(Debug, Serialize)] +pub struct UpdateResult { + pub presentation_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_object_id: Option, +} + +/// Result from replace_all_text. +#[derive(Debug, Serialize)] +pub struct ReplaceResult { + pub presentation_id: String, + pub occurrences_changed: i64, +} + +/// Result from batch_update. +#[derive(Debug, Serialize)] +pub struct BatchUpdateResult { + pub presentation_id: String, + pub replies: Vec, +} diff --git a/tools-src/wasm-tools/slack/Cargo.toml b/tools-src/slack/Cargo.toml similarity index 94% rename from tools-src/wasm-tools/slack/Cargo.toml rename to tools-src/slack/Cargo.toml index 83425bc8..cb3c0ad2 100644 --- a/tools-src/wasm-tools/slack/Cargo.toml +++ b/tools-src/slack/Cargo.toml @@ -10,7 +10,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -wit-bindgen = "0.36" +wit-bindgen = "=0.36" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/tools-src/wasm-tools/slack/README.md b/tools-src/slack/README.md similarity index 99% rename from tools-src/wasm-tools/slack/README.md rename to tools-src/slack/README.md index c1c9efaf..68e056e7 100644 --- a/tools-src/wasm-tools/slack/README.md +++ b/tools-src/slack/README.md @@ -34,7 +34,7 @@ A standalone WASM component that provides Slack integration for IronClaw. This s ## Building ```bash -cd tools-src/wasm-tools/slack +cd tools-src/slack cargo component build --release ``` diff --git a/tools-src/slack/slack-tool.capabilities.json b/tools-src/slack/slack-tool.capabilities.json new file mode 100644 index 00000000..753cffc6 --- /dev/null +++ b/tools-src/slack/slack-tool.capabilities.json @@ -0,0 +1,50 @@ +{ + "http": { + "allowlist": [ + { + "host": "slack.com", + "path_prefix": "/api/", + "methods": ["GET", "POST"] + } + ], + "credentials": { + "slack_bot_token": { + "secret_name": "slack_bot_token", + "location": { "type": "bearer" }, + "host_patterns": ["slack.com"] + } + }, + "rate_limit": { + "requests_per_minute": 50, + "requests_per_hour": 1000 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["slack_bot_token"] + }, + "auth": { + "secret_name": "slack_bot_token", + "display_name": "Slack", + "oauth": { + "authorization_url": "https://slack.com/oauth/v2/authorize", + "token_url": "https://slack.com/api/oauth.v2.access", + "client_id_env": "SLACK_OAUTH_CLIENT_ID", + "client_secret_env": "SLACK_OAUTH_CLIENT_SECRET", + "scopes": [ + "chat:write", + "channels:read", + "channels:history", + "groups:read", + "groups:history", + "reactions:write", + "users:read" + ], + "use_pkce": false + }, + "instructions": "1. Create a Slack App at https://api.slack.com/apps\n2. Add Bot Token Scopes under OAuth & Permissions:\n chat:write, channels:read, channels:history, groups:read,\n groups:history, reactions:write, users:read\n3. Install the app to your workspace\n4. Copy the Bot User OAuth Token (starts with xoxb-)", + "setup_url": "https://api.slack.com/apps", + "token_hint": "Starts with 'xoxb-'", + "env_var": "SLACK_BOT_TOKEN" + } +} diff --git a/tools-src/wasm-tools/slack/src/api.rs b/tools-src/slack/src/api.rs similarity index 87% rename from tools-src/wasm-tools/slack/src/api.rs rename to tools-src/slack/src/api.rs index 5fd56f37..cae7506b 100644 --- a/tools-src/wasm-tools/slack/src/api.rs +++ b/tools-src/slack/src/api.rs @@ -9,6 +9,24 @@ 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); @@ -22,7 +40,10 @@ fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result Result { /// Get message history from a channel. pub fn get_channel_history(channel: &str, limit: u32) -> Result { - let url = format!("conversations.history?channel={}&limit={}", channel, limit); + let url = format!( + "conversations.history?channel={}&limit={}", + url_encode(channel), + limit + ); let response = slack_api_call("GET", &url, None)?; @@ -143,7 +168,11 @@ pub fn get_channel_history(channel: &str, limit: u32) -> Result Result { +pub fn post_reaction( + channel: &str, + timestamp: &str, + emoji: &str, +) -> Result { let payload = serde_json::json!({ "channel": channel, "timestamp": timestamp, @@ -169,7 +198,7 @@ pub fn post_reaction(channel: &str, timestamp: &str, emoji: &str) -> Result Result { - let url = format!("users.info?user={}", user_id); + let url = format!("users.info?user={}", url_encode(user_id)); let response = slack_api_call("GET", &url, None)?; diff --git a/tools-src/wasm-tools/slack/src/lib.rs b/tools-src/slack/src/lib.rs similarity index 99% rename from tools-src/wasm-tools/slack/src/lib.rs rename to tools-src/slack/src/lib.rs index 04cc2b70..40b338e9 100644 --- a/tools-src/wasm-tools/slack/src/lib.rs +++ b/tools-src/slack/src/lib.rs @@ -32,7 +32,7 @@ use types::SlackAction; // This creates the `bindings` module with types and traits. wit_bindgen::generate!({ world: "sandboxed-tool", - path: "../../../wit/tool.wit", + path: "../../wit/tool.wit", }); /// Implementation of the tool interface. diff --git a/tools-src/wasm-tools/slack/src/types.rs b/tools-src/slack/src/types.rs similarity index 96% rename from tools-src/wasm-tools/slack/src/types.rs rename to tools-src/slack/src/types.rs index e6c54c3d..55c53016 100644 --- a/tools-src/wasm-tools/slack/src/types.rs +++ b/tools-src/slack/src/types.rs @@ -138,10 +138,3 @@ pub struct GetUserInfoResult { pub ok: bool, pub user: UserInfo, } - -/// Generic Slack API error response. -#[derive(Debug, Deserialize)] -pub struct SlackApiError { - pub ok: bool, - pub error: String, -} diff --git a/tools-src/telegram/Cargo.toml b/tools-src/telegram/Cargo.toml new file mode 100644 index 00000000..ed283acf --- /dev/null +++ b/tools-src/telegram/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "telegram-tool" +version = "0.1.0" +edition = "2021" +description = "Telegram user-mode integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +grammers-mtproto = "0.8" +grammers-crypto = "0.8" +grammers-tl-types = "0.8" +num-bigint = "0.4" +getrandom = "0.3" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/telegram/src/api.rs b/tools-src/telegram/src/api.rs new file mode 100644 index 00000000..25a87186 --- /dev/null +++ b/tools-src/telegram/src/api.rs @@ -0,0 +1,705 @@ +//! Telegram MTProto API implementation. +//! +//! Sends encrypted RPC requests directly to Telegram's data centers via +//! HTTP POST to `https://{dc}.web.telegram.org/apiw`. Uses grammers-mtproto +//! (Sans-IO) for message framing and encryption; no TDLib/TDLight needed. + +use grammers_mtproto::mtp::Encrypted; +use grammers_tl_types::{self as tl, Deserializable, Serializable}; + +use crate::session::Session; +use crate::transport; +use crate::types::*; + +/// Current TL layer. Must match grammers-tl-types. +const LAYER: i32 = 185; + +/// Wrap a request in InvokeWithLayer + InitConnection for the first RPC. +/// +/// Telegram requires the first request in a session to be wrapped in +/// initConnection so the server knows our client metadata. +fn wrap_init_connection(session: &Session, inner_bytes: Vec) -> Vec { + let init = tl::functions::InitConnection { + api_id: session.api_id, + device_model: "WASM Sandbox".to_string(), + system_version: "wasip2".to_string(), + app_version: "0.1.0".to_string(), + system_lang_code: "en".to_string(), + lang_pack: String::new(), + lang_code: "en".to_string(), + proxy: None, + params: None, + query: inner_bytes, + }; + + tl::functions::InvokeWithLayer { + layer: LAYER, + query: init.to_bytes(), + } + .to_bytes() +} + +/// Create an Encrypted MTP instance from session state. +fn make_mtp(session: &Session) -> Result { + let auth_key = session.auth_key_bytes()?; + Ok(Encrypted::build() + .time_offset(session.time_offset) + .first_salt(session.first_salt) + .finish(auth_key)) +} + +/// Send an encrypted RPC, wrapping in initConnection on first call. +fn rpc_call( + mtp: &mut Encrypted, + session: &Session, + request_bytes: Vec, + init_wrap: bool, +) -> Result, String> { + let bytes = if init_wrap { + wrap_init_connection(session, request_bytes) + } else { + request_bytes + }; + transport::post_encrypted(mtp, session.dc_id, &bytes) +} + +// --------------------------------------------------------------------------- +// Login flow +// --------------------------------------------------------------------------- + +/// Send auth code to phone number. +pub fn send_code(session: &mut Session) -> Result { + let phone = session + .phone_number + .as_ref() + .ok_or("phone_number not set in session")? + .clone(); + + let mut mtp = make_mtp(session)?; + let request = tl::functions::auth::SendCode { + phone_number: phone, + api_id: session.api_id, + api_hash: session.api_hash.clone(), + settings: tl::enums::CodeSettings::Settings(tl::types::CodeSettings { + allow_flashcall: false, + current_number: false, + allow_app_hash: false, + allow_missed_call: false, + allow_firebase: false, + unknown_number: false, + logout_tokens: None, + token: None, + app_sandbox: None, + }), + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let sent = tl::enums::auth::SentCode::from_bytes(&resp_bytes) + .map_err(|e| format!("parse SentCode: {e}"))?; + + match sent { + tl::enums::auth::SentCode::Code(code) => { + session.phone_code_hash = Some(code.phone_code_hash.clone()); + Ok(serde_json::to_string(&LoginResult { + status: "code_sent".into(), + phone_code_hash: Some(code.phone_code_hash), + message: Some( + "Verification code sent. Use submit_auth_code to complete login.".into(), + ), + }) + .unwrap_or_default()) + } + tl::enums::auth::SentCode::Success(_) => { + session.logged_in = true; + Ok(serde_json::to_string(&LoginResult { + status: "logged_in".into(), + phone_code_hash: None, + message: Some("Already logged in.".into()), + }) + .unwrap_or_default()) + } + tl::enums::auth::SentCode::PaymentRequired(_) => { + Err("Telegram requires payment to send auth codes to this number.".into()) + } + } +} + +/// Complete login with the verification code. +pub fn sign_in(session: &mut Session, code: &str) -> Result { + let phone = session + .phone_number + .as_ref() + .ok_or("phone_number not set, call login first")? + .clone(); + let hash = session + .phone_code_hash + .as_ref() + .ok_or("phone_code_hash not set, call login first")? + .clone(); + + let mut mtp = make_mtp(session)?; + let request = tl::functions::auth::SignIn { + phone_number: phone, + phone_code_hash: hash, + phone_code: Some(code.to_string()), + email_verification: None, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + + match tl::enums::auth::Authorization::from_bytes(&resp_bytes) { + Ok(tl::enums::auth::Authorization::Authorization(auth)) => { + session.logged_in = true; + session.phone_code_hash = None; + Ok(format_user_auth(&auth.user)) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => { + Err("Account not registered. Sign up on a Telegram client first.".into()) + } + Err(e) => Err(format!( + "signIn failed (maybe 2FA required): {e}. \ + If you have 2FA enabled, use submit_2fa_password." + )), + } +} + +/// Submit 2FA password using SRP protocol. +pub fn check_password(session: &mut Session, password: &str) -> Result { + let mut mtp = make_mtp(session)?; + + // Get the current password info (SRP parameters). + let request = tl::functions::account::GetPassword {}.to_bytes(); + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let pwd = tl::enums::account::Password::from_bytes(&resp_bytes) + .map_err(|e| format!("parse Password: {e}"))?; + + let tl::enums::account::Password::Password(pwd) = pwd; + + let current_algo = pwd + .current_algo + .ok_or("no current_algo, 2FA might not be enabled")?; + + let srp_b = pwd.srp_b.ok_or("no srp_B in password response")?; + let srp_id = pwd.srp_id.ok_or("no srp_id in password response")?; + + match current_algo { + tl::enums::PasswordKdfAlgo::Sha256Sha256Pbkdf2Hmacsha512iter100000Sha256ModPow(algo) => { + let mut a_bytes = vec![0u8; 256]; + getrandom::fill(&mut a_bytes).map_err(|e| format!("getrandom failed: {e}"))?; + + let (m1, g_a) = grammers_crypto::two_factor_auth::calculate_2fa( + &algo.salt1, + &algo.salt2, + &algo.p, + &algo.g, + srp_b, + a_bytes, + password.as_bytes(), + ); + + let check_req = tl::functions::auth::CheckPassword { + password: tl::enums::InputCheckPasswordSrp::Srp(tl::types::InputCheckPasswordSrp { + srp_id, + a: g_a.to_vec(), + m1: m1.to_vec(), + }), + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, check_req, false)?; + match tl::enums::auth::Authorization::from_bytes(&resp_bytes) { + Ok(tl::enums::auth::Authorization::Authorization(auth)) => { + session.logged_in = true; + session.phone_code_hash = None; + Ok(format_user_auth(&auth.user)) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => { + Err("Unexpected sign-up required after 2FA".into()) + } + Err(e) => Err(format!("2FA check failed: {e}")), + } + } + tl::enums::PasswordKdfAlgo::Unknown => { + Err("server returned unknown password KDF algorithm; client may be outdated".into()) + } + } +} + +// --------------------------------------------------------------------------- +// Read-only API methods +// --------------------------------------------------------------------------- + +pub fn get_me(session: &Session) -> Result { + let mut mtp = make_mtp(session)?; + let request = tl::functions::users::GetFullUser { + id: tl::enums::InputUser::UserSelf, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let full = tl::enums::users::UserFull::from_bytes(&resp_bytes) + .map_err(|e| format!("parse UserFull: {e}"))?; + + let tl::enums::users::UserFull::Full(full) = full; + + for user_enum in &full.users { + if let tl::enums::User::User(u) = user_enum { + return Ok(serde_json::to_string(&UserInfo { + id: u.id, + first_name: u.first_name.clone().unwrap_or_default(), + last_name: u.last_name.clone(), + username: u.username.clone(), + phone_number: u.phone.clone(), + }) + .unwrap_or_default()); + } + } + Err("no user in response".into()) +} + +pub fn get_contacts(session: &Session) -> Result { + let mut mtp = make_mtp(session)?; + let request = tl::functions::contacts::GetContacts { hash: 0 }.to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let contacts = tl::enums::contacts::Contacts::from_bytes(&resp_bytes) + .map_err(|e| format!("parse Contacts: {e}"))?; + + match contacts { + tl::enums::contacts::Contacts::Contacts(c) => { + let users: Vec = c + .users + .iter() + .filter_map(|u| match u { + tl::enums::User::User(u) => Some(UserInfo { + id: u.id, + first_name: u.first_name.clone().unwrap_or_default(), + last_name: u.last_name.clone(), + username: u.username.clone(), + phone_number: u.phone.clone(), + }), + _ => None, + }) + .collect(); + Ok(serde_json::to_string(&users).unwrap_or_default()) + } + tl::enums::contacts::Contacts::NotModified => Ok("[]".into()), + } +} + +pub fn get_chats(session: &Session, limit: i32) -> Result { + let mut mtp = make_mtp(session)?; + let request = tl::functions::messages::GetDialogs { + exclude_pinned: false, + folder_id: None, + offset_date: 0, + offset_id: 0, + offset_peer: tl::enums::InputPeer::Empty, + limit, + hash: 0, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let dialogs = tl::enums::messages::Dialogs::from_bytes(&resp_bytes) + .map_err(|e| format!("parse Dialogs: {e}"))?; + + let chats = extract_chats_from_dialogs(&dialogs); + Ok(serde_json::to_string(&chats).unwrap_or_default()) +} + +pub fn get_messages( + session: &Session, + chat_id: i64, + limit: i32, + from_message_id: Option, +) -> Result { + let mut mtp = make_mtp(session)?; + let peer = resolve_peer(chat_id); + + let request = tl::functions::messages::GetHistory { + peer, + offset_id: from_message_id.unwrap_or(0), + offset_date: 0, + add_offset: 0, + limit, + max_id: 0, + min_id: 0, + hash: 0, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let messages = tl::enums::messages::Messages::from_bytes(&resp_bytes) + .map_err(|e| format!("parse Messages: {e}"))?; + + let msgs = extract_messages(&messages); + Ok(serde_json::to_string(&msgs).unwrap_or_default()) +} + +pub fn send_message(session: &Session, chat_id: i64, text: &str) -> Result { + let mut mtp = make_mtp(session)?; + let peer = resolve_peer(chat_id); + + let mut rng_buf = [0u8; 8]; + getrandom::fill(&mut rng_buf).map_err(|e| format!("getrandom: {e}"))?; + let random_id = i64::from_le_bytes(rng_buf); + + let request = tl::functions::messages::SendMessage { + no_webpage: false, + silent: false, + background: false, + clear_draft: false, + noforwards: false, + update_stickersets_order: false, + invert_media: false, + allow_paid_floodskip: false, + peer, + reply_to: None, + message: text.to_string(), + random_id, + reply_markup: None, + entities: None, + schedule_date: None, + send_as: None, + quick_reply_shortcut: None, + effect: None, + allow_paid_stars: None, + suggested_post: None, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let result = + tl::enums::Updates::from_bytes(&resp_bytes).map_err(|e| format!("parse Updates: {e}"))?; + + match result { + tl::enums::Updates::UpdateShortSentMessage(m) => Ok(serde_json::to_string(&SendResult { + message_id: m.id, + date: m.date, + }) + .unwrap_or_default()), + _ => Ok(serde_json::to_string(&SendResult { + message_id: 0, + date: 0, + }) + .unwrap_or_default()), + } +} + +pub fn forward_message( + session: &Session, + from_chat_id: i64, + to_chat_id: i64, + message_ids: Vec, +) -> Result { + let mut mtp = make_mtp(session)?; + let from_peer = resolve_peer(from_chat_id); + let to_peer = resolve_peer(to_chat_id); + + let random_ids: Result, String> = message_ids + .iter() + .map(|_| { + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).map_err(|e| format!("getrandom: {e}"))?; + Ok(i64::from_le_bytes(buf)) + }) + .collect(); + + let request = tl::functions::messages::ForwardMessages { + silent: false, + background: false, + with_my_score: false, + drop_author: false, + drop_media_captions: false, + noforwards: false, + allow_paid_floodskip: false, + from_peer, + id: message_ids, + random_id: random_ids?, + to_peer, + top_msg_id: None, + reply_to: None, + schedule_date: None, + send_as: None, + quick_reply_shortcut: None, + video_timestamp: None, + allow_paid_stars: None, + suggested_post: None, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let _updates = + tl::enums::Updates::from_bytes(&resp_bytes).map_err(|e| format!("parse Updates: {e}"))?; + + Ok(serde_json::to_string(&ForwardResult { ok: true }).unwrap_or_default()) +} + +pub fn delete_messages( + session: &Session, + message_ids: Vec, + revoke: bool, +) -> Result { + let mut mtp = make_mtp(session)?; + let request = tl::functions::messages::DeleteMessages { + revoke, + id: message_ids, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let _affected = tl::enums::messages::AffectedMessages::from_bytes(&resp_bytes) + .map_err(|e| format!("parse AffectedMessages: {e}"))?; + + Ok(serde_json::to_string(&DeleteResult { ok: true }).unwrap_or_default()) +} + +pub fn search_messages( + session: &Session, + query: &str, + chat_id: Option, + limit: i32, +) -> Result { + let mut mtp = make_mtp(session)?; + + let request = if let Some(cid) = chat_id { + let peer = resolve_peer(cid); + tl::functions::messages::Search { + peer, + q: query.to_string(), + from_id: None, + saved_peer_id: None, + saved_reaction: None, + top_msg_id: None, + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_id: 0, + add_offset: 0, + limit, + max_id: 0, + min_id: 0, + hash: 0, + } + .to_bytes() + } else { + tl::functions::messages::SearchGlobal { + broadcasts_only: false, + groups_only: false, + users_only: false, + folder_id: None, + q: query.to_string(), + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_rate: 0, + offset_peer: tl::enums::InputPeer::Empty, + offset_id: 0, + limit, + } + .to_bytes() + }; + + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let messages = tl::enums::messages::Messages::from_bytes(&resp_bytes) + .map_err(|e| format!("parse Messages: {e}"))?; + + let msgs = extract_messages(&messages); + Ok(serde_json::to_string(&msgs).unwrap_or_default()) +} + +pub fn get_updates(session: &Session) -> Result { + let mut mtp = make_mtp(session)?; + + let request = tl::functions::updates::GetState {}.to_bytes(); + let resp_bytes = rpc_call(&mut mtp, session, request, true)?; + let state = tl::enums::updates::State::from_bytes(&resp_bytes) + .map_err(|e| format!("parse State: {e}"))?; + + let tl::enums::updates::State::State(s) = state; + + let request = tl::functions::updates::GetDifference { + pts: s.pts.saturating_sub(10), + pts_limit: None, + pts_total_limit: None, + date: s.date, + qts: s.qts, + qts_limit: None, + } + .to_bytes(); + + let resp_bytes = rpc_call(&mut mtp, session, request, false)?; + let diff = tl::enums::updates::Difference::from_bytes(&resp_bytes) + .map_err(|e| format!("parse Difference: {e}"))?; + + let updates = extract_updates_from_diff(&diff); + Ok(serde_json::to_string(&updates).unwrap_or_default()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Format a successful auth response with user info. +fn format_user_auth(user: &tl::enums::User) -> String { + match user { + tl::enums::User::User(u) => serde_json::to_string(&AuthResult { + status: "logged_in".into(), + user: Some(UserInfo { + id: u.id, + first_name: u.first_name.clone().unwrap_or_default(), + last_name: u.last_name.clone(), + username: u.username.clone(), + phone_number: u.phone.clone(), + }), + message: None, + }) + .unwrap_or_default(), + tl::enums::User::Empty(e) => serde_json::to_string(&AuthResult { + status: "logged_in".into(), + user: Some(UserInfo { + id: e.id, + first_name: "Unknown".into(), + last_name: None, + username: None, + phone_number: None, + }), + message: None, + }) + .unwrap_or_default(), + } +} + +/// Resolve a chat_id to an InputPeer. Negative IDs are channels/supergroups. +fn resolve_peer(chat_id: i64) -> tl::enums::InputPeer { + if chat_id > 0 { + tl::enums::InputPeer::User(tl::types::InputPeerUser { + user_id: chat_id, + access_hash: 0, + }) + } else { + let abs_id = chat_id.unsigned_abs() as i64; + if abs_id > 1_000_000_000_000 { + // Channel/supergroup: strip -100 prefix + let channel_id = abs_id - 1_000_000_000_000; + tl::enums::InputPeer::Channel(tl::types::InputPeerChannel { + channel_id, + access_hash: 0, + }) + } else { + tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: abs_id }) + } + } +} + +fn extract_chats_from_dialogs(dialogs: &tl::enums::messages::Dialogs) -> Vec { + let chats = match dialogs { + tl::enums::messages::Dialogs::Dialogs(d) => &d.chats, + tl::enums::messages::Dialogs::Slice(d) => &d.chats, + tl::enums::messages::Dialogs::NotModified(_) => return vec![], + }; + + chats.iter().filter_map(chat_to_info).collect() +} + +fn chat_to_info(chat: &tl::enums::Chat) -> Option { + match chat { + tl::enums::Chat::Chat(c) => Some(ChatInfo { + id: -(c.id), + chat_type: "group".into(), + title: Some(c.title.clone()), + username: None, + }), + tl::enums::Chat::Channel(c) => Some(ChatInfo { + id: -(1_000_000_000_000 + c.id), + chat_type: if c.megagroup { "supergroup" } else { "channel" }.into(), + title: Some(c.title.clone()), + username: c.username.clone(), + }), + tl::enums::Chat::Forbidden(c) => Some(ChatInfo { + id: -(c.id), + chat_type: "group".into(), + title: Some(c.title.clone()), + username: None, + }), + tl::enums::Chat::ChannelForbidden(c) => Some(ChatInfo { + id: -(1_000_000_000_000 + c.id), + chat_type: "channel".into(), + title: Some(c.title.clone()), + username: None, + }), + tl::enums::Chat::Empty(_) => None, + } +} + +fn extract_messages(msgs: &tl::enums::messages::Messages) -> Vec { + let messages = match msgs { + tl::enums::messages::Messages::Messages(m) => &m.messages, + tl::enums::messages::Messages::Slice(m) => &m.messages, + tl::enums::messages::Messages::ChannelMessages(m) => &m.messages, + tl::enums::messages::Messages::NotModified(_) => return vec![], + }; + + messages.iter().filter_map(message_to_info).collect() +} + +fn message_to_info(msg: &tl::enums::Message) -> Option { + match msg { + tl::enums::Message::Message(m) => Some(MessageInfo { + message_id: m.id, + date: m.date, + from_user_id: m.from_id.as_ref().and_then(peer_id), + text: Some(m.message.clone()), + chat_id: Some(peer_id_value(&m.peer_id)), + }), + tl::enums::Message::Service(m) => Some(MessageInfo { + message_id: m.id, + date: m.date, + from_user_id: m.from_id.as_ref().and_then(peer_id), + text: Some("[service message]".into()), + chat_id: Some(peer_id_value(&m.peer_id)), + }), + tl::enums::Message::Empty(_) => None, + } +} + +fn peer_id(peer: &tl::enums::Peer) -> Option { + Some(peer_id_value(peer)) +} + +fn peer_id_value(peer: &tl::enums::Peer) -> i64 { + match peer { + tl::enums::Peer::User(p) => p.user_id, + tl::enums::Peer::Chat(p) => -(p.chat_id), + tl::enums::Peer::Channel(p) => -(1_000_000_000_000 + p.channel_id), + } +} + +fn extract_updates_from_diff(diff: &tl::enums::updates::Difference) -> Vec { + match diff { + tl::enums::updates::Difference::Difference(d) => extract_update_list(&d.new_messages), + tl::enums::updates::Difference::Slice(d) => extract_update_list(&d.new_messages), + tl::enums::updates::Difference::Empty(_) => vec![], + tl::enums::updates::Difference::TooLong(_) => { + vec![UpdateInfo { + update_type: "too_long".into(), + message: None, + }] + } + } +} + +fn extract_update_list(messages: &[tl::enums::Message]) -> Vec { + messages + .iter() + .filter_map(|m| { + message_to_info(m).map(|info| UpdateInfo { + update_type: "new_message".into(), + message: Some(info), + }) + }) + .collect() +} diff --git a/tools-src/telegram/src/auth.rs b/tools-src/telegram/src/auth.rs new file mode 100644 index 00000000..4ca43889 --- /dev/null +++ b/tools-src/telegram/src/auth.rs @@ -0,0 +1,53 @@ +use grammers_mtproto::authentication; +use grammers_tl_types::{self as tl, Deserializable}; + +use crate::session::Session; +use crate::transport; + +/// Perform the full DH auth key exchange with a Telegram DC. +/// +/// This drives the Sans-IO `grammers_mtproto::authentication` module over +/// HTTP transport. Four round trips: +/// +/// 1. step1 -> ReqPqMulti -> server returns ResPq +/// 2. step2 -> ReqDhParams -> server returns ServerDhParams +/// 3. step3 -> SetClientDhParams -> server returns DhGen answer +/// 4. create_key -> produces auth_key, salt, time_offset +pub fn generate_auth_key(session: &mut Session) -> Result<(), String> { + let dc_id = session.dc_id; + + // Step 1: generate nonce, send ReqPqMulti + let (request, step1_data) = + authentication::step1().map_err(|e| format!("auth step1 failed: {e}"))?; + + let response_bytes = transport::post_plain(dc_id, &request)?; + let res_pq = tl::enums::ResPq::from_bytes(&response_bytes) + .map_err(|e| format!("failed to parse ResPq: {e}"))?; + + // Step 2: factorize PQ, RSA encrypt, send ReqDhParams + let (request, step2_data) = + authentication::step2(step1_data, res_pq).map_err(|e| format!("auth step2 failed: {e}"))?; + + let response_bytes = transport::post_plain(dc_id, &request)?; + let server_dh = tl::enums::ServerDhParams::from_bytes(&response_bytes) + .map_err(|e| format!("failed to parse ServerDhParams: {e}"))?; + + // Step 3: compute DH g_b, send SetClientDhParams + let (request, step3_data) = authentication::step3(step2_data, server_dh) + .map_err(|e| format!("auth step3 failed: {e}"))?; + + let response_bytes = transport::post_plain(dc_id, &request)?; + let dh_answer = tl::enums::SetClientDhParamsAnswer::from_bytes(&response_bytes) + .map_err(|e| format!("failed to parse DhGenAnswer: {e}"))?; + + // Final: derive auth key from shared secret + let finished = authentication::create_key(step3_data, dh_answer) + .map_err(|e| format!("auth create_key failed: {e}"))?; + + session.set_auth_key(&finished.auth_key); + session.first_salt = finished.first_salt; + session.time_offset = finished.time_offset; + session.initialized = true; + + Ok(()) +} diff --git a/tools-src/telegram/src/lib.rs b/tools-src/telegram/src/lib.rs new file mode 100644 index 00000000..cdf73ee8 --- /dev/null +++ b/tools-src/telegram/src/lib.rs @@ -0,0 +1,401 @@ +//! Telegram User-Mode WASM Tool for IronClaw. +//! +//! Provides Telegram integration operating from the **user's personal account**, +//! not a bot. This tool sends encrypted MTProto messages directly to Telegram's +//! data centers via HTTPS POST, using the grammers crate for the Sans-IO +//! protocol implementation. +//! +//! # Architecture +//! +//! ```text +//! WASM Tool ──MTProto/HTTPS──► Telegram DC (*.web.telegram.org/apiw) +//! ``` +//! +//! No Docker container, no middleware. The tool performs the DH key exchange, +//! encrypts requests with the auth key, and POSTs raw ciphertext to Telegram's +//! web transport endpoint. +//! +//! # Session Persistence +//! +//! Session state (auth key, salt, DC, login status) is stored in the workspace +//! at `telegram/session.json`. The agent should save updated session data after +//! auth actions using `memory_write`. +//! +//! # Prerequisites +//! +//! 1. Get Telegram API credentials from https://my.telegram.org/apps +//! 2. Store them: `ironclaw secret set telegram_api_id ` +//! `ironclaw secret set telegram_api_hash ` +//! 3. Use the `login` action with your phone number +//! +//! # Authentication Flow +//! +//! 1. Call `login` with your phone number +//! - Generates an auth key (DH exchange with Telegram DC) +//! - Sends verification code to your phone +//! - Returns session data and phone_code_hash +//! 2. Call `submit_auth_code` with the verification code +//! 3. Call `submit_2fa_password` if you have 2FA enabled +//! 4. After each auth step, save the returned `session` JSON to +//! `telegram/session.json` via `memory_write` +//! +//! # Privacy +//! +//! - `get_messages` does NOT mark messages as read +//! - Messages are read via `messages.getHistory`, not `getUpdates` + +mod api; +mod auth; +mod session; +mod transport; +mod types; + +use session::Session; +use types::TelegramAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct TelegramTool; + +impl exports::near::agent::tool::Guest for TelegramTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + SCHEMA.to_string() + } + + fn description() -> String { + "Telegram user-mode integration for reading and sending messages from the user's \ + personal account. Supports contacts, chat history, message search, sending, \ + forwarding, and deletion. Communicates directly with Telegram's servers via \ + encrypted MTProto over HTTPS (no Docker/TDLight needed). Does NOT mark messages \ + as read when reading history. Use the 'login' action to authenticate with your \ + phone number. Session state is persisted in the workspace at telegram/session.json." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + let action: TelegramAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?; + + near::agent::host::log( + near::agent::host::LogLevel::Info, + &format!("Executing Telegram action: {action:?}"), + ); + + match action { + TelegramAction::Login { phone_number } => execute_login(&phone_number), + TelegramAction::SubmitAuthCode { code } => execute_submit_code(&code), + TelegramAction::Submit2faPassword { password } => execute_submit_2fa(&password), + TelegramAction::GetMe => with_session(api::get_me), + TelegramAction::GetContacts => with_session(api::get_contacts), + TelegramAction::GetChats { limit } => with_session(|s| api::get_chats(s, limit)), + TelegramAction::GetMessages { + chat_id, + limit, + from_message_id, + } => with_session(|s| api::get_messages(s, chat_id, limit, from_message_id)), + TelegramAction::SendMessage { chat_id, text } => { + with_session(|s| api::send_message(s, chat_id, &text)) + } + TelegramAction::ForwardMessage { + from_chat_id, + to_chat_id, + message_ids, + } => with_session(|s| api::forward_message(s, from_chat_id, to_chat_id, message_ids)), + TelegramAction::DeleteMessage { + message_ids, + revoke, + } => with_session(|s| api::delete_messages(s, message_ids, revoke)), + TelegramAction::SearchMessages { + query, + chat_id, + limit, + } => with_session(|s| api::search_messages(s, &query, chat_id, limit)), + TelegramAction::GetUpdates => with_session(api::get_updates), + } +} + +/// Load session from workspace, verify it's initialized and logged in, then run the action. +fn with_session(f: impl FnOnce(&Session) -> Result) -> Result { + let session = session::load_session().ok_or( + "No session found. Use the 'login' action first, then save the returned session \ + to telegram/session.json via memory_write." + .to_string(), + )?; + + if !session.initialized { + return Err("Session exists but auth key not generated. Run 'login' again.".into()); + } + if !session.logged_in { + return Err("Session exists but not logged in. Complete the login flow \ + (submit_auth_code / submit_2fa_password)." + .into()); + } + + f(&session) +} + +/// Login flow: create session, generate auth key, send verification code. +fn execute_login(phone_number: &str) -> Result { + let api_id = get_api_id()?; + let api_hash = get_api_hash()?; + + // Default to DC2 (Venus) as it's commonly assigned to new sessions + let dc_id = 2u8; + + let mut session = Session::new(api_id, api_hash, dc_id); + session.phone_number = Some(phone_number.to_string()); + + // Step 1: DH auth key exchange + near::agent::host::log( + near::agent::host::LogLevel::Info, + "Starting DH auth key exchange with Telegram DC...", + ); + auth::generate_auth_key(&mut session)?; + + // Step 2: send verification code + near::agent::host::log( + near::agent::host::LogLevel::Info, + "Auth key generated. Sending verification code...", + ); + let result = api::send_code(&mut session)?; + + // Return session + result so agent can persist it + let session_json = session::session_to_json(&session)?; + Ok(format!( + "{{\"result\":{result},\"session\":{session_json},\"instructions\":\ + \"Save the 'session' object to telegram/session.json using memory_write.\"}}" + )) +} + +/// Submit auth code, return updated session. +fn execute_submit_code(code: &str) -> Result { + let mut session = + session::load_session().ok_or("No session found. Use 'login' first.".to_string())?; + + let result = api::sign_in(&mut session, code)?; + let session_json = session::session_to_json(&session)?; + + Ok(format!( + "{{\"result\":{result},\"session\":{session_json},\"instructions\":\ + \"Save the 'session' object to telegram/session.json using memory_write.\"}}" + )) +} + +/// Submit 2FA password, return updated session. +fn execute_submit_2fa(password: &str) -> Result { + let mut session = + session::load_session().ok_or("No session found. Use 'login' first.".to_string())?; + + let result = api::check_password(&mut session, password)?; + let session_json = session::session_to_json(&session)?; + + Ok(format!( + "{{\"result\":{result},\"session\":{session_json},\"instructions\":\ + \"Save the 'session' object to telegram/session.json using memory_write.\"}}" + )) +} + +/// Read api_id from params or check secret existence. +fn get_api_id() -> Result { + // The secret store holds the value but WASM can't read it directly. + // The api_id is injected via env or must be in capabilities. + // For now, read from workspace config if available. + if let Some(val) = near::agent::host::workspace_read("telegram/api_id") { + return val + .trim() + .parse::() + .map_err(|e| format!("invalid api_id in workspace: {e}")); + } + Err( + "Telegram API ID not found. Store it in workspace at telegram/api_id \ + (just the numeric value) using memory_write." + .into(), + ) +} + +fn get_api_hash() -> Result { + if let Some(val) = near::agent::host::workspace_read("telegram/api_hash") { + let trimmed = val.trim().to_string(); + if trimmed.is_empty() { + return Err("telegram/api_hash is empty".into()); + } + return Ok(trimmed); + } + Err( + "Telegram API hash not found. Store it in workspace at telegram/api_hash \ + using memory_write." + .into(), + ) +} + +const SCHEMA: &str = r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "login" }, + "phone_number": { + "type": "string", + "description": "Phone number in international format (e.g., '+1234567890')" + } + }, + "required": ["action", "phone_number"] + }, + { + "properties": { + "action": { "const": "submit_auth_code" }, + "code": { + "type": "string", + "description": "Verification code received via SMS or Telegram" + } + }, + "required": ["action", "code"] + }, + { + "properties": { + "action": { "const": "submit_2fa_password" }, + "password": { + "type": "string", + "description": "Two-factor authentication password" + } + }, + "required": ["action", "password"] + }, + { + "properties": { + "action": { "const": "get_me" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_contacts" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_chats" }, + "limit": { + "type": "integer", + "description": "Maximum number of chats to return (default: 20)", + "default": 20 + } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_messages" }, + "chat_id": { + "type": "integer", + "description": "Chat ID (negative for groups/channels)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of messages (default: 20)", + "default": 20 + }, + "from_message_id": { + "type": "integer", + "description": "Start from this message ID for pagination" + } + }, + "required": ["action", "chat_id"] + }, + { + "properties": { + "action": { "const": "send_message" }, + "chat_id": { + "type": "integer", + "description": "Chat ID to send the message to" + }, + "text": { + "type": "string", + "description": "Message text" + } + }, + "required": ["action", "chat_id", "text"] + }, + { + "properties": { + "action": { "const": "forward_message" }, + "from_chat_id": { + "type": "integer", + "description": "Source chat ID" + }, + "to_chat_id": { + "type": "integer", + "description": "Destination chat ID" + }, + "message_ids": { + "type": "array", + "items": { "type": "integer" }, + "description": "Message IDs to forward" + } + }, + "required": ["action", "from_chat_id", "to_chat_id", "message_ids"] + }, + { + "properties": { + "action": { "const": "delete_message" }, + "message_ids": { + "type": "array", + "items": { "type": "integer" }, + "description": "Message IDs to delete" + }, + "revoke": { + "type": "boolean", + "description": "Also delete for other participants (default: false)", + "default": false + } + }, + "required": ["action", "message_ids"] + }, + { + "properties": { + "action": { "const": "search_messages" }, + "query": { + "type": "string", + "description": "Search query" + }, + "chat_id": { + "type": "integer", + "description": "Chat ID to search within (omit for global search)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 20)", + "default": 20 + } + }, + "required": ["action", "query"] + }, + { + "properties": { + "action": { "const": "get_updates" } + }, + "required": ["action"] + } + ] +}"#; + +export!(TelegramTool); diff --git a/tools-src/telegram/src/session.rs b/tools-src/telegram/src/session.rs new file mode 100644 index 00000000..9113e10f --- /dev/null +++ b/tools-src/telegram/src/session.rs @@ -0,0 +1,142 @@ +use serde::{Deserialize, Serialize}; + +/// Persistent session state, stored as base64 in the workspace at telegram/session.json. +/// +/// Contains everything needed to resume an encrypted MTProto session between +/// WASM invocations: auth key, server salt, DC identifier, API credentials, +/// and transient login state. +#[derive(Clone, Serialize, Deserialize)] +pub struct Session { + /// 256-byte auth key from the DH exchange, hex-encoded for JSON safety. + pub auth_key_hex: String, + /// First salt from DH exchange (or most recent salt from server). + pub first_salt: i64, + /// Time offset from server, in seconds. + pub time_offset: i32, + /// Telegram data center ID (1-5). + pub dc_id: u8, + /// Telegram API ID from my.telegram.org. + pub api_id: i32, + /// Telegram API hash from my.telegram.org. + pub api_hash: String, + /// Whether this session has completed auth key generation. + pub initialized: bool, + /// Whether a user is logged in. + pub logged_in: bool, + /// Transient: phone_code_hash from auth.sendCode, needed for auth.signIn. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_code_hash: Option, + /// Transient: phone number used during login. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, +} + +impl Session { + pub fn new(api_id: i32, api_hash: String, dc_id: u8) -> Self { + Self { + auth_key_hex: String::new(), + first_salt: 0, + time_offset: 0, + dc_id, + api_id, + api_hash, + initialized: false, + logged_in: false, + phone_code_hash: None, + phone_number: None, + } + } + + pub fn auth_key_bytes(&self) -> Result<[u8; 256], String> { + let bytes = hex_decode(&self.auth_key_hex) + .map_err(|e| format!("corrupt auth_key_hex in session: {e}"))?; + if bytes.len() != 256 { + return Err(format!( + "auth_key_hex decoded to {} bytes, expected 256", + bytes.len() + )); + } + let mut key = [0u8; 256]; + key.copy_from_slice(&bytes); + Ok(key) + } + + pub fn set_auth_key(&mut self, key: &[u8; 256]) { + self.auth_key_hex = hex_encode(key); + } +} + +/// Load session from workspace (returns None if not found or unparseable). +pub fn load_session() -> Option { + let data = crate::near::agent::host::workspace_read("telegram/session.json")?; + serde_json::from_str(&data).ok() +} + +/// Serialize session to JSON for the agent to store via memory_write. +pub fn session_to_json(session: &Session) -> Result { + serde_json::to_string_pretty(session).map_err(|e| format!("session serialize failed: {e}")) +} + +// Minimal hex encode/decode (no extra dep needed). + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0xf) as usize] as char); + } + out +} + +fn hex_decode(s: &str) -> Result, String> { + if s.len() % 2 != 0 { + return Err("odd-length hex string".into()); + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + for chunk in bytes.chunks(2) { + let hi = hex_val(chunk[0])?; + let lo = hex_val(chunk[1])?; + out.push((hi << 4) | lo); + } + Ok(out) +} + +fn hex_val(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(format!("invalid hex char: {b}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_roundtrip() { + let data = [0u8, 1, 15, 16, 255, 128, 64]; + let encoded = hex_encode(&data); + assert_eq!(encoded, "00010f10ff8040"); + let decoded = hex_decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn session_serialization() { + let mut session = Session::new(12345, "abcdef".into(), 2); + let key = [42u8; 256]; + session.set_auth_key(&key); + session.initialized = true; + + let json = session_to_json(&session).unwrap(); + let restored: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.auth_key_bytes().unwrap(), key); + assert_eq!(restored.api_id, 12345); + assert_eq!(restored.dc_id, 2); + assert!(restored.initialized); + } +} diff --git a/tools-src/telegram/src/transport.rs b/tools-src/telegram/src/transport.rs new file mode 100644 index 00000000..48624ef9 --- /dev/null +++ b/tools-src/telegram/src/transport.rs @@ -0,0 +1,131 @@ +use grammers_crypto::DequeBuffer; +use grammers_mtproto::mtp::{Deserialization, Encrypted, Mtp, Plain}; +use grammers_tl_types::Serializable; + +use crate::near::agent::host; + +/// DC names indexed by dc_id (1-based). DC1=pluto, DC2=venus, etc. +const DC_NAMES: &[&str] = &["", "pluto", "venus", "aurora", "vesta", "flora"]; + +/// Build the HTTPS URL for a Telegram data center's web transport endpoint. +pub fn dc_url(dc_id: u8) -> Result { + let idx = dc_id as usize; + if idx == 0 || idx >= DC_NAMES.len() { + return Err(format!("invalid dc_id {dc_id}, must be 1-5")); + } + Ok(format!("https://{}.web.telegram.org/apiw", DC_NAMES[idx])) +} + +/// Send a plaintext (unencrypted) MTProto request via HTTP POST. +/// +/// Used during auth key generation. The request is a TL-serializable type; +/// the response bytes are returned raw for the caller to deserialize. +pub fn post_plain(dc_id: u8, request: &R) -> Result, String> { + let url = dc_url(dc_id)?; + let mut plain = Plain::new(); + let mut buffer = DequeBuffer::with_capacity(0, 0); + + let request_bytes = request.to_bytes(); + plain + .push(&mut buffer, &request_bytes) + .ok_or("plain push returned None")?; + plain.finalize(&mut buffer); + + let body: Vec = buffer[..].to_vec(); + let response = http_post_binary(&url, &body)?; + + let results = plain + .deserialize(&response) + .map_err(|e| format!("plain deserialize: {e}"))?; + + for result in results { + if let Deserialization::RpcResult(rpc) = result { + return Ok(rpc.body); + } + } + Err("no RPC result in plain response".into()) +} + +/// Send an encrypted MTProto RPC request via HTTP POST. +/// +/// Pushes a serialized TL request into the Encrypted MTP, finalizes (encrypts), +/// POSTs the ciphertext, then deserializes the response. +/// +/// Returns the first RPC result body for the caller to deserialize as the +/// expected response type. +pub fn post_encrypted( + mtp: &mut Encrypted, + dc_id: u8, + request_bytes: &[u8], +) -> Result, String> { + let url = dc_url(dc_id)?; + let mut buffer = DequeBuffer::with_capacity(0, 0); + + mtp.push(&mut buffer, request_bytes) + .ok_or("encrypted push returned None")?; + mtp.finalize(&mut buffer); + + let body: Vec = buffer[..].to_vec(); + let response = http_post_binary(&url, &body)?; + + let results = mtp + .deserialize(&response) + .map_err(|e| format!("encrypted deserialize: {e}"))?; + + for result in results { + match result { + Deserialization::RpcResult(rpc) => return Ok(rpc.body), + Deserialization::RpcError(err) => { + return Err(format!( + "RPC error {}: {}", + err.error.error_code, err.error.error_message + )); + } + _ => {} + } + } + Err("no RPC result in encrypted response".into()) +} + +/// HTTP POST with raw binary body via the WASM host's http-request capability. +fn http_post_binary(url: &str, body: &[u8]) -> Result, String> { + let resp = host::http_request("POST", url, "{}", Some(body))?; + + if resp.status < 200 || resp.status >= 300 { + let body_text = String::from_utf8_lossy(&resp.body); + return Err(format!( + "HTTP {} from {}: {}", + resp.status, + url, + truncate(&body_text, 200) + )); + } + + Ok(resp.body) +} + +fn truncate(s: &str, max: usize) -> &str { + if s.len() <= max { + s + } else { + &s[..max] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dc_url_valid() { + assert_eq!(dc_url(1).unwrap(), "https://pluto.web.telegram.org/apiw"); + assert_eq!(dc_url(2).unwrap(), "https://venus.web.telegram.org/apiw"); + assert_eq!(dc_url(5).unwrap(), "https://flora.web.telegram.org/apiw"); + } + + #[test] + fn dc_url_invalid() { + assert!(dc_url(0).is_err()); + assert!(dc_url(6).is_err()); + } +} diff --git a/tools-src/telegram/src/types.rs b/tools-src/telegram/src/types.rs new file mode 100644 index 00000000..87c5506a --- /dev/null +++ b/tools-src/telegram/src/types.rs @@ -0,0 +1,190 @@ +//! Types for the Telegram user-mode tool (MTProto direct). + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Telegram tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum TelegramAction { + /// Start login: generate auth key + send verification code. + Login { + /// Phone number in international format (e.g., "+1234567890"). + phone_number: String, + }, + + /// Submit the verification code received after login. + SubmitAuthCode { + /// The verification code received via SMS or Telegram. + code: String, + }, + + /// Submit 2FA password if the account has two-factor auth enabled. + Submit2faPassword { + /// The two-factor authentication password. + password: String, + }, + + /// Get the authenticated user's profile info. + GetMe, + + /// Get the user's contact list. + GetContacts, + + /// List the user's recent chats/conversations. + GetChats { + /// Maximum number of chats to return (default: 20). + #[serde(default = "default_chat_limit")] + limit: i32, + }, + + /// Read message history from a chat. Does NOT mark messages as read. + GetMessages { + /// Chat ID (numeric, negative for groups/channels). + chat_id: i64, + /// Maximum number of messages to return (default: 20). + #[serde(default = "default_message_limit")] + limit: i32, + /// Return messages starting from this message ID (for pagination). + #[serde(default)] + from_message_id: Option, + }, + + /// Send a text message to a chat. + SendMessage { + /// Chat ID to send the message to. + chat_id: i64, + /// Message text. + text: String, + }, + + /// Forward messages from one chat to another. + ForwardMessage { + /// Source chat ID. + from_chat_id: i64, + /// Destination chat ID. + to_chat_id: i64, + /// Message IDs to forward. + message_ids: Vec, + }, + + /// Delete messages. + DeleteMessage { + /// Message IDs to delete. + message_ids: Vec, + /// Also delete for other participants (default: false). + #[serde(default)] + revoke: bool, + }, + + /// Search for messages across chats or within a specific chat. + SearchMessages { + /// Query string to search for. + query: String, + /// Chat ID to search within (omit for global search). + #[serde(default)] + chat_id: Option, + /// Maximum number of results (default: 20). + #[serde(default = "default_message_limit")] + limit: i32, + }, + + /// Poll for new incoming updates. + GetUpdates, +} + +fn default_chat_limit() -> i32 { + 20 +} + +fn default_message_limit() -> i32 { + 20 +} + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +/// Result from the login action (code_sent phase). +#[derive(Debug, Serialize)] +pub struct LoginResult { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_code_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Result from auth code / 2FA / signIn. +#[derive(Debug, Serialize)] +pub struct AuthResult { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// User profile info. +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub id: i64, + pub first_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, +} + +/// Chat information. +#[derive(Debug, Serialize)] +pub struct ChatInfo { + pub id: i64, + #[serde(rename = "type")] + pub chat_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +/// A message in a chat. +#[derive(Debug, Serialize)] +pub struct MessageInfo { + pub message_id: i32, + pub date: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chat_id: Option, +} + +/// Result from sending a message. +#[derive(Debug, Serialize)] +pub struct SendResult { + pub message_id: i32, + pub date: i32, +} + +/// Result from forwarding messages. +#[derive(Debug, Serialize)] +pub struct ForwardResult { + pub ok: bool, +} + +/// Result from deleting messages. +#[derive(Debug, Serialize)] +pub struct DeleteResult { + pub ok: bool, +} + +/// An update from getDifference. +#[derive(Debug, Serialize)] +pub struct UpdateInfo { + pub update_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} diff --git a/tools-src/telegram/telegram-tool.capabilities.json b/tools-src/telegram/telegram-tool.capabilities.json new file mode 100644 index 00000000..03736e06 --- /dev/null +++ b/tools-src/telegram/telegram-tool.capabilities.json @@ -0,0 +1,28 @@ +{ + "http": { + "allowlist": [ + { + "host": "*.web.telegram.org", + "path_prefix": "/apiw", + "methods": ["POST"] + } + ], + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 500 + }, + "timeout_secs": 60 + }, + "workspace": { + "allowed_prefixes": ["telegram/"] + }, + "secrets": { + "allowed_names": ["telegram_api_id", "telegram_api_hash"] + }, + "auth": { + "secret_name": "telegram_api_id", + "display_name": "Telegram", + "instructions": "1. Go to https://my.telegram.org/apps and create an app\n2. Store your API ID and hash in the workspace:\n - Write your numeric API ID to telegram/api_id\n - Write your API hash string to telegram/api_hash\n3. Use the 'login' action with your phone number\n4. Use 'submit_auth_code' with the code you receive\n5. Use 'submit_2fa_password' if you have 2FA enabled\n6. Save the returned session JSON to telegram/session.json", + "setup_url": "https://my.telegram.org/apps" + } +} diff --git a/tools-src/wasm-tools/slack/slack-tool.capabilities.json b/tools-src/wasm-tools/slack/slack-tool.capabilities.json deleted file mode 100644 index b034b0fa..00000000 --- a/tools-src/wasm-tools/slack/slack-tool.capabilities.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "http": { - "allowlist": [ - { - "host": "slack.com", - "path_prefix": "/api/", - "methods": ["GET", "POST"] - } - ], - "credentials": { - "slack_bot_token": { - "secret_name": "slack_bot_token", - "location": { "type": "bearer" }, - "host_patterns": ["slack.com"] - } - }, - "rate_limit": { - "requests_per_minute": 50, - "requests_per_hour": 1000 - }, - "timeout_secs": 30 - }, - "secrets": { - "allowed_names": ["slack_bot_token"] - } -}