use std::path::Path;
use anyhow::{bail, Context, Result};
use mecha_slack::binding::SlackStore;
use mecha_slack::{files, Slack};
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sent {
pub filename: String,
pub bytes: u64,
pub file_id: String,
}
pub(crate) fn owner_client(store: &SlackStore) -> Result<(Slack, String)> {
let creds = store
.credentials()?
.context("no Slack tokens stored — run `mecha slack auth` first")?;
let binding = store
.binding()?
.context("nothing is bound — run `mecha slack link` first")?;
let owner = binding
.owners
.first()
.context("the binding names no owners")?
.clone();
Ok((Slack::new(&creds.bot_token), owner))
}
pub(crate) async fn open_dm(slack: &Slack, owner: &str) -> Result<String> {
let opened: Value = slack
.call("conversations.open", json!({ "users": owner }))
.await
.context("opening a DM with the owner")?;
Ok(opened["channel"]["id"]
.as_str()
.context("conversations.open returned no channel")?
.to_string())
}
pub(crate) async fn owner_dm(store: &SlackStore) -> Result<(Slack, String)> {
let (slack, owner) = owner_client(store)?;
let channel = open_dm(&slack, &owner).await?;
Ok((slack, channel))
}
pub(crate) fn vet(path: &Path, is_dir: bool, len: u64, max_bytes: u64) -> Result<String> {
if is_dir {
bail!(
"{} is a directory — send one file (or tar it first)",
path.display()
);
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.filter(|n| !n.is_empty())
.with_context(|| format!("{} has no file name to send it under", path.display()))?;
if len == 0 {
bail!("{name} is empty — there is nothing to send");
}
if len > max_bytes {
bail!(
"{name} is {} — the cap is {} (`[slack] max_upload_mb`)",
human(len),
human(max_bytes)
);
}
Ok(name.to_string())
}
pub(crate) fn human(bytes: u64) -> String {
const MB: u64 = 1024 * 1024;
const KB: u64 = 1024;
if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} bytes")
}
}
pub async fn send_file(path: &Path, comment: Option<&str>) -> Result<Sent> {
let cfg = mecha_core::config::Config::load_global()?;
let max_bytes = cfg.slack.max_upload_mb.saturating_mul(1024 * 1024);
let meta =
std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
let name = vet(path, meta.is_dir(), meta.len(), max_bytes)?;
let store = SlackStore::open(mecha_core::work::mecha_home()?.join("slack"))?;
let (slack, channel) = owner_dm(&store).await?;
let bytes = std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
if bytes.len() as u64 > max_bytes {
bail!(
"{name} grew to {} while being read — the cap is {}",
human(bytes.len() as u64),
human(max_bytes)
);
}
let file_id = files::upload(
&slack,
&name,
&bytes,
&files::Share {
channel_id: Some(&channel),
thread_ts: None,
initial_comment: comment,
title: Some(&name),
},
)
.await
.with_context(|| format!("uploading {name}"))?;
Ok(Sent {
filename: name,
bytes: bytes.len() as u64,
file_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
const CAP: u64 = 25 * 1024 * 1024;
#[test]
fn an_ordinary_file_sends_under_its_own_name() {
let name = vet(Path::new("/w/reports/chart.png"), false, 4_096, CAP).unwrap();
assert_eq!(name, "chart.png");
}
#[test]
fn a_directory_is_refused_by_name_with_the_way_out() {
let err = vet(Path::new("/w/reports"), true, 4_096, CAP).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("/w/reports"), "{msg}");
assert!(msg.contains("directory"), "{msg}");
assert!(msg.contains("tar"), "{msg}");
}
#[test]
fn an_empty_file_is_refused_here_rather_than_by_slack() {
let err = vet(Path::new("/w/empty.log"), false, 0, CAP).unwrap_err();
assert!(err.to_string().contains("nothing to send"));
}
#[test]
fn an_oversized_file_is_refused_naming_both_sizes_and_the_knob() {
let err = vet(Path::new("/w/big.bin"), false, CAP + 1, CAP).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("25.0 MB"), "{msg}");
assert!(msg.contains("max_upload_mb"), "{msg}");
}
#[test]
fn the_cap_itself_is_allowed() {
assert!(vet(Path::new("/w/big.bin"), false, CAP, CAP).is_ok());
}
#[test]
fn a_path_with_no_file_name_is_refused_rather_than_sent_as_something_else() {
assert!(vet(Path::new("/"), false, 10, CAP).is_err());
}
#[test]
fn sizes_read_the_way_a_person_reads_them() {
assert_eq!(human(512), "512 bytes");
assert_eq!(human(2 * 1024), "2.0 KB");
assert_eq!(human(3 * 1024 * 1024), "3.0 MB");
}
}