diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad1203e --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Read Later Bot + +## Configuration + +The bot reads a TOML config passed via `--config`. Most values are standard TOML types. The `user_id` field accepts multiple forms so it can be sourced from secrets managers. + +### `user_id` + +You can provide the Telegram user ID as: + +- A number +- A numeric string +- A file path containing the numeric ID (useful for age/sops) +- An explicit file object + +Examples: + +```toml +user_id = 123456789 +``` + +```toml +user_id = "123456789" +``` + +```toml +user_id = "/run/agenix/readlater-user-id" +``` + +```toml +user_id = { file = "/run/agenix/readlater-user-id" } +``` diff --git a/src/main.rs b/src/main.rs index 5dc78e1..16ef405 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ const DELETE_CONFIRM_TTL_SECS: u64 = 5 * 60; const RESOURCE_PROMPT_TTL_SECS: u64 = 5 * 60; const PAGE_SIZE: usize = 3; -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Clone)] struct Config { token: String, user_id: u64, @@ -36,6 +36,26 @@ struct Config { sync: Option, } +#[derive(Debug, Deserialize, Clone)] +struct ConfigFile { + token: String, + user_id: UserIdInput, + read_later_path: PathBuf, + finished_path: PathBuf, + resources_path: PathBuf, + data_dir: PathBuf, + retry_interval_seconds: Option, + sync: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +enum UserIdInput { + Number(u64), + String(String), + File { file: PathBuf }, +} + #[derive(Debug, Deserialize, Clone)] struct SyncConfig { repo_path: PathBuf, @@ -3059,10 +3079,67 @@ where Err(last_err.unwrap_or_else(|| anyhow!("retry failed"))) } +fn resolve_user_id(input: UserIdInput, config_dir: &Path) -> Result { + match input { + UserIdInput::Number(value) => Ok(value), + UserIdInput::String(raw) => resolve_user_id_string(&raw, config_dir), + UserIdInput::File { file } => { + let path = resolve_user_id_path(&file, config_dir); + read_user_id_file(&path) + } + } +} + +fn resolve_user_id_string(raw: &str, config_dir: &Path) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(anyhow!("user_id is empty")); + } + if trimmed.chars().all(|c| c.is_ascii_digit()) { + return parse_user_id_value(trimmed).context("parse user_id"); + } + let path = resolve_user_id_path(Path::new(trimmed), config_dir); + read_user_id_file(&path) +} + +fn resolve_user_id_path(path: &Path, config_dir: &Path) -> PathBuf { + if path.is_relative() { + config_dir.join(path) + } else { + path.to_path_buf() + } +} + +fn read_user_id_file(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("read user_id file {}", path.display()))?; + parse_user_id_value(contents.trim()) + .with_context(|| format!("parse user_id from {}", path.display())) +} + +fn parse_user_id_value(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(anyhow!("user_id is empty")); + } + trimmed.parse::().context("parse user_id") +} + fn load_config(path: &Path) -> Result { let contents = fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?; - let config: Config = toml::from_str(&contents).context("parse config")?; - Ok(config) + let config_file: ConfigFile = toml::from_str(&contents).context("parse config")?; + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let user_id = resolve_user_id(config_file.user_id, config_dir)?; + Ok(Config { + token: config_file.token, + user_id, + read_later_path: config_file.read_later_path, + finished_path: config_file.finished_path, + resources_path: config_file.resources_path, + data_dir: config_file.data_dir, + retry_interval_seconds: config_file.retry_interval_seconds, + sync: config_file.sync, + }) } fn list_resource_files(dir: &Path) -> Result> {