- Expand communication style presets (professional, expressive, custom) - Enrich SOUL.md with human-like tone and emoji-awareness guidance - Add crash recovery and sub-task scoping guidance to AGENTS.md scaffold - Add 'Use when / Don't use when' guidance to TOOLS.md and runtime prompts - Implement memory hygiene system with configurable archiving and retention - Add MemoryConfig options: hygiene_enabled, archive_after_days, purge_after_days, conversation_retention_days - Archive old daily memory and session files to archive subdirectories - Purge old archives and prune stale SQLite conversation rows - Add comprehensive tests for new features
247 lines
7.9 KiB
Rust
247 lines
7.9 KiB
Rust
use super::traits::{Channel, ChannelMessage};
|
|
use async_trait::async_trait;
|
|
use uuid::Uuid;
|
|
|
|
/// Telegram channel — long-polls the Bot API for updates
|
|
pub struct TelegramChannel {
|
|
bot_token: String,
|
|
allowed_users: Vec<String>,
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl TelegramChannel {
|
|
pub fn new(bot_token: String, allowed_users: Vec<String>) -> Self {
|
|
Self {
|
|
bot_token,
|
|
allowed_users,
|
|
client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
|
|
fn api_url(&self, method: &str) -> String {
|
|
format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
|
|
}
|
|
|
|
fn is_user_allowed(&self, username: &str) -> bool {
|
|
self.allowed_users.iter().any(|u| u == "*" || u == username)
|
|
}
|
|
|
|
fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool
|
|
where
|
|
I: IntoIterator<Item = &'a str>,
|
|
{
|
|
identities.into_iter().any(|id| self.is_user_allowed(id))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for TelegramChannel {
|
|
fn name(&self) -> &str {
|
|
"telegram"
|
|
}
|
|
|
|
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
|
let body = serde_json::json!({
|
|
"chat_id": chat_id,
|
|
"text": message,
|
|
"parse_mode": "Markdown"
|
|
});
|
|
|
|
self.client
|
|
.post(self.api_url("sendMessage"))
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
|
let mut offset: i64 = 0;
|
|
|
|
tracing::info!("Telegram channel listening for messages...");
|
|
|
|
loop {
|
|
let url = self.api_url("getUpdates");
|
|
let body = serde_json::json!({
|
|
"offset": offset,
|
|
"timeout": 30,
|
|
"allowed_updates": ["message"]
|
|
});
|
|
|
|
let resp = match self.client.post(&url).json(&body).send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!("Telegram poll error: {e}");
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let data: serde_json::Value = match resp.json().await {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::warn!("Telegram parse error: {e}");
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) {
|
|
for update in results {
|
|
// Advance offset past this update
|
|
if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) {
|
|
offset = uid + 1;
|
|
}
|
|
|
|
let Some(message) = update.get("message") else {
|
|
continue;
|
|
};
|
|
|
|
let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
|
|
continue;
|
|
};
|
|
|
|
let username_opt = message
|
|
.get("from")
|
|
.and_then(|f| f.get("username"))
|
|
.and_then(|u| u.as_str());
|
|
let username = username_opt.unwrap_or("unknown");
|
|
|
|
let user_id = message
|
|
.get("from")
|
|
.and_then(|f| f.get("id"))
|
|
.and_then(serde_json::Value::as_i64);
|
|
let user_id_str = user_id.map(|id| id.to_string());
|
|
|
|
let mut identities = vec![username];
|
|
if let Some(ref id) = user_id_str {
|
|
identities.push(id.as_str());
|
|
}
|
|
|
|
if !self.is_any_user_allowed(identities.iter().copied()) {
|
|
tracing::warn!(
|
|
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
|
|
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
|
user_id_str.as_deref().unwrap_or("unknown")
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let chat_id = message
|
|
.get("chat")
|
|
.and_then(|c| c.get("id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.map(|id| id.to_string())
|
|
.unwrap_or_default();
|
|
|
|
let msg = ChannelMessage {
|
|
id: Uuid::new_v4().to_string(),
|
|
sender: chat_id,
|
|
content: text.to_string(),
|
|
channel: "telegram".to_string(),
|
|
timestamp: std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs(),
|
|
};
|
|
|
|
if tx.send(msg).await.is_err() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn health_check(&self) -> bool {
|
|
self.client
|
|
.get(self.api_url("getMe"))
|
|
.send()
|
|
.await
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn telegram_channel_name() {
|
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
|
assert_eq!(ch.name(), "telegram");
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_api_url() {
|
|
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
|
assert_eq!(
|
|
ch.api_url("getMe"),
|
|
"https://api.telegram.org/bot123:ABC/getMe"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_allowed_wildcard() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["*".into()]);
|
|
assert!(ch.is_user_allowed("anyone"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_allowed_specific() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]);
|
|
assert!(ch.is_user_allowed("alice"));
|
|
assert!(!ch.is_user_allowed("eve"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_denied_empty() {
|
|
let ch = TelegramChannel::new("t".into(), vec![]);
|
|
assert!(!ch.is_user_allowed("anyone"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_exact_match_not_substring() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
|
|
assert!(!ch.is_user_allowed("alice_bot"));
|
|
assert!(!ch.is_user_allowed("alic"));
|
|
assert!(!ch.is_user_allowed("malice"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_empty_string_denied() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
|
|
assert!(!ch.is_user_allowed(""));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_case_sensitive() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]);
|
|
assert!(ch.is_user_allowed("Alice"));
|
|
assert!(!ch.is_user_allowed("alice"));
|
|
assert!(!ch.is_user_allowed("ALICE"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_wildcard_with_specific_users() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]);
|
|
assert!(ch.is_user_allowed("alice"));
|
|
assert!(ch.is_user_allowed("bob"));
|
|
assert!(ch.is_user_allowed("anyone"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_allowed_by_numeric_id_identity() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]);
|
|
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_user_denied_when_none_of_identities_match() {
|
|
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
|
|
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
|
}
|
|
}
|