codex-telegram-bridge 0.1.0

Telegram away-mode control for Codex threads with optional Hermes MCP
Documentation
use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value};
use std::time::Duration;

use crate::config::TelegramConfig;

pub(crate) fn telegram_api_post(
    bot_token: &str,
    method: &str,
    payload: &Value,
    timeout: Duration,
) -> Result<Value> {
    let agent = ureq::AgentBuilder::new().timeout(timeout).build();
    let url = format!(
        "https://api.telegram.org/bot{}/{}",
        bot_token.trim(),
        method.trim()
    );
    let response = agent
        .post(&url)
        .send_json(payload.clone())
        .map_err(|error| {
            anyhow!(
                "Telegram API {method} request failed: {}",
                crate::redact_secret_text(&error.to_string(), bot_token)
            )
        })?;
    let value: Value = response
        .into_json()
        .with_context(|| format!("Telegram API {method} returned invalid JSON"))?;
    if value.get("ok").and_then(Value::as_bool) != Some(true) {
        bail!("Telegram API {method} returned error: {value}");
    }
    Ok(value)
}

pub(crate) fn telegram_delete_webhook(bot_token: &str, timeout: Duration) -> Result<Value> {
    telegram_api_post(
        bot_token,
        "deleteWebhook",
        &json!({ "drop_pending_updates": false }),
        timeout,
    )
}

pub(crate) fn telegram_get_updates(
    bot_token: &str,
    offset: Option<i64>,
    timeout_seconds: u64,
    timeout: Duration,
) -> Result<Value> {
    let mut body = serde_json::Map::new();
    if let Some(offset) = offset {
        body.insert("offset".to_string(), json!(offset));
    }
    body.insert("timeout".to_string(), json!(timeout_seconds));
    body.insert(
        "allowed_updates".to_string(),
        json!(["message", "callback_query"]),
    );
    telegram_api_post(bot_token, "getUpdates", &Value::Object(body), timeout)
}

pub(crate) fn telegram_send_message(
    telegram: &TelegramConfig,
    payload: &Value,
    timeout: Duration,
) -> Result<Value> {
    telegram_api_post(&telegram.bot_token, "sendMessage", payload, timeout)
}

pub(crate) fn telegram_send_text(
    telegram: &TelegramConfig,
    text: &str,
    timeout: Duration,
) -> Result<Value> {
    telegram_send_message(
        telegram,
        &json!({
            "chat_id": telegram.chat_id,
            "text": text,
            "disable_web_page_preview": true
        }),
        timeout,
    )
}

pub(crate) fn telegram_send_text_message_id(
    telegram: &TelegramConfig,
    text: &str,
    timeout: Duration,
) -> Result<i64> {
    telegram_send_text(telegram, text, timeout)?
        .pointer("/result/message_id")
        .and_then(Value::as_i64)
        .context("Telegram sendMessage response missing result.message_id")
}

pub(crate) fn telegram_bot_commands() -> Vec<Value> {
    vec![
        json!({ "command": "start", "description": "Pair and show remote control help" }),
        json!({ "command": "help", "description": "Show Telegram remote control commands" }),
        json!({ "command": "away_on", "description": "Enable away mode notifications" }),
        json!({ "command": "away_off", "description": "Disable away mode notifications" }),
        json!({ "command": "status", "description": "Show away mode and pending delivery status" }),
        json!({ "command": "new_thread", "description": "Start a new Codex thread from Telegram" }),
        json!({ "command": "project", "description": "Show or switch the current project" }),
        json!({ "command": "projects", "description": "List configured projects and suggestions" }),
        json!({ "command": "inbox", "description": "Show actionable Codex inbox rows" }),
        json!({ "command": "waiting", "description": "Show threads waiting for you" }),
        json!({ "command": "recent", "description": "Show recent Codex threads" }),
        json!({ "command": "settings", "description": "Show current Telegram bridge settings" }),
    ]
}

pub(crate) fn telegram_set_my_commands(
    telegram: &TelegramConfig,
    timeout: Duration,
) -> Result<Value> {
    telegram_api_post(
        &telegram.bot_token,
        "setMyCommands",
        &json!({ "commands": telegram_bot_commands() }),
        timeout,
    )
}

pub(crate) fn telegram_answer_callback_query(
    telegram: &TelegramConfig,
    callback_query_id: &str,
    text: &str,
    timeout: Duration,
) -> Result<Value> {
    telegram_api_post(
        &telegram.bot_token,
        "answerCallbackQuery",
        &json!({
            "callback_query_id": callback_query_id,
            "text": text,
            "show_alert": false
        }),
        timeout,
    )
}

pub(crate) fn telegram_updates_array(updates: &Value) -> Result<&[Value]> {
    updates
        .get("result")
        .and_then(Value::as_array)
        .map(Vec::as_slice)
        .context("Telegram getUpdates response did not contain result array")
}

pub(crate) fn telegram_chat_id(message: &Value) -> Option<String> {
    message
        .get("chat")
        .and_then(|chat| chat.get("id"))
        .and_then(|value| {
            value
                .as_i64()
                .map(|id| id.to_string())
                .or_else(|| value.as_str().map(str::to_string))
        })
}

pub(crate) fn telegram_from_user_id(message: &Value) -> Option<String> {
    message
        .get("from")
        .and_then(|from| from.get("id"))
        .and_then(|value| {
            value
                .as_i64()
                .map(|id| id.to_string())
                .or_else(|| value.as_str().map(str::to_string))
        })
}

pub(crate) fn telegram_message_id(message: &Value) -> Option<i64> {
    message.get("message_id").and_then(Value::as_i64)
}

pub(crate) fn telegram_bot_id(bot_token: &str) -> String {
    crate::sha256_hex(bot_token.as_bytes())[..16].to_string()
}

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

    #[test]
    fn telegram_bot_commands_are_registered_for_core_remote_actions() {
        let commands = telegram_bot_commands();
        let names = commands
            .iter()
            .filter_map(|command| command.get("command").and_then(Value::as_str))
            .collect::<Vec<_>>();

        for required in [
            "start",
            "help",
            "away_on",
            "away_off",
            "status",
            "new_thread",
            "project",
            "projects",
            "inbox",
            "waiting",
            "recent",
            "settings",
        ] {
            assert!(
                names.contains(&required),
                "missing required telegram bot command {required}"
            );
        }

        for command in commands {
            assert!(
                command["command"].as_str().expect("command").len() <= 32,
                "Telegram command names must fit BotCommand limits"
            );
            assert!(
                !command["description"]
                    .as_str()
                    .expect("description")
                    .trim()
                    .is_empty(),
                "Telegram commands must include human-readable descriptions"
            );
        }
    }
}