feat: add Docker env var support for PORT, HOST, and TEMPERATURE

- Add port and host fields to GatewayConfig with defaults (3000, 127.0.0.1)
- Enhanced apply_env_overrides() to support:
  - ZEROCLAW_GATEWAY_PORT or PORT - Gateway server port
  - ZEROCLAW_GATEWAY_HOST or HOST - Gateway bind address
  - ZEROCLAW_TEMPERATURE - Default temperature (0.0-2.0)
- Add comprehensive tests for all new env var overrides
- Fix clippy warnings (is_multiple_of, too_many_lines)

Closes #45
This commit is contained in:
argenis de la rosa 2026-02-14 16:19:26 -05:00
parent 365692853c
commit 09d3140127
7 changed files with 803 additions and 13 deletions

View file

@ -1,5 +1,7 @@
use super::traits::{Channel, ChannelMessage};
use async_trait::async_trait;
use reqwest::multipart::{Form, Part};
use std::path::Path;
use uuid::Uuid;
/// Telegram channel — long-polls the Bot API for updates
@ -32,6 +34,333 @@ impl TelegramChannel {
{
identities.into_iter().any(|id| self.is_user_allowed(id))
}
/// Send a document/file to a Telegram chat
pub async fn send_document(
&self,
chat_id: &str,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("document", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a document from bytes (in-memory) to a Telegram chat
pub async fn send_document_bytes(
&self,
chat_id: &str,
file_bytes: Vec<u8>,
file_name: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("document", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a photo to a Telegram chat
pub async fn send_photo(
&self,
chat_id: &str,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("photo.jpg");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("photo", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a photo from bytes (in-memory) to a Telegram chat
pub async fn send_photo_bytes(
&self,
chat_id: &str,
file_bytes: Vec<u8>,
file_name: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("photo", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a video to a Telegram chat
pub async fn send_video(
&self,
chat_id: &str,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("video.mp4");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("video", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendVideo"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVideo failed: {err}");
}
tracing::info!("Telegram video sent to {chat_id}: {file_name}");
Ok(())
}
/// Send an audio file to a Telegram chat
pub async fn send_audio(
&self,
chat_id: &str,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audio.mp3");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("audio", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendAudio"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendAudio failed: {err}");
}
tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a voice message to a Telegram chat
pub async fn send_voice(
&self,
chat_id: &str,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("voice.ogg");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("voice", part);
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendVoice"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVoice failed: {err}");
}
tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a file by URL (Telegram will download it)
pub async fn send_document_by_url(
&self,
chat_id: &str,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"document": url
});
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendDocument"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument by URL failed: {err}");
}
tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
Ok(())
}
/// Send a photo by URL (Telegram will download it)
pub async fn send_photo_by_url(
&self,
chat_id: &str,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"photo": url
});
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
}
let resp = self
.client
.post(self.api_url("sendPhoto"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
}
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
Ok(())
}
}
#[async_trait]
@ -243,4 +572,250 @@ mod tests {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
}
// ── File sending API URL tests ──────────────────────────────────
#[test]
fn telegram_api_url_send_document() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
assert_eq!(
ch.api_url("sendDocument"),
"https://api.telegram.org/bot123:ABC/sendDocument"
);
}
#[test]
fn telegram_api_url_send_photo() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
assert_eq!(
ch.api_url("sendPhoto"),
"https://api.telegram.org/bot123:ABC/sendPhoto"
);
}
#[test]
fn telegram_api_url_send_video() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
assert_eq!(
ch.api_url("sendVideo"),
"https://api.telegram.org/bot123:ABC/sendVideo"
);
}
#[test]
fn telegram_api_url_send_audio() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
assert_eq!(
ch.api_url("sendAudio"),
"https://api.telegram.org/bot123:ABC/sendAudio"
);
}
#[test]
fn telegram_api_url_send_voice() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
assert_eq!(
ch.api_url("sendVoice"),
"https://api.telegram.org/bot123:ABC/sendVoice"
);
}
// ── File sending integration tests (with mock server) ──────────
#[tokio::test]
async fn telegram_send_document_bytes_builds_correct_form() {
// This test verifies the method doesn't panic and handles bytes correctly
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let file_bytes = b"Hello, this is a test file content".to_vec();
// The actual API call will fail (no real server), but we verify the method exists
// and handles the input correctly up to the network call
let result = ch
.send_document_bytes("123456", file_bytes, "test.txt", Some("Test caption"))
.await;
// Should fail with network error, not a panic or type error
assert!(result.is_err());
let err = result.unwrap_err().to_string();
// Error should be network-related, not a code bug
assert!(
err.contains("error") || err.contains("failed") || err.contains("connect"),
"Expected network error, got: {err}"
);
}
#[tokio::test]
async fn telegram_send_photo_bytes_builds_correct_form() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
// Minimal valid PNG header bytes
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let result = ch
.send_photo_bytes("123456", file_bytes, "test.png", None)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_document_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let result = ch
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_photo_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let result = ch
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
.await;
assert!(result.is_err());
}
// ── File path handling tests ────────────────────────────────────
#[tokio::test]
async fn telegram_send_document_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let path = Path::new("/nonexistent/path/to/file.txt");
let result = ch.send_document("123456", path, None).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
// Should fail with file not found error
assert!(
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
"Expected file not found error, got: {err}"
);
}
#[tokio::test]
async fn telegram_send_photo_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let path = Path::new("/nonexistent/path/to/photo.jpg");
let result = ch.send_photo("123456", path, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_video_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let path = Path::new("/nonexistent/path/to/video.mp4");
let result = ch.send_video("123456", path, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_audio_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let path = Path::new("/nonexistent/path/to/audio.mp3");
let result = ch.send_audio("123456", path, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_voice_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let path = Path::new("/nonexistent/path/to/voice.ogg");
let result = ch.send_voice("123456", path, None).await;
assert!(result.is_err());
}
// ── Caption handling tests ──────────────────────────────────────
#[tokio::test]
async fn telegram_send_document_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let file_bytes = b"test content".to_vec();
// With caption
let result = ch
.send_document_bytes("123456", file_bytes.clone(), "test.txt", Some("My caption"))
.await;
assert!(result.is_err()); // Network error expected
// Without caption
let result = ch
.send_document_bytes("123456", file_bytes, "test.txt", None)
.await;
assert!(result.is_err()); // Network error expected
}
#[tokio::test]
async fn telegram_send_photo_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
// With caption
let result = ch
.send_photo_bytes(
"123456",
file_bytes.clone(),
"test.png",
Some("Photo caption"),
)
.await;
assert!(result.is_err());
// Without caption
let result = ch
.send_photo_bytes("123456", file_bytes, "test.png", None)
.await;
assert!(result.is_err());
}
// ── Empty/edge case tests ───────────────────────────────────────
#[tokio::test]
async fn telegram_send_document_bytes_empty_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let file_bytes: Vec<u8> = vec![];
let result = ch
.send_document_bytes("123456", file_bytes, "empty.txt", None)
.await;
// Should not panic, will fail at API level
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_document_bytes_empty_filename() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let file_bytes = b"content".to_vec();
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
// Should not panic
assert!(result.is_err());
}
#[tokio::test]
async fn telegram_send_document_bytes_empty_chat_id() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let file_bytes = b"content".to_vec();
let result = ch
.send_document_bytes("", file_bytes, "test.txt", None)
.await;
// Should not panic
assert!(result.is_err());
}
}