#![allow(dead_code)]
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,
#[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>,
}
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
}
pub async fn send_message_typed(
bot_token: &str,
chat_id: i64,
text: &str,
parse_mode: Option<&str>,
reply_to: Option<i64>,
) -> Result<()> {
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(())
}
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(())
}
#[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>,
}
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() {
let err = send_message("fake-token", "not-a-number", "hi").await.unwrap_err();
assert!(
err.to_string().contains("not a valid integer"),
"got: {}",
err
);
}
}