P1 deep-dive: platform, security, subagents, skills, profile + installer fix
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.
This commit is contained in:
@@ -1,32 +1,326 @@
|
||||
//! Telegram bot platform adapter.
|
||||
//! 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 gyandoot_core::PlatformResult;
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
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};
|
||||
|
||||
/// Adapter for the Telegram Bot API.
|
||||
// ─── 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,
|
||||
client: Client,
|
||||
allowed_users: Vec<i64>,
|
||||
/// 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.
|
||||
pub fn new(bot_token: String, allowed_users: Vec<i64>) -> Self {
|
||||
///
|
||||
/// `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,
|
||||
client: Client::new(),
|
||||
allowed_users,
|
||||
bot_token: token,
|
||||
base_url,
|
||||
client,
|
||||
last_update_id: Arc::new(std::sync::Mutex::new(0)),
|
||||
stopped: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Telegram API URL for a given method.
|
||||
fn api_url(&self, method: &str) -> String {
|
||||
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,103 +330,165 @@ impl Platform for TelegramPlatform {
|
||||
"telegram"
|
||||
}
|
||||
|
||||
async fn start(&self) -> PlatformResult<tokio::sync::mpsc::Receiver<IncomingMessage>> {
|
||||
info!("Starting Telegram polling...");
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(100);
|
||||
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 allowed_users = self.allowed_users.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 mut offset: i64 = 0;
|
||||
let platform = TelegramPlatform {
|
||||
bot_token,
|
||||
base_url,
|
||||
client,
|
||||
last_update_id,
|
||||
stopped,
|
||||
};
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/getUpdates?offset={}&timeout=30",
|
||||
bot_token, offset
|
||||
);
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => {
|
||||
if let Ok(data) = response.json::<serde_json::Value>().await {
|
||||
if let Some(results) = data["result"].as_array() {
|
||||
for update in results {
|
||||
let update_id = update["update_id"].as_i64().unwrap_or(0);
|
||||
offset = update_id + 1;
|
||||
if let Some(msg) = update.get("message") {
|
||||
let chat_id = msg["chat"]["id"].as_i64().unwrap_or(0);
|
||||
let text = msg["text"].as_str().unwrap_or("").to_string();
|
||||
let user_id = msg["from"]["id"].as_i64().unwrap_or(0).to_string();
|
||||
let username = msg["from"]["username"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
if platform.stopped.load(Ordering::SeqCst) {
|
||||
info!("Telegram polling loop stopped");
|
||||
break;
|
||||
}
|
||||
|
||||
if !allowed_users.is_empty() && !allowed_users.contains(&chat_id) {
|
||||
debug!("Ignoring message from non-allowed user {}", chat_id);
|
||||
continue;
|
||||
}
|
||||
let offset = {
|
||||
let id = platform.last_update_id.lock().unwrap();
|
||||
*id + 1
|
||||
};
|
||||
|
||||
let incoming = IncomingMessage {
|
||||
platform: "telegram".to_string(),
|
||||
channel_id: chat_id.to_string(),
|
||||
user_id,
|
||||
username,
|
||||
text,
|
||||
message_id: update_id.to_string(),
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if tx.send(incoming).await.is_err() {
|
||||
error!("Telegram channel closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 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) => {
|
||||
error!("Telegram poll error: {}", 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: i64 = message.channel_id.parse().unwrap_or(0);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": message.text,
|
||||
"parse_mode": "Markdown",
|
||||
});
|
||||
let chat_id = &message.channel_id;
|
||||
let text = &message.text;
|
||||
|
||||
let url = self.api_url("sendMessage");
|
||||
let response = self.client.post(&url).json(&body).send().await?;
|
||||
// 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());
|
||||
|
||||
if !response.status().is_success() {
|
||||
error!("Telegram send failed: {}", response.status());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
self.send_message_internal(chat_id, text, reply_to_id).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, channel_id: &str) -> PlatformResult<()> {
|
||||
let chat_id: i64 = channel_id.parse().unwrap_or(0);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"action": "typing",
|
||||
});
|
||||
|
||||
let url = self.api_url("sendChatAction");
|
||||
self.client.post(&url).json(&body).send().await?;
|
||||
Ok(())
|
||||
self.send_chat_action(channel_id, "typing").await
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> PlatformResult<()> {
|
||||
info!("Telegram platform shutting down");
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user