PLATFORM (gyandoot-platform): - Telegram adapter: Long-polling, sendMessage, sendDocument, chat actions - Discord adapter: REST polling, Bearer auth, channel discovery - Webhook adapter: Hyper v1 HTTP server, HMAC-SHA256 verification - CLI UX: Robot head banner, spinner, confirm prompts, colored output SECURITY (gyandoot-security): - CredentialVault: AES-256-GCM encrypted per-key files in ~/.gyandoot/vault/ - Sanitizer: PII redaction (email, phone, API keys, IPs), control char stripping - ThreatDetector: Prompt injection, jailbreak, exfiltration detection with severity SUBAGENTS (gyandoot-subagents): - SubAgentWorker: Isolated agent loop with tool filtering and workdir - SubAgentManager: Bounded concurrency via tokio::Semaphore - Full lifecycle tracking and result collection SKILLS (gyandoot-skills): - SkillsManager: scan, load, inject_skills with YAML frontmatter parsing - Preprocessor: template resolution - SkillBundle: JSON bundle serialization PROFILE (gyandoot-profile): - Profile struct with builder and validation - ConfigResolver: env > symlink > profile > default chain - ProfileManager: create, list, switch, delete, CRUD with real filesystem ops INSTALLER FIX: Fixed camofox-browser directory escaping bug that caused 'could not find Cargo.toml' on macOS. Always CD back to project root.
494 lines
16 KiB
Rust
494 lines
16 KiB
Rust
//! Telegram Bot API adapter — long-polling message receiver + REST sender.
|
|
//!
|
|
//! Uses the Telegram Bot API over HTTPS to receive updates via `getUpdates`
|
|
//! and send messages via `sendMessage` / `sendDocument`.
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use reqwest::multipart;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use tokio::sync::mpsc;
|
|
use tracing::{debug, error, info, warn};
|
|
|
|
use crate::{IncomingMessage, OutgoingMessage, Platform};
|
|
use gyandoot_core::{PlatformError, PlatformResult};
|
|
|
|
// ─── Telegram Update types (subset of Bot API) ──────────────────────────
|
|
|
|
/// Minimal deserialisation wrapper for Telegram API responses.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct TgApiResponse<T> {
|
|
ok: bool,
|
|
result: Option<T>,
|
|
description: Option<String>,
|
|
}
|
|
|
|
/// A single update from `getUpdates`.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct TgUpdate {
|
|
#[serde(rename = "update_id")]
|
|
update_id: i64,
|
|
#[serde(default)]
|
|
message: Option<TgMessage>,
|
|
#[serde(default)]
|
|
edited_message: Option<TgMessage>,
|
|
#[serde(default)]
|
|
channel_post: Option<TgMessage>,
|
|
#[serde(default)]
|
|
edited_channel_post: Option<TgMessage>,
|
|
}
|
|
|
|
/// A Telegram message object (subset of fields).
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[allow(dead_code)]
|
|
struct TgMessage {
|
|
#[serde(rename = "message_id")]
|
|
message_id: i64,
|
|
#[serde(default)]
|
|
from: Option<TgUser>,
|
|
#[serde(default)]
|
|
chat: Option<TgChat>,
|
|
#[serde(default)]
|
|
text: Option<String>,
|
|
#[serde(default)]
|
|
date: Option<i64>,
|
|
}
|
|
|
|
/// A Telegram user.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[allow(dead_code)]
|
|
struct TgUser {
|
|
id: i64,
|
|
#[serde(default)]
|
|
first_name: String,
|
|
#[serde(default)]
|
|
last_name: Option<String>,
|
|
#[serde(default)]
|
|
username: Option<String>,
|
|
}
|
|
|
|
/// A Telegram chat.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[allow(dead_code)]
|
|
struct TgChat {
|
|
id: i64,
|
|
#[serde(rename = "type")]
|
|
chat_type: String,
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
username: Option<String>,
|
|
#[serde(default)]
|
|
first_name: Option<String>,
|
|
#[serde(default)]
|
|
last_name: Option<String>,
|
|
}
|
|
|
|
// ─── TelegramPlatform ──────────────────────────────────────────────────────
|
|
|
|
/// Adapter for the Telegram Bot API (long-polling).
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```no_run
|
|
/// use gyandoot_platform::telegram::TelegramPlatform;
|
|
///
|
|
/// let platform = TelegramPlatform::new("YOUR_BOT_TOKEN");
|
|
/// ```
|
|
pub struct TelegramPlatform {
|
|
/// The bot token (starts with `bot` prefix handling).
|
|
bot_token: String,
|
|
/// Base URL for the Bot API (e.g. `https://api.telegram.org/bot<token>`).
|
|
base_url: String,
|
|
/// Shared HTTP client.
|
|
client: reqwest::Client,
|
|
/// The last processed `update_id` (used for offset-based long-polling).
|
|
last_update_id: Arc<std::sync::Mutex<i64>>,
|
|
/// Signal to stop the polling loop.
|
|
stopped: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl TelegramPlatform {
|
|
/// Create a new Telegram platform adapter.
|
|
///
|
|
/// `bot_token` is the secret token obtained from [@BotFather](https://t.me/BotFather).
|
|
/// It can include or omit the `bot` prefix — the adapter normalises it.
|
|
pub fn new(bot_token: impl Into<String>) -> Self {
|
|
let token: String = bot_token.into();
|
|
let prefix = if token.starts_with("bot") || token.starts_with("Bot") {
|
|
String::new()
|
|
} else {
|
|
"bot".to_string()
|
|
};
|
|
let base_url = format!("https://api.telegram.org/{prefix}{token}");
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.build()
|
|
.expect("Failed to create reqwest Client");
|
|
|
|
Self {
|
|
bot_token: token,
|
|
base_url,
|
|
client,
|
|
last_update_id: Arc::new(std::sync::Mutex::new(0)),
|
|
stopped: Arc::new(AtomicBool::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Build the full URL for a Bot API method.
|
|
fn method_url(&self, method: &str) -> String {
|
|
format!("{}/{}", self.base_url, method)
|
|
}
|
|
|
|
/// Internal: poll `getUpdates` and return parsed updates.
|
|
async fn fetch_updates(&self, offset: i64, timeout: i64) -> PlatformResult<Vec<TgUpdate>> {
|
|
let url = self.method_url("getUpdates");
|
|
let params = [
|
|
("offset", offset.to_string()),
|
|
("timeout", timeout.to_string()),
|
|
("allowed_updates", r#"["message"]"#.to_string()),
|
|
];
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&url)
|
|
.form(¶ms)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = %e, "Telegram getUpdates request failed");
|
|
PlatformError::Network(e)
|
|
})?;
|
|
|
|
let status = resp.status();
|
|
let body_text = resp.text().await.map_err(|e| {
|
|
error!(error = %e, "Failed to read Telegram response body");
|
|
PlatformError::Network(e)
|
|
})?;
|
|
|
|
if !status.is_success() {
|
|
warn!(
|
|
status = %status,
|
|
body = %body_text,
|
|
"Telegram API returned non-success status"
|
|
);
|
|
return Err(PlatformError::Send(format!(
|
|
"HTTP {status}: {body_text}"
|
|
)));
|
|
}
|
|
|
|
let api_resp: TgApiResponse<Vec<TgUpdate>> = serde_json::from_str(&body_text).map_err(|e| {
|
|
error!(error = %e, body = %body_text, "Failed to parse Telegram getUpdates response");
|
|
PlatformError::Send(format!("JSON parse error: {e}"))
|
|
})?;
|
|
|
|
if !api_resp.ok {
|
|
let desc = api_resp.description.unwrap_or_default();
|
|
warn!(description = %desc, "Telegram API returned ok=false");
|
|
return Err(PlatformError::Send(format!("API error: {desc}")));
|
|
}
|
|
|
|
Ok(api_resp.result.unwrap_or_default())
|
|
}
|
|
|
|
/// Internal: send a text message via `sendMessage`.
|
|
async fn send_message_internal(&self, chat_id: &str, text: &str, reply_to: Option<i64>) -> PlatformResult<()> {
|
|
let url = self.method_url("sendMessage");
|
|
|
|
let mut params = vec![
|
|
("chat_id", chat_id.to_string()),
|
|
("text", text.to_string()),
|
|
("parse_mode", "HTML".to_string()),
|
|
];
|
|
if let Some(reply) = reply_to {
|
|
params.push(("reply_to_message_id", reply.to_string()));
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&url)
|
|
.form(¶ms)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = %e, "Telegram sendMessage request failed");
|
|
PlatformError::Network(e)
|
|
})?;
|
|
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
warn!(status = %status, body = %body, "Telegram sendMessage failed");
|
|
return Err(PlatformError::Send(format!("HTTP {status}: {body}")));
|
|
}
|
|
|
|
debug!(chat_id = %chat_id, text_len = %text.len(), "Telegram message sent");
|
|
Ok(())
|
|
}
|
|
|
|
/// Internal: send a file via `sendDocument`.
|
|
#[allow(dead_code)]
|
|
async fn send_file_internal(
|
|
&self,
|
|
chat_id: &str,
|
|
file_bytes: Vec<u8>,
|
|
filename: &str,
|
|
caption: Option<&str>,
|
|
) -> PlatformResult<()> {
|
|
let url = self.method_url("sendDocument");
|
|
|
|
let file_part = multipart::Part::bytes(file_bytes)
|
|
.file_name(filename.to_string())
|
|
.mime_str("application/octet-stream")
|
|
.map_err(|e| PlatformError::Send(format!("MIME error: {e}")))?;
|
|
|
|
let mut form = multipart::Form::new()
|
|
.text("chat_id", chat_id.to_string())
|
|
.part("document", file_part);
|
|
|
|
if let Some(cap) = caption {
|
|
form = form.text("caption", cap.to_string());
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&url)
|
|
.multipart(form)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = %e, "Telegram sendDocument request failed");
|
|
PlatformError::Network(e)
|
|
})?;
|
|
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
warn!(status = %status, body = %body, "Telegram sendDocument failed");
|
|
return Err(PlatformError::Send(format!("HTTP {status}: {body}")));
|
|
}
|
|
|
|
debug!(chat_id = %chat_id, filename = %filename, "Telegram document sent");
|
|
Ok(())
|
|
}
|
|
|
|
/// Internal: send a chat action (typing indicator).
|
|
async fn send_chat_action(&self, chat_id: &str, action: &str) -> PlatformResult<()> {
|
|
let url = self.method_url("sendChatAction");
|
|
let params = [
|
|
("chat_id", chat_id.to_string()),
|
|
("action", action.to_string()),
|
|
];
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&url)
|
|
.form(¶ms)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
debug!(error = %e, "Telegram sendChatAction failed (non-critical)");
|
|
return PlatformError::Network(e);
|
|
})?;
|
|
|
|
if !resp.status().is_success() {
|
|
// sendChatAction failures are non-critical
|
|
debug!(
|
|
status = %resp.status(),
|
|
"Telegram sendChatAction non-success (ignored)"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Try to send a file upload. Returns true if the file was sent.
|
|
/// Used for future extensibility.
|
|
#[allow(dead_code)]
|
|
async fn try_send_file(
|
|
&self,
|
|
_channel_id: &str,
|
|
_text: &str,
|
|
_reply_to: Option<i64>,
|
|
) -> PlatformResult<bool> {
|
|
// Not yet automatically detecting files from message text.
|
|
// This method provides the hook for future file content detection.
|
|
Ok(false)
|
|
}
|
|
|
|
/// Normalise a Telegram chat ID to string.
|
|
#[allow(dead_code)]
|
|
fn chat_id_to_string(chat_id: i64) -> String {
|
|
chat_id.to_string()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Platform for TelegramPlatform {
|
|
fn name(&self) -> &str {
|
|
"telegram"
|
|
}
|
|
|
|
async fn start(&self) -> PlatformResult<mpsc::Receiver<IncomingMessage>> {
|
|
info!("Starting Telegram long-polling adapter");
|
|
self.stopped.store(false, Ordering::SeqCst);
|
|
|
|
let (tx, rx) = mpsc::channel::<IncomingMessage>(256);
|
|
|
|
let bot_token = self.bot_token.clone();
|
|
let base_url = self.base_url.clone();
|
|
let client = self.client.clone();
|
|
let last_update_id = Arc::clone(&self.last_update_id);
|
|
let stopped = Arc::clone(&self.stopped);
|
|
|
|
// Spawn the polling loop on a background task.
|
|
tokio::spawn(async move {
|
|
let platform = TelegramPlatform {
|
|
bot_token,
|
|
base_url,
|
|
client,
|
|
last_update_id,
|
|
stopped,
|
|
};
|
|
|
|
loop {
|
|
if platform.stopped.load(Ordering::SeqCst) {
|
|
info!("Telegram polling loop stopped");
|
|
break;
|
|
}
|
|
|
|
let offset = {
|
|
let id = platform.last_update_id.lock().unwrap();
|
|
*id + 1
|
|
};
|
|
|
|
match platform.fetch_updates(offset, 30).await {
|
|
Ok(updates) => {
|
|
for update in updates {
|
|
// Track the highest update_id
|
|
{
|
|
let mut id = platform.last_update_id.lock().unwrap();
|
|
if update.update_id >= *id {
|
|
*id = update.update_id;
|
|
}
|
|
}
|
|
|
|
// Extract the message from whichever field is populated
|
|
let msg = update
|
|
.message
|
|
.or(update.edited_message)
|
|
.or(update.channel_post)
|
|
.or(update.edited_channel_post);
|
|
|
|
if let Some(tg_msg) = msg {
|
|
let text = tg_msg.text.unwrap_or_default();
|
|
let chat_id = tg_msg
|
|
.chat
|
|
.as_ref()
|
|
.map(|c| c.id.to_string())
|
|
.unwrap_or_default();
|
|
let user_id = tg_msg
|
|
.from
|
|
.as_ref()
|
|
.map(|u| u.id.to_string())
|
|
.unwrap_or_default();
|
|
let username = tg_msg
|
|
.from
|
|
.as_ref()
|
|
.and_then(|u| u.username.clone())
|
|
.or_else(|| {
|
|
tg_msg
|
|
.from
|
|
.as_ref()
|
|
.map(|u| u.first_name.clone())
|
|
})
|
|
.unwrap_or_default();
|
|
let message_id = tg_msg.message_id.to_string();
|
|
|
|
// Skip empty messages
|
|
if text.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let incoming = IncomingMessage {
|
|
platform: "telegram".to_string(),
|
|
channel_id: chat_id,
|
|
user_id,
|
|
username,
|
|
text,
|
|
message_id,
|
|
};
|
|
|
|
if tx.send(incoming).await.is_err() {
|
|
warn!("Telegram receiver channel closed");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(error = %e, "Telegram poll error, retrying in 5s");
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
info!("Telegram polling loop spawned");
|
|
Ok(rx)
|
|
}
|
|
|
|
async fn send(&self, message: OutgoingMessage) -> PlatformResult<()> {
|
|
let chat_id = &message.channel_id;
|
|
let text = &message.text;
|
|
|
|
// Try to extract reply_to as numeric message ID
|
|
let reply_to_id: Option<i64> = message
|
|
.reply_to
|
|
.as_ref()
|
|
.and_then(|r| r.parse::<i64>().ok());
|
|
|
|
self.send_message_internal(chat_id, text, reply_to_id).await
|
|
}
|
|
|
|
async fn send_typing(&self, channel_id: &str) -> PlatformResult<()> {
|
|
self.send_chat_action(channel_id, "typing").await
|
|
}
|
|
|
|
async fn shutdown(&self) -> PlatformResult<()> {
|
|
info!("Shutting down Telegram adapter");
|
|
self.stopped.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_new_platform_with_prefix() {
|
|
let p = TelegramPlatform::new("bot12345:ABC-DEF");
|
|
assert!(p.base_url.contains("bot12345:ABC-DEF"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_platform_without_prefix() {
|
|
let p = TelegramPlatform::new("12345:ABC-DEF");
|
|
assert!(p.base_url.contains("bot12345:ABC-DEF"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_method_url() {
|
|
let p = TelegramPlatform::new("test:token");
|
|
assert!(p.method_url("sendMessage").ends_with("/sendMessage"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_chat_id_to_string() {
|
|
assert_eq!(TelegramPlatform::chat_id_to_string(-12345), "-12345");
|
|
}
|
|
} |