i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
#![allow(dead_code)]

//! Telegram Bot API primitives.
//!
//! Just enough of the API surface to support the `message listen` command:
//! - `send_message` — drop a text message into a chat (used by automation actions)
//! - `send_photo`   — upload a local image (used for `/screenshot`)
//! - `get_updates`  — long-poll the bot inbox with offset tracking
//!
//! All public functions are async and return `anyhow::Result`. The shape of
//! the returned `Update` mirrors only the subset of Telegram's response that
//! the listener consumes — adding fields here is cheap, so prefer extending
//! over adding parallel parsing.

use anyhow::{anyhow, Context, Result};
use reqwest::multipart;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;

#[derive(Serialize)]
struct SendMessageRequest<'a> {
    chat_id: i64,
    text: &'a str,
    /// Telegram caps message length at 4096 chars. Truncation happens upstream.
    #[serde(skip_serializing_if = "Option::is_none")]
    parse_mode: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    reply_to_message_id: Option<i64>,
}

/// Send a plain-text message. Used by automation actions; chat_id is a
/// string here for compat with the existing call site that reads the env
/// `TELEGRAM_CHAT_ID` (which may be a negative-number group id).
pub async fn send_message(bot_token: &str, chat_id: &str, text: &str) -> Result<()> {
    let parsed: i64 = chat_id
        .parse()
        .with_context(|| format!("TELEGRAM_CHAT_ID `{}` is not a valid integer", chat_id))?;
    send_message_typed(bot_token, parsed, text, None, None).await
}

/// Strongly-typed variant used by the listener.
pub async fn send_message_typed(
    bot_token: &str,
    chat_id: i64,
    text: &str,
    parse_mode: Option<&str>,
    reply_to: Option<i64>,
) -> Result<()> {
    // Telegram's hard limit is 4096 UTF-16 code units per message. We use a
    // conservative 4000-char ceiling and truncate with an ellipsis so a
    // long /sessions or /share output still gets through instead of erroring.
    let truncated = if text.chars().count() > 4000 {
        let mut s: String = text.chars().take(3990).collect();
        s.push_str("\n…[truncated]");
        s
    } else {
        text.to_string()
    };

    let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
    let resp = Client::new()
        .post(&url)
        .json(&SendMessageRequest {
            chat_id,
            text: &truncated,
            parse_mode,
            reply_to_message_id: reply_to,
        })
        .send()
        .await
        .context("POST sendMessage")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().await.unwrap_or_default();
        return Err(anyhow!("Telegram sendMessage {}: {}", status, body));
    }
    Ok(())
}

/// Upload a photo from a local file to a chat. Used by `/screenshot`.
pub async fn send_photo(bot_token: &str, chat_id: i64, path: &Path, caption: Option<&str>) -> Result<()> {
    let bytes = tokio::fs::read(path)
        .await
        .with_context(|| format!("read {}", path.display()))?;
    let filename = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("photo.png")
        .to_string();

    let mut form = multipart::Form::new()
        .text("chat_id", chat_id.to_string())
        .part("photo", multipart::Part::bytes(bytes).file_name(filename));
    if let Some(c) = caption {
        form = form.text("caption", c.to_string());
    }

    let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token);
    let resp = Client::new()
        .post(&url)
        .multipart(form)
        .send()
        .await
        .context("POST sendPhoto")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().await.unwrap_or_default();
        return Err(anyhow!("Telegram sendPhoto {}: {}", status, body));
    }
    Ok(())
}

/// One incoming message stripped down to the fields the listener uses.
#[derive(Debug, Clone)]
pub struct Update {
    pub update_id: i64,
    pub chat_id: i64,
    pub from_user: Option<String>,
    pub message_id: i64,
    pub text: String,
}

#[derive(Deserialize)]
struct GetUpdatesResponse {
    ok: bool,
    #[serde(default)]
    result: Vec<RawUpdate>,
    #[serde(default)]
    description: Option<String>,
}

#[derive(Deserialize)]
struct RawUpdate {
    update_id: i64,
    #[serde(default)]
    message: Option<RawMessage>,
}

#[derive(Deserialize)]
struct RawMessage {
    message_id: i64,
    chat: RawChat,
    #[serde(default)]
    from: Option<RawUser>,
    #[serde(default)]
    text: Option<String>,
}

#[derive(Deserialize)]
struct RawChat {
    id: i64,
}

#[derive(Deserialize)]
struct RawUser {
    #[serde(default)]
    username: Option<String>,
    #[serde(default)]
    first_name: Option<String>,
}

/// Long-poll for new updates. `offset` is the last `update_id + 1` you
/// processed; pass `0` on first call. `timeout` is the Telegram-side long-poll
/// duration in seconds (we send the HTTP request with a slightly longer
/// client-side timeout so the server has room to respond cleanly).
pub async fn get_updates(
    bot_token: &str,
    offset: i64,
    timeout_secs: u64,
) -> Result<Vec<Update>> {
    let url = format!("https://api.telegram.org/bot{}/getUpdates", bot_token);
    let client = Client::builder()
        .timeout(Duration::from_secs(timeout_secs + 10))
        .build()
        .context("build reqwest client")?;
    let body = serde_json::json!({
        "offset": offset,
        "timeout": timeout_secs,
        "allowed_updates": ["message"],
    });
    let resp = client
        .post(&url)
        .json(&body)
        .send()
        .await
        .context("POST getUpdates")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().await.unwrap_or_default();
        return Err(anyhow!("Telegram getUpdates {}: {}", status, body));
    }

    let parsed: GetUpdatesResponse = resp.json().await.context("decode getUpdates response")?;
    if !parsed.ok {
        return Err(anyhow!(
            "Telegram getUpdates ok=false: {}",
            parsed.description.unwrap_or_default()
        ));
    }

    Ok(parsed
        .result
        .into_iter()
        .filter_map(|u| {
            let m = u.message?;
            let text = m.text?;
            let from = m.from.as_ref().and_then(|user| {
                user.username
                    .clone()
                    .or_else(|| user.first_name.clone())
            });
            Some(Update {
                update_id: u.update_id,
                chat_id: m.chat.id,
                from_user: from,
                message_id: m.message_id,
                text,
            })
        })
        .collect())
}

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

    #[tokio::test]
    async fn send_message_rejects_non_integer_chat_id() {
        // We can't smoke-test the network path here, but the chat_id parse
        // happens before any HTTP call and is the most common foot-gun.
        let err = send_message("fake-token", "not-a-number", "hi").await.unwrap_err();
        assert!(
            err.to_string().contains("not a valid integer"),
            "got: {}",
            err
        );
    }
}