mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
- 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]>
531 lines
17 KiB
Rust
531 lines
17 KiB
Rust
//! 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<String, String> {
|
|
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(), None)?;
|
|
|
|
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<CreateSpreadsheetResult, String> {
|
|
let sheets: Vec<serde_json::Value> = 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<SpreadsheetMetadata, String> {
|
|
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<ValuesResult, String> {
|
|
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<BatchValuesResult, String> {
|
|
let range_params: Vec<String> = 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<serde_json::Value>],
|
|
value_input_option: &str,
|
|
) -> Result<UpdateResult, String> {
|
|
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<serde_json::Value>],
|
|
value_input_option: &str,
|
|
) -> Result<UpdateResult, String> {
|
|
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<ClearResult, String> {
|
|
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<serde_json::Value>,
|
|
) -> Result<serde_json::Value, String> {
|
|
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<AddSheetResult, String> {
|
|
let requests = vec![serde_json::json!({
|
|
"addSheet": {
|
|
"properties": {
|
|
"title": title
|
|
}
|
|
}
|
|
})];
|
|
|
|
let parsed = batch_update(spreadsheet_id, requests)?;
|
|
|
|
let reply = parsed["replies"]
|
|
.as_array()
|
|
.and_then(|arr| arr.first())
|
|
.map(|r| &r["addSheet"]["properties"]);
|
|
|
|
let reply = reply.ok_or("No reply from batch update")?;
|
|
|
|
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<SheetOperationResult, String> {
|
|
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<SheetOperationResult, String> {
|
|
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<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!({
|
|
"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<bool>,
|
|
pub italic: Option<bool>,
|
|
pub font_size: Option<i64>,
|
|
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<FormatResult, String> {
|
|
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";
|