codex-telegram-bridge 0.1.0

Telegram away-mode control for Codex threads with optional Hermes MCP
Documentation
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::PathBuf;

use crate::state_dir_path;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DaemonConfig {
    pub(crate) version: u32,
    pub(crate) bridge_command: String,
    pub(crate) events: String,
    #[serde(default)]
    pub(crate) telegram: Option<TelegramConfig>,
    #[serde(default)]
    pub(crate) projects: Vec<RegisteredProject>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TelegramConfig {
    pub(crate) bot_token: String,
    pub(crate) chat_id: String,
    #[serde(default)]
    pub(crate) allowed_user_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RegisteredProject {
    pub(crate) id: String,
    pub(crate) label: String,
    pub(crate) cwd: String,
    #[serde(default)]
    pub(crate) aliases: Vec<String>,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct TelegramSetupOptions<'a> {
    pub(crate) bot_token: Option<&'a str>,
    pub(crate) chat_id: Option<&'a str>,
    pub(crate) allowed_user_id: Option<&'a str>,
    pub(crate) events: &'a str,
    pub(crate) bridge_command: &'a str,
    pub(crate) dry_run: bool,
    pub(crate) pair_timeout_ms: u64,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct SetupOptions<'a> {
    pub(crate) bot_token: Option<&'a str>,
    pub(crate) chat_id: Option<&'a str>,
    pub(crate) allowed_user_id: Option<&'a str>,
    pub(crate) events: &'a str,
    pub(crate) bridge_command: &'a str,
    pub(crate) daemon_label: &'a str,
    pub(crate) install_daemon: bool,
    pub(crate) start_daemon: bool,
    pub(crate) register_hermes: bool,
    pub(crate) hermes_server_name: &'a str,
    pub(crate) hermes_command: &'a str,
    pub(crate) dry_run: bool,
    pub(crate) pair_timeout_ms: u64,
}

pub(crate) fn daemon_config_path() -> Result<PathBuf> {
    Ok(state_dir_path()?.join("config.json"))
}

pub(crate) fn merged_daemon_config(
    existing: Option<&DaemonConfig>,
    bridge_command: &str,
    events: &str,
    telegram: TelegramConfig,
) -> DaemonConfig {
    DaemonConfig {
        version: 3,
        bridge_command: bridge_command.to_string(),
        events: events.to_string(),
        telegram: Some(telegram),
        projects: existing
            .map(|config| config.projects.clone())
            .unwrap_or_default(),
    }
}

pub(crate) fn write_daemon_config(config: &DaemonConfig) -> Result<PathBuf> {
    let path = daemon_config_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, serde_json::to_vec_pretty(config)?)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
    }
    Ok(path)
}

pub(crate) fn load_daemon_config() -> Result<DaemonConfig> {
    let path = daemon_config_path()?;
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("daemon config not found at {}", path.display()))?;
    let config: DaemonConfig = serde_json::from_str(&raw)
        .with_context(|| format!("failed to parse daemon config at {}", path.display()))?;
    if config.events.trim().is_empty() {
        bail!("daemon config events cannot be empty");
    }
    match config.telegram.as_ref() {
        Some(telegram) => {
            if telegram.bot_token.trim().is_empty() {
                bail!("daemon config telegram.botToken cannot be empty");
            }
            if telegram.chat_id.trim().is_empty() {
                bail!("daemon config telegram.chatId cannot be empty");
            }
        }
        None => bail!(
            "daemon config must include Telegram transport. Run `codex-telegram-bridge setup` or `codex-telegram-bridge telegram setup`."
        ),
    }
    for project in &config.projects {
        if project.id.trim().is_empty() {
            bail!("daemon config project id cannot be empty");
        }
        if project.label.trim().is_empty() {
            bail!("daemon config project label cannot be empty");
        }
        if project.cwd.trim().is_empty() {
            bail!("daemon config project cwd cannot be empty");
        }
    }
    Ok(config)
}

pub(crate) fn redacted_daemon_config(config: &DaemonConfig) -> Value {
    json!({
        "version": config.version,
        "bridgeCommand": config.bridge_command,
        "events": config.events,
        "telegram": config.telegram.as_ref().map(|telegram| json!({
            "botToken": "<redacted>",
            "chatId": telegram.chat_id,
            "allowedUserId": telegram.allowed_user_id
        })),
        "projects": config.projects.iter().map(|project| json!({
            "id": project.id,
            "label": project.label,
            "cwd": project.cwd,
            "aliases": project.aliases
        })).collect::<Vec<_>>()
    })
}

pub(crate) fn read_daemon_config_raw() -> Result<Option<DaemonConfig>> {
    let path = daemon_config_path()?;
    if !path.exists() {
        return Ok(None);
    }
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("failed to read daemon config at {}", path.display()))?;
    let config: DaemonConfig = serde_json::from_str(&raw)
        .with_context(|| format!("failed to parse daemon config at {}", path.display()))?;
    Ok(Some(config))
}

pub(crate) fn resolve_telegram_bot_token(explicit: Option<&str>) -> Result<String> {
    explicit
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .or_else(|| {
            env::var("TELEGRAM_BOT_TOKEN")
                .ok()
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
        })
        .context(
            "Telegram bot token is required. Pass --bot-token or set TELEGRAM_BOT_TOKEN after creating a bot with @BotFather.",
        )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn merged_daemon_config_preserves_existing_projects() {
        let merged = merged_daemon_config(
            Some(&DaemonConfig {
                version: 2,
                bridge_command: "old-bridge".to_string(),
                events: "thread_waiting".to_string(),
                telegram: Some(TelegramConfig {
                    bot_token: "old".to_string(),
                    chat_id: "old-chat".to_string(),
                    allowed_user_id: None,
                }),
                projects: vec![RegisteredProject {
                    id: "bridge".to_string(),
                    label: "Codex Telegram Bridge".to_string(),
                    cwd: "/Users/hanifcarroll/projects/codex-telegram-bridge".to_string(),
                    aliases: vec!["codex".to_string()],
                }],
            }),
            "codex-telegram-bridge",
            crate::DEFAULT_NOTIFICATION_EVENTS,
            TelegramConfig {
                bot_token: "123:secret".to_string(),
                chat_id: "456".to_string(),
                allowed_user_id: Some("789".to_string()),
            },
        );

        assert_eq!(merged.version, 3);
        assert_eq!(merged.projects.len(), 1);
        assert_eq!(merged.projects[0].id, "bridge");
        assert_eq!(merged.telegram.as_ref().unwrap().chat_id, "456");
    }
}