deltos 0.1.0

Send text/code snippets to Telegram as formatted code blocks for mobile copy-paste
use std::fmt;
use std::fs::{self, File};
use std::io::{self, IsTerminal, Read};
use std::process::{Command, ExitCode};
use std::thread;
use std::time::{Duration, SystemTime};

use clap::Parser;
use serde::Deserialize;

const BOT_TOKEN_SERVICE: &str = "telegram-bot-token";
const CHAT_ID_SERVICE: &str = "telegram-chat-id";
const LOCK_PATH: &str = "/tmp/deltos.lock";
const TELEGRAM_API_BASE: &str = "https://api.telegram.org";

#[derive(Parser, Debug)]
#[command(name = "deltos", about = "Send snippets to Telegram")]
struct Cli {
    #[arg(long)]
    plain: bool,
    /// Optional label (bold header above content)
    label: Option<String>,
    /// Content to send (omit to read from stdin)
    content: Option<String>,
}

#[derive(Debug)]
enum AppError {
    CredentialMissing(String),
    Io(String),
    SendFailed(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::CredentialMissing(service) => {
                write!(
                    f,
                    "Error: missing keychain credential for service '{service}'"
                )
            }
            Self::Io(msg) => write!(f, "Error: {msg}"),
            Self::SendFailed(msg) => write!(f, "Error: {msg}"),
        }
    }
}

#[derive(Debug, Deserialize)]
struct TelegramResponse {
    ok: bool,
    description: Option<String>,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("{err}");
            ExitCode::from(1)
        }
    }
}

fn run() -> Result<(), AppError> {
    let cli = Cli::parse();

    let token = read_from_keychain(BOT_TOKEN_SERVICE)?;
    let chat_id = read_from_keychain(CHAT_ID_SERVICE)?;

    let (label, content) = resolve_input(cli.label, cli.content)?;
    let message = build_message(label.as_deref(), &content, cli.plain);

    enforce_rate_limit()?;
    send_message(&token, &chat_id, &message)?;
    touch_lock()?;

    Ok(())
}

fn read_from_keychain(service: &str) -> Result<String, AppError> {
    let output = Command::new("security")
        .args(["find-generic-password", "-s", service, "-w"])
        .output()
        .map_err(|_| AppError::CredentialMissing(service.to_owned()))?;

    if !output.status.success() {
        return Err(AppError::CredentialMissing(service.to_owned()));
    }

    let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    if value.is_empty() {
        Err(AppError::CredentialMissing(service.to_owned()))
    } else {
        Ok(value)
    }
}

fn resolve_input(
    label: Option<String>,
    content: Option<String>,
) -> Result<(Option<String>, String), AppError> {
    if let Some(content) = content {
        return Ok((label, content));
    }

    let stdin_is_tty = io::stdin().is_terminal();
    match label {
        Some(label_value) if stdin_is_tty => Ok((None, label_value)),
        Some(label_value) => {
            let stdin_content = read_stdin()?;
            Ok((Some(label_value), stdin_content))
        }
        None => {
            let stdin_content = read_stdin()?;
            Ok((None, stdin_content))
        }
    }
}

fn read_stdin() -> Result<String, AppError> {
    let mut buffer = String::new();
    io::stdin()
        .read_to_string(&mut buffer)
        .map_err(|err| AppError::Io(format!("failed to read stdin: {err}")))?;

    if buffer.is_empty() {
        Err(AppError::Io(String::from(
            "no content provided; pass CONTENT argument or pipe data to stdin",
        )))
    } else {
        Ok(buffer)
    }
}

fn html_escape(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn build_message(label: Option<&str>, content: &str, plain: bool) -> String {
    let escaped = html_escape(content);
    let body = if plain {
        escaped
    } else {
        format!("<pre>{escaped}</pre>")
    };

    match label {
        Some(label_text) => format!("<b>{}</b>\n{body}", html_escape(label_text)),
        None => body,
    }
}

fn enforce_rate_limit() -> Result<(), AppError> {
    if let Ok(meta) = fs::metadata(LOCK_PATH) {
        let modified = meta
            .modified()
            .map_err(|err| AppError::Io(format!("failed to read lock metadata: {err}")))?;
        let elapsed = SystemTime::now()
            .duration_since(modified)
            .unwrap_or_default();
        if elapsed < Duration::from_secs(1) {
            thread::sleep(Duration::from_secs(1) - elapsed);
        }
    }

    Ok(())
}

fn touch_lock() -> Result<(), AppError> {
    File::create(LOCK_PATH)
        .map(|_| ())
        .map_err(|err| AppError::Io(format!("failed to touch lock file: {err}")))
}

fn send_message(token: &str, chat_id: &str, message: &str) -> Result<(), AppError> {
    let url = format!("{TELEGRAM_API_BASE}/bot{token}/sendMessage");
    let response = ureq::post(&url)
        .send_form([
            ("chat_id", chat_id),
            ("parse_mode", "HTML"),
            ("disable_web_page_preview", "true"),
            ("text", message),
        ])
        .map_err(|err| AppError::SendFailed(err.to_string()))?;

    let telegram_response: TelegramResponse = response
        .into_body()
        .read_json()
        .map_err(|err| AppError::SendFailed(err.to_string()))?;

    if telegram_response.ok {
        Ok(())
    } else {
        Err(AppError::SendFailed(
            telegram_response
                .description
                .unwrap_or_else(|| String::from("telegram returned ok=false")),
        ))
    }
}