Files
optimclaw/tools-src/google-docs/src/api.rs
T
Illia PolosukhinandClaude Opus 4.6 a0e01f04d3 fix: address critical/high audit findings across WASM sub-crates
- Telegram: remove .unwrap() panic on workspace_read (owner_id check)
- WhatsApp: use configured api_version instead of hardcoded v18.0
- WhatsApp: log config parse errors before falling back to defaults
- Slack: log serialization errors in emit_message and json_response
- Google Docs: safe array access for batch update replies
- Google Sheets: safe array access for add_sheet replies
- Google Calendar: fix doc comment secret name mismatch
- Gmail: avoid unnecessary String allocation in UNREAD check

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 12:54:20 -08:00

522 lines
16 KiB
Rust

//! 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<String, String> {
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(), None)?;
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<serde_json::Value>,
) -> Result<serde_json::Value, String> {
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<CreateDocumentResult, String> {
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<DocumentMetadata, String> {
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<ReadContentResult, String> {
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<UpdateResult, String> {
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<UpdateResult, String> {
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<ReplaceResult, String> {
let request = serde_json::json!({
"replaceAllText": {
"containsText": {
"text": find,
"matchCase": match_case,
},
"replaceText": replace,
}
});
let parsed = batch_update_raw(document_id, vec![request])?;
let first_reply = parsed["replies"].as_array().and_then(|arr| arr.first());
let occurrences = first_reply
.map(|r| {
r["replaceAllText"]["occurrencesChanged"]
.as_i64()
.unwrap_or(0)
})
.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<serde_json::Value> {
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<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub strikethrough: Option<bool>,
pub font_size: Option<f64>,
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<UpdateResult, String> {
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<f64>,
) -> Result<UpdateResult, String> {
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<UpdateResult, String> {
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<UpdateResult, String> {
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<serde_json::Value>,
) -> Result<BatchUpdateResult, String> {
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";