feat: implement household workflows binary files and write receipts

This commit is contained in:
LucaPisl
2026-10-01 01:15:23 +03:00
parent 2131974765
commit f604ef84a4
7 changed files with 690 additions and 28 deletions

240
src/tools/actions.rs Normal file
View File

@@ -0,0 +1,240 @@
use crate::{
catalog::ValidatedCall,
client::{ApiClient, ApiRequest, RequestBody},
error::{AppError, Result},
model::{ToolOutput, Verification, WriteReport, WriteStatus},
};
use rust_decimal::Decimal;
use serde_json::{Value, json};
fn build(call: &ValidatedCall) -> ApiRequest {
let mut r = ApiRequest::get(call.op.route);
r.method = call.op.method;
for (key, param) in &call.op.path_params {
let value = &call.args[key];
r.params.insert(
param.clone(),
value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string()),
);
}
if !call.op.body_keys.is_empty() {
let body = call
.op
.body_keys
.iter()
.filter_map(|k| call.args.get(k).map(|v| (k.clone(), v.clone())))
.collect::<serde_json::Map<_, _>>();
r.body = RequestBody::Json(Value::Object(body));
}
r
}
fn readback(route: &'static str, param: &str, id: &Value) -> ApiRequest {
let mut r = ApiRequest::get(route);
r.params.insert(
param.into(),
id.as_str()
.map(str::to_owned)
.unwrap_or_else(|| id.to_string()),
);
r
}
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
let mut r = build(call);
let target = r.target();
let mut verify = None;
if call.op.mutates {
if let Some(id) = call
.args
.get("product_id")
.or_else(|| call.args.get("product_id_to_keep"))
{
let read = readback("/stock/products/{productId}", "productId", id);
let details = client.request(read).await?.json()?;
if let Some(unit) = call.args.get("quantity_unit_id") {
apply_conversion(client, call, &details, &mut r, unit).await?;
}
verify = Some(readback("/stock/products/{productId}", "productId", id));
} else if let Some(barcode) = call.args.get("barcode") {
let read = readback("/stock/products/by-barcode/{barcode}", "barcode", barcode);
let details = client.request(read).await?.json()?;
if let Some(unit) = call.args.get("quantity_unit_id") {
apply_conversion(client, call, &details, &mut r, unit).await?;
}
verify = Some(readback(
"/stock/products/by-barcode/{barcode}",
"barcode",
barcode,
));
} else if let Some(id) = call.args.get("entry_id") {
let details = client
.request(readback("/stock/entry/{entryId}", "entryId", id))
.await?
.json()?;
if let Some(unit) = call.args.get("quantity_unit_id") {
let details = json!({"product":super::records::get_record(client,"products",&details["product_id"]).await?});
apply_conversion(client, call, &details, &mut r, unit).await?;
}
verify = Some(readback("/stock/entry/{entryId}", "entryId", id));
} else if let Some(id) = call.args.get("recipe_id") {
super::records::get_record(client, "recipes", id).await?;
verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id));
} else if let Some(id) = call
.args
.get("chore_id")
.or_else(|| call.args.get("chore_id_to_keep"))
{
super::records::get_record(client, "chores", id).await?;
verify = Some(readback("/chores/{choreId}", "choreId", id));
} else if let Some(id) = call.args.get("battery_id") {
super::records::get_record(client, "batteries", id).await?;
verify = Some(readback("/batteries/{batteryId}", "batteryId", id));
} else if let Some(id) = call.args.get("task_id") {
super::records::get_record(client, "tasks", id).await?;
verify = Some(readback("/objects/{entity}/{id}", "id", id));
verify
.as_mut()
.unwrap()
.params
.insert("entity".into(), "tasks".into());
} else if let Some(id) = call.args.get("transaction_id") {
client
.request(readback(
"/stock/transactions/{transactionId}",
"transactionId",
id,
))
.await?;
verify = Some(readback(
"/stock/transactions/{transactionId}",
"transactionId",
id,
));
} else if let Some(id) = call.args.get("booking_id") {
client
.request(readback("/stock/bookings/{bookingId}", "bookingId", id))
.await?;
verify = Some(readback("/stock/bookings/{bookingId}", "bookingId", id));
} else if let Some(id) = call.args.get("execution_id") {
let log = super::records::get_record(client, "chores_log", id).await?;
verify = Some(readback("/chores/{choreId}", "choreId", &log["chore_id"]));
} else if let Some(id) = call.args.get("charge_cycle_id") {
let log = super::records::get_record(client, "battery_charge_cycles", id).await?;
verify = Some(readback(
"/batteries/{batteryId}",
"batteryId",
&log["battery_id"],
));
} else if call.op.name.starts_with("shopping_") {
if let Some(id) = call.args.get("list_id") {
super::records::get_record(client, "shopping_lists", id).await?;
}
let mut q = ApiRequest::get("/objects/{entity}");
q.params.insert("entity".into(), "shopping_list".into());
verify = Some(q);
}
for key in ["location_id", "location_id_from", "location_id_to"] {
if let Some(id) = call.args.get(key) {
super::records::get_record(client, "locations", id).await?;
}
}
if call.op.name.starts_with("stock_add") || call.op.name.starts_with("stock_inventory") {
if let RequestBody::Json(body) = &mut r.body {
body["stock_label_type"] = json!(1);
}
}
}
if call.op.mutates && call.op.name.starts_with("shopping_") {
let mut q = ApiRequest::get("/objects/{entity}");
q.params.insert("entity".into(), "shopping_list".into());
verify = Some(q);
}
if call.op.mutates && call.op.name == "chores_recalculate_assignments" {
verify = Some(ApiRequest::get("/chores"));
}
let receipt = client.request(r).await?.json()?;
if !call.op.mutates {
let data = if let Some(arr) = receipt.as_array() {
call.page.slice(arr.clone())
} else if call.op.name == "stock_volatile" {
let mut grouped = serde_json::Map::new();
for (k, v) in receipt.as_object().ok_or_else(AppError::input)? {
grouped.insert(
k.clone(),
if let Some(a) = v.as_array() {
call.page.slice(a.clone())
} else {
v.clone()
},
);
}
Value::Object(grouped)
} else {
receipt
};
return Ok(ToolOutput::data(data));
}
if call.op.name == "stock_copy" {
if let Some(id) = receipt.get("created_object_id") {
verify = Some(readback("/stock/products/{productId}", "productId", id));
}
}
if call.op.name == "recipe_copy" {
if let Some(id) = receipt.get("created_object_id") {
verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id));
}
}
let (data, verification) = if let Some(q) = verify {
match client.request(q).await.and_then(|r| r.json()) {
Ok(v) => (v, Verification::Confirmed),
Err(_) => (Value::Null, Verification::Incomplete),
}
} else {
(Value::Null, Verification::Unavailable)
};
Ok(ToolOutput {
data,
write: Some(WriteReport {
status: WriteStatus::Completed,
verification,
target,
receipt,
}),
attachment: None,
})
}
async fn apply_conversion(
client: &ApiClient,
call: &ValidatedCall,
details: &Value,
r: &mut ApiRequest,
unit: &Value,
) -> Result<()> {
let product = details.get("product").unwrap_or(details);
let id = super::validation::record_id(&product["id"])?;
let stock = super::validation::record_id(&product["qu_id_stock"])?;
let from = super::validation::record_id(unit)?;
let conversions = if from != stock {
super::validation::load_conversions(client, id).await?
} else {
vec![]
};
let field = if call.args.get("new_amount").is_some() {
"new_amount"
} else {
"amount"
};
let value = &call.args[field];
let amount = value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string())
.parse::<Decimal>()
.map_err(|_| AppError::input())?;
let converted = super::validation::convert_quantity(amount, from, stock, &conversions)?;
if let RequestBody::Json(body) = &mut r.body {
body[field] = json!(converted.to_string());
}
Ok(())
}

46
src/tools/exports.rs Normal file
View File

@@ -0,0 +1,46 @@
use crate::{
catalog::ValidatedCall,
client::{ApiClient, ApiRequest, ResponseBody, ResponseKind},
error::{AppError, Result},
model::ToolOutput,
};
use serde_json::{Value, json};
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
let mut r = ApiRequest::get(call.op.route);
if call.op.name == "calendar_export" {
r.kind = ResponseKind::Text;
let response = client.request(r).await?;
let ResponseBody::Text(content) = response.body else {
return Err(AppError::input());
};
if !content.trim_start().starts_with("BEGIN:VCALENDAR") {
return Err(AppError::new(
"INVALID_RESPONSE",
"Expected iCalendar data from the API.",
));
}
return Ok(ToolOutput::data(
json!({"mime":"text/calendar","content":content}),
));
}
if call.op.name == "shopping_export" {
r.params.insert("entity".into(), "shopping_list".into());
let rows = client.request(r).await?.json()?;
let id = call.args["list_id"].as_u64().ok_or_else(AppError::input)?;
let items = rows
.as_array()
.ok_or_else(AppError::input)?
.iter()
.filter(|r| {
super::validation::record_id(&r["shopping_list_id"]).is_ok_and(|n| n.0 == id)
})
.cloned()
.collect::<Vec<_>>();
return Ok(ToolOutput::data(
json!({"shopping_list_id":id,"items":items}),
));
}
let rows = client.request(r).await?.json()?;
let users=rows.as_array().ok_or_else(AppError::input)?.iter().map(|u|json!({"id":u["id"],"name":u.get("display_name").or_else(||u.get("username")).unwrap_or(&Value::Null)})).collect::<Vec<_>>();
Ok(ToolOutput::data(json!({"users":users})))
}

167
src/tools/files.rs Normal file
View File

@@ -0,0 +1,167 @@
use crate::{
catalog::OperationSpec,
client::{
ApiClient, ApiRequest, BINARY_LIMIT, HttpMethod, RequestBody, ResponseBody, ResponseKind,
},
error::{AppError, Result},
model::{AccessMode, Attachment, ToolOutput, Verification, WriteReport, WriteStatus},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
pub fn validate_filename(name: &str) -> Result<()> {
if name.is_empty()
|| name.len() > 255
|| matches!(name, "." | "..")
|| name.contains(['/', '\\', '%'])
|| name.chars().any(char::is_control)
{
return Err(AppError::input());
}
Ok(())
}
pub fn operations(mode: AccessMode) -> Vec<OperationSpec> {
let mut ops = vec![];
for (name, method, route) in [
("file_get", HttpMethod::Get, "/files/{group}/{fileName}"),
("file_upload", HttpMethod::Put, "/files/{group}/{fileName}"),
(
"file_delete",
HttpMethod::Delete,
"/files/{group}/{fileName}",
),
("calendar_export", HttpMethod::Get, "/calendar/ical"),
("shopping_export", HttpMethod::Get, "/objects/{entity}"),
("assignment_users", HttpMethod::Get, "/users"),
] {
if method.mutates() && mode == AccessMode::ReadOnly {
continue;
}
let mut properties = json!({});
let mut required = vec![];
if name.starts_with("file_") {
properties = json!({"group":{"type":"string","enum":["equipmentmanuals","recipepictures","productpictures","userfiles"]},"filename":{"type":"string","maxLength":255}});
required.extend(["group", "filename"]);
if name == "file_upload" {
properties["content_base64"] = json!({"type":"string","maxLength":((BINARY_LIMIT+2)/3)*4,"description":"Standard base64 of actual file bytes, at most 8 MiB decoded. No local path or external URL."});
required.push("content_base64")
}
}
if name == "shopping_export" {
properties["list_id"] = json!({"type":"integer","minimum":1});
required.push("list_id")
}
let description = match name {
"file_get" => {
"Read a known API file by group and filename. Returns real bytes as an image or embedded resource, at most 8 MiB. Does not fetch URLs or host files. Grocy has no orphan-file listing API."
}
"file_upload" => {
"Upload bounded base64 content as binary through the API, replacing this exact named household file. Returns a write receipt and readback verification."
}
"file_delete" => {
"Permanently delete the exact API file. Specify the group and filename; no host filesystem access."
}
"calendar_export" => {
"Export household dates as iCalendar data through the API; no public sharing link."
}
"shopping_export" => {
"Export actual shopping-list items for an explicit list ID as JSON; no printer action."
}
_ => {
"List assignment user IDs and display names only. No passwords, keys or account fields."
}
};
ops.push(OperationSpec{name,route,method,schema:json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}),mutates:method.mutates(),destructive:method.mutates(),description:description.into(),path_params:Default::default(),body_keys:vec![]});
}
ops
}
fn file_request(args: &Value, method: HttpMethod) -> Result<ApiRequest> {
let filename = args["filename"].as_str().ok_or_else(AppError::input)?;
validate_filename(filename)?;
let mut r = ApiRequest::get("/files/{group}/{fileName}");
r.params.insert(
"group".into(),
args["group"].as_str().ok_or_else(AppError::input)?.into(),
);
r.params
.insert("fileName".into(), STANDARD.encode(filename));
r.method = method;
r.kind = ResponseKind::Binary;
Ok(r)
}
pub async fn run(client: &ApiClient, call: &crate::catalog::ValidatedCall) -> Result<ToolOutput> {
let mut r = file_request(&call.args, call.op.method)?;
let target = json!({"group":call.args["group"],"filename":call.args["filename"]});
let mut expected = None;
if call.op.method == HttpMethod::Put {
let encoded = call.args["content_base64"]
.as_str()
.ok_or_else(AppError::input)?;
if encoded.len() > ((BINARY_LIMIT + 2) / 3) * 4 {
return Err(AppError::input());
}
let bytes = STANDARD.decode(encoded).map_err(|_| AppError::input())?;
if bytes.len() > BINARY_LIMIT {
return Err(AppError::input());
}
expected = Some(bytes.clone());
r.body = RequestBody::Binary(bytes);
}
let response = client.request(r).await?;
if call.op.method == HttpMethod::Get {
let ResponseBody::Binary(bytes) = response.body else {
return Err(AppError::input());
};
let mime = validated_mime(&bytes, &response.content_type);
return Ok(ToolOutput {
data: json!({"file":target,"bytes":bytes.len(),"mime":mime}),
write: None,
attachment: Some(Attachment {
bytes,
mime,
name: call.args["filename"].as_str().unwrap().into(),
}),
});
}
let check = client
.request(file_request(&call.args, HttpMethod::Get)?)
.await;
let verification = match (call.op.method, check) {
(HttpMethod::Delete, Err(e)) if e.code == "NOT_FOUND" => Verification::Confirmed,
(HttpMethod::Put, Ok(r)) => match r.body {
ResponseBody::Binary(b) if Some(&b) == expected.as_ref() => Verification::Confirmed,
_ => Verification::Incomplete,
},
_ => Verification::Incomplete,
};
Ok(ToolOutput {
data: json!({"file":target,"bytes":expected.as_ref().map(Vec::len)}),
write: Some(WriteReport {
status: WriteStatus::Completed,
verification,
target,
receipt: json!({"http_status":response.status}),
}),
attachment: None,
})
}
fn validated_mime(bytes: &[u8], reported: &str) -> String {
let image = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if bytes.starts_with(&[255, 216, 255]) {
Some("image/jpeg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("image/gif")
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
Some("image/webp")
} else {
None
};
if let Some(mime) = image {
return mime.into();
}
if reported == "application/pdf" && bytes.starts_with(b"%PDF-") {
"application/pdf".into()
} else {
"application/octet-stream".into()
}
}

View File

@@ -1,2 +1,17 @@
mod actions;
pub mod exports;
pub mod files;
pub mod records;
pub mod validation;
use crate::{catalog::ValidatedCall, client::ApiClient, error::Result, model::ToolOutput};
pub async fn execute(client: &ApiClient, call: ValidatedCall) -> Result<ToolOutput> {
if call.op.name.starts_with("records_") || call.op.name.starts_with("userfields_") {
records::run(client, &call).await
} else if call.op.name.starts_with("file_") {
files::run(client, &call).await
} else if ["calendar_export", "shopping_export", "assignment_users"].contains(&call.op.name) {
exports::run(client, &call).await
} else {
actions::run(client, &call).await
}
}