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,
label: Option<String>,
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('&', "&")
.replace('<', "<")
.replace('>', ">")
}
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")),
))
}
}