use anyhow::Result;
use async_trait::async_trait;
use mecha_core::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
use mecha_slack::{files, Slack};
use serde_json::{json, Value};
pub struct ShowFileTool {
max_upload_bytes: u64,
}
impl ShowFileTool {
pub fn new(max_upload_mb: u64) -> Self {
ShowFileTool {
max_upload_bytes: max_upload_mb.saturating_mul(1024 * 1024),
}
}
}
#[async_trait]
impl Tool for ShowFileTool {
fn name(&self) -> &str {
"show_file"
}
fn description(&self) -> &str {
"Put a file in front of the user where they can actually look at it — a chart, a \
rendered image, a PDF, a log. Use this when you have produced something whose \
point is to be *seen* rather than described, and the user is not sitting at this \
machine. **Call it in a later turn than the one that wrote the file**: tool calls \
you request together are executed at the same time, so showing a file in the same \
turn that creates it will usually find nothing there. It works only while the \
session is mirrored to a Slack thread; if it is not, say what you made and where \
it is instead."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file to show, relative to the workspace."
}
},
"required": ["path"]
})
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities::default().private()
}
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let Some(raw) = input.get("path").and_then(Value::as_str) else {
return Ok(ToolOutput::err("show_file needs a `path`"));
};
let path = match ctx.resolve(raw) {
Ok(path) => path,
Err(e) => return Ok(ToolOutput::err(format!("{raw}: {e}"))),
};
let store = match crate::slack::remote::RemoteStore::open_default() {
Ok(store) => store,
Err(e) => return Ok(ToolOutput::err(format!("no remote store: {e}"))),
};
let record = match store.attached_here() {
Ok(Some(record)) => record,
Ok(None) => {
return Ok(ToolOutput::err(
"this session is not mirrored to a Slack thread, so there is nowhere to \
show it. Tell the user what you made and where it is; they can attach \
with `/remote-control <name>` if they want to see it.",
))
}
Err(e) => return Ok(ToolOutput::err(format!("could not read the store: {e}"))),
};
let meta = match std::fs::metadata(&path) {
Ok(meta) => meta,
Err(e) => return Ok(ToolOutput::err(format!("cannot read {raw}: {e}"))),
};
let name = match crate::slack::send::vet(
&path,
meta.is_dir(),
meta.len(),
self.max_upload_bytes,
) {
Ok(name) => name,
Err(e) => return Ok(ToolOutput::err(format!("{e:#}"))),
};
let (Some(channel), Some(thread_ts)) = (&record.channel_id, &record.thread_ts) else {
return Ok(ToolOutput::err("the attachment has no thread yet"));
};
let home = match mecha_core::work::mecha_home() {
Ok(home) => home,
Err(e) => return Ok(ToolOutput::err(format!("no mecha home: {e}"))),
};
let creds = match mecha_slack::binding::SlackStore::open(home.join("slack"))
.and_then(|s| s.credentials())
{
Ok(Some(creds)) => creds,
Ok(None) => return Ok(ToolOutput::err("no Slack tokens stored")),
Err(e) => {
return Ok(ToolOutput::err(format!(
"could not read the Slack store: {e}"
)))
}
};
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => return Ok(ToolOutput::err(format!("cannot read {raw}: {e}"))),
};
let slack = Slack::new(&creds.bot_token);
match files::upload(
&slack,
&name,
&bytes,
&files::Share {
channel_id: Some(channel),
thread_ts: Some(thread_ts),
initial_comment: None,
title: Some(&name),
},
)
.await
{
Ok(_) => Ok(ToolOutput::ok(format!(
"Shown to the user in the `{}` Slack thread as {name}.",
record.name
))),
Err(e) => Ok(ToolOutput::err(format!("could not show {name}: {e}"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_file_sits_in_the_third_quadrant() {
let caps = ShowFileTool::new(25).capabilities();
assert!(caps.private_data, "it reads workspace bytes");
assert!(
!caps.external_send,
"it reaches the owner's own DM and nobody else — marking it a send \
sink would stop a tainted session showing the user its own chart"
);
assert!(
!caps.untrusted_input,
"it returns the harness's own report, not third-party content"
);
assert!(!caps.destructive, "it changes nothing");
assert!(ShowFileTool::new(25).read_only());
}
#[test]
fn the_schema_offers_no_way_to_name_a_destination() {
let schema = ShowFileTool::new(25).input_schema();
let props = schema["properties"].as_object().expect("an object schema");
assert_eq!(
props.keys().collect::<Vec<_>>(),
vec!["path"],
"show_file grew an argument; if it names a destination the tool is now a sink"
);
for banned in ["channel", "channel_id", "thread", "thread_ts", "to", "user"] {
assert!(!props.contains_key(banned), "{banned} must not be nameable");
}
}
struct HomeGuard {
_lock: std::sync::MutexGuard<'static, ()>,
previous: Option<String>,
dir: std::path::PathBuf,
}
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
impl HomeGuard {
fn new() -> Self {
let lock = ENV.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("MECHA_HOME").ok();
let dir =
std::env::temp_dir().join(format!("mecha-show-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("MECHA_HOME", &dir);
HomeGuard {
_lock: lock,
previous,
dir,
}
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match &self.previous {
Some(v) => std::env::set_var("MECHA_HOME", v),
None => std::env::remove_var("MECHA_HOME"),
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[tokio::test]
async fn an_unparseable_config_no_longer_reaches_a_call_two_hours_in() {
let home = HomeGuard::new();
std::fs::write(
home.dir.join("config.toml"),
"[a_section_this_binary_has_never_heard_of]\nkey = 1\n",
)
.unwrap();
assert!(mecha_core::config::Config::load_global().is_err());
let workspace = home.dir.join("ws");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(workspace.join("chart.png"), b"not really a png").unwrap();
let store = crate::slack::remote::RemoteStore::open_default().unwrap();
let mut rec = crate::slack::remote::AttachRecord::new("t", "s", workspace.clone());
rec.channel_id = Some("C1".into());
rec.thread_ts = Some("1.2".into());
store.put(&rec).unwrap();
let ctx = ToolCtx::default().with_workspace(workspace);
let out = ShowFileTool::new(0)
.call(json!({ "path": "chart.png" }), &ctx)
.await
.unwrap();
assert!(out.is_error);
assert!(
!out.content.contains("config"),
"the cap must come from registration, not from a call-time load: {}",
out.content
);
assert!(out.content.contains("max_upload_mb"), "{}", out.content);
}
#[tokio::test]
async fn a_missing_path_argument_is_a_recoverable_error() {
let ctx = ToolCtx::default();
let out = ShowFileTool::new(25).call(json!({}), &ctx).await.unwrap();
assert!(out.is_error);
assert!(out.content.contains("path"), "{}", out.content);
assert!(
!out.external,
"a harness refusal is not third-party content"
);
}
}