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:
2026-06-11 21:18:42 +05:30
parent 54d8ca3774
commit dd21654555
21 changed files with 6738 additions and 761 deletions

View File

@@ -1,41 +1,486 @@
//! CLI user experience — rich terminal interface.
//! CLI UX helpers — banner printing, colored output, spinners, confirm prompts.
//!
//! Provides utilities for building interactive CLI experiences in GyanDoot,
//! including ASCII art banners, status formatting, spinner animations, and
//! simple confirmation prompts.
use async_trait::async_trait;
use gyandoot_core::PlatformResult;
use tracing::info;
use std::io::{self, Write};
use crate::{IncomingMessage, OutgoingMessage, Platform};
// ─── ANSI color constants ─────────────────────────────────────────────────
/// CLI platform adapter — reads from stdin, writes to stdout.
pub struct CliPlatform;
/// ANSI escape codes for terminal colours.
#[allow(dead_code)]
pub mod color {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const BLUE: &str = "\x1b[34m";
pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
pub const WHITE: &str = "\x1b[37m";
pub const BRIGHT_CYAN: &str = "\x1b[96m";
pub const BRIGHT_GREEN: &str = "\x1b[92m";
pub const BRIGHT_YELLOW: &str = "\x1b[93m";
pub const BRIGHT_RED: &str = "\x1b[91m";
pub const BRIGHT_BLUE: &str = "\x1b[94m";
pub const BRIGHT_MAGENTA: &str = "\x1b[95m";
}
#[async_trait]
impl Platform for CliPlatform {
fn name(&self) -> &str {
"cli"
// ─── Primary Banner (Kapil's Robot Head) ─────────────────────────────────
/// The primary ASCII art banner — robot head with 4-bit ANSI color.
///
/// This is Kapil's approved design. It is displayed when the terminal is
/// interactive (not piped).
pub const BANNER_ART: &str = r#"
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▄▟███████████████████████▙▄
▟███████████████████████████▙
▟██████████████████████████████▙
████████████████████████████████
██▀▀▀▀▀▀▀█████████▀▀▀▀▀▀▀████
██ ████ ███████████ ████ █████
██ ████ ███████████ ████ █████
██ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ █████
████████████████████████████████
████████████████████████████████
██ ▄▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄▄ █████
██ ████ ████ ████ ████ █████
██ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ █████
████████████████████████████████
████████████████████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"#;
/// Fallback banner for non-interactive (piped) terminals.
pub const BANNER_FALLBACK: &str = r#"
╔══════════════════════════╗
║ GyanDoot v2.0 ║
║ AI Agent Platform ║
╚══════════════════════════╝
"#;
// ─── Banner functions ─────────────────────────────────────────────────────
/// Print the primary banner with ANSI colors.
///
/// Uses cyan for the robot head outline and yellow for accents.
/// Falls back to [`print_banner_fallback`] if the output is not a terminal
/// or if colors are unsupported.
pub fn print_banner() {
if !atty::is(atty::Stream::Stdout) {
print_banner_fallback();
return;
}
async fn start(&self) -> PlatformResult<tokio::sync::mpsc::Receiver<IncomingMessage>> {
info!("CLI platform ready (reading from stdin)");
// CLI input is handled directly by the agent loop in gyandoot-agent,
// via the AgentLoop's read_input method. This channel remains empty
// since CLI is the default interactive mode.
let (_tx, rx) = tokio::sync::mpsc::channel(100);
Ok(rx)
let robot_head = format!(
"{cyan}{bold}{art}{reset}",
cyan = color::CYAN,
bold = color::BOLD,
art = BANNER_ART,
reset = color::RESET,
);
let tagline = format!(
"{bright_cyan}{bold} ⚡ GyanDoot v2.0 — AI Agent Platform ⚡{reset}",
bright_cyan = color::BRIGHT_CYAN,
bold = color::BOLD,
reset = color::RESET,
);
let separator = format!(
"{dim}{dashes}{reset}",
dim = color::DIM,
dashes = " ─────────────────────────────────────",
reset = color::RESET,
);
println!("{robot_head}");
println!("{tagline}");
println!("{separator}");
println!();
}
/// Print the fallback banner (no colors, compact).
pub fn print_banner_fallback() {
println!("{}", BANNER_FALLBACK);
}
/// Return the banner text without ANSI codes (plain text).
///
/// Useful for embedding in logs or transmitting over text-only channels.
pub fn banner_plain() -> String {
format!(
"{}\n GyanDoot v2.0 — AI Agent Platform\n",
BANNER_FALLBACK
)
}
// ─── Colored output helpers ───────────────────────────────────────────────
/// Print a success message (green).
pub fn print_success(msg: impl AsRef<str>) {
println!(
"{green}✓{reset} {msg}",
green = color::GREEN,
reset = color::RESET,
msg = msg.as_ref()
);
}
/// Print an error message (red).
pub fn print_error(msg: impl AsRef<str>) {
eprintln!(
"{red}✗{reset} {msg}",
red = color::RED,
reset = color::RESET,
msg = msg.as_ref()
);
}
/// Print a warning message (yellow).
pub fn print_warning(msg: impl AsRef<str>) {
println!(
"{yellow}⚠{reset} {msg}",
yellow = color::YELLOW,
reset = color::RESET,
msg = msg.as_ref()
);
}
/// Print an info message (cyan).
pub fn print_info(msg: impl AsRef<str>) {
println!(
"{cyan}ℹ{reset} {msg}",
cyan = color::CYAN,
reset = color::RESET,
msg = msg.as_ref()
);
}
/// Print a status line with a coloured label.
///
/// # Example
///
/// ```ignore
/// show_status("connected", "Connected to Telegram API");
/// ```
pub fn show_status(label: &str, message: &str) {
let (prefix, color_code) = match label.to_lowercase().as_str() {
"success" | "ok" | "connected" => ("✓", color::GREEN),
"error" | "failed" | "err" => ("✗", color::RED),
"warn" | "warning" => ("⚠", color::YELLOW),
"info" | "note" => ("ℹ", color::CYAN),
_ => ("•", color::WHITE),
};
println!(
"{color}{bold}{prefix}{reset} {dim}{label}:{reset} {message}",
color = color_code,
bold = color::BOLD,
prefix = prefix,
reset = color::RESET,
dim = color::DIM,
label = label,
message = message,
);
}
/// Return a string with the given ANSI colour applied.
pub fn colored(text: &str, ansi_color: &str) -> String {
format!("{}{}{}", ansi_color, text, color::RESET)
}
/// Return a bold string.
pub fn bold(text: &str) -> String {
format!("{}{}{}", color::BOLD, text, color::RESET)
}
/// Return a dimmed string.
pub fn dim(text: &str) -> String {
format!("{}{}{}", color::DIM, text, color::RESET)
}
// ─── Spinner / Progress display ──────────────────────────────────────────
/// Simple spinner states for progress display.
#[derive(Debug, Clone, Copy)]
pub enum SpinnerState {
Active,
Completed,
Failed,
}
/// Spinner frame characters.
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Sleep duration between spinner frames (milliseconds).
const SPINNER_INTERVAL_MS: u64 = 80;
/// Run a closure while displaying a spinner animation.
///
/// The spinner is shown on stderr and the closure result is returned.
///
/// # Example
///
/// ```ignore
/// let result = progress_spinner("Connecting...", || {
/// std::thread::sleep(std::time::Duration::from_secs(2));
/// 42
/// });
/// ```
pub fn progress_spinner<F, T>(message: &str, f: F) -> T
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
if !atty::is(atty::Stream::Stderr) {
// Non-interactive: just print message and run
eprintln!("{}...", message);
return f();
}
async fn send(&self, message: OutgoingMessage) -> PlatformResult<()> {
println!("{}", message.text);
Ok(())
let start = std::time::Instant::now();
let frame_count = SPINNER_FRAMES.len();
let mut frame_idx = 0;
// Spawn a thread to animate the spinner while the closure runs
let (tx, rx) = std::sync::mpsc::channel::<T>();
let handle = std::thread::spawn(move || {
let result = f();
let _ = tx.send(result);
});
// Animate spinner until the closure completes
loop {
if rx.recv_timeout(std::time::Duration::from_millis(SPINNER_INTERVAL_MS)).is_ok() {
break;
}
let elapsed = start.elapsed();
let spinner_char = SPINNER_FRAMES[frame_idx % frame_count];
frame_idx += 1;
// Print spinner line (clear with \r)
eprint!(
"\r{cyan}{spinner}{reset} {msg} ({elapsed:.1}s)",
cyan = color::CYAN,
spinner = spinner_char,
reset = color::RESET,
msg = message,
elapsed = elapsed.as_secs_f64(),
);
let _ = io::stderr().flush();
}
async fn send_typing(&self, _channel_id: &str) -> PlatformResult<()> {
// No typing indicator needed for CLI
Ok(())
let result = rx.recv().expect("Spinner thread panicked");
handle.join().expect("Spinner thread panicked");
// Clear spinner line
eprint!("\r\x1b[K");
let _ = io::stderr().flush();
// Print completion line
let elapsed = start.elapsed();
eprintln!(
"{green}✓{reset} {msg} ({elapsed:.1}s)",
green = color::GREEN,
reset = color::RESET,
msg = message,
elapsed = elapsed.as_secs_f64(),
);
result
}
// ─── Confirm prompt ───────────────────────────────────────────────────────
/// Prompt the user for a yes/no confirmation.
///
/// Returns `true` if the user answered yes, `false` otherwise.
/// If `default_yes` is true and the user just presses Enter, the answer
/// defaults to yes.
///
/// # Example
///
/// ```ignore
/// if confirm_prompt("Are you sure?", true) {
/// // proceed
/// }
/// ```
pub fn confirm_prompt(prompt: &str, default_yes: bool) -> bool {
let hint = if default_yes { "Y/n" } else { "y/N" };
loop {
print!(
"{yellow}?{reset} {bold}{prompt}{reset} [{hint}]: ",
yellow = color::YELLOW,
reset = color::RESET,
bold = color::BOLD,
prompt = prompt,
hint = hint,
);
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_err() {
// If stdin read fails, use default
return default_yes;
}
let trimmed = input.trim().to_lowercase();
match trimmed.as_str() {
"y" | "yes" => return true,
"n" | "no" => return false,
"" => return default_yes,
_ => {
eprintln!(
"{red}Please answer 'y' or 'n'.{reset}",
red = color::RED,
reset = color::RESET,
);
}
}
}
}
// ─── Status line formatting ───────────────────────────────────────────────
/// Format a status line for a CLI status display.
///
/// Returns a formatted string like `[ RUNNING ] Agent processing...`.
pub fn format_status_line(status: &str, message: &str) -> String {
let (status_colored, _status_upper) = match status.to_lowercase().as_str() {
"ok" | "success" | "done" => (format!("{}OK{}", color::GREEN, color::RESET), "OK"),
"error" | "fail" | "failed" => (format!("{}ERROR{}", color::RED, color::RESET), "ERROR"),
"warn" | "warning" => (format!("{}WARN{}", color::YELLOW, color::RESET), "WARN"),
"info" | "note" => (format!("{}INFO{}", color::CYAN, color::RESET), "INFO"),
"running" | "active" => (
format!("{}RUNNING{}", color::BRIGHT_CYAN, color::RESET),
"RUNNING",
),
"idle" => (format!("{}IDLE{}", color::DIM, color::RESET), "IDLE"),
"thinking" => (
format!("{}THINKING{}", color::BRIGHT_MAGENTA, color::RESET),
"THINKING",
),
_ => (
format!("{} {}", status, color::RESET),
status,
),
};
format!(
"{bold}[{status_colored}{bold}]{reset} {message}",
bold = color::BOLD,
status_colored = status_colored,
reset = color::RESET,
message = message,
)
}
/// Print a formatted status line to stdout.
pub fn print_status(status: &str, message: &str) {
println!("{}", format_status_line(status, message));
}
/// Print a formatted status line to stderr.
pub fn eprint_status(status: &str, message: &str) {
eprintln!("{}", format_status_line(status, message));
}
/// Clear the current line and print a status line (for animated progress).
pub fn update_status_line(status: &str, message: &str) {
eprint!(
"\r\x1b[K{}",
format_status_line(status, message)
);
let _ = io::stderr().flush();
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_banner_plain() {
let plain = banner_plain();
assert!(plain.contains("GyanDoot"));
assert!(!plain.contains("\x1b")); // No ANSI codes
}
async fn shutdown(&self) -> PlatformResult<()> {
info!("CLI platform shutting down");
Ok(())
#[test]
fn test_colored() {
let result = colored("hello", color::RED);
assert!(result.starts_with("\x1b[31m"));
assert!(result.ends_with("\x1b[0m"));
}
#[test]
fn test_bold() {
let result = bold("test");
assert!(result.starts_with("\x1b[1m"));
assert!(result.ends_with("\x1b[0m"));
}
#[test]
fn test_dim() {
let result = dim("test");
assert!(result.starts_with("\x1b[2m"));
assert!(result.ends_with("\x1b[0m"));
}
#[test]
fn test_format_status_line_ok() {
let line = format_status_line("ok", "Everything good");
assert!(line.contains("OK"));
assert!(line.contains("Everything good"));
}
#[test]
fn test_format_status_line_error() {
let line = format_status_line("error", "Something broke");
assert!(line.contains("ERROR"));
assert!(line.contains("Something broke"));
}
#[test]
fn test_format_status_line_running() {
let line = format_status_line("running", "Processing...");
assert!(line.contains("RUNNING"));
assert!(line.contains("Processing..."));
}
#[test]
fn test_confirm_default_yes() {
// Test that default_yes works when no input is available
// (read_line on empty stdin returns empty string)
// This tests the "" => default_yes branch
let result = confirm_prompt("test", true);
// In non-interactive mode with no stdin, read_line fails, returns default
// We can't easily test this without mocking stdin, but the logic is sound.
}
#[test]
fn test_print_success_does_not_panic() {
// Just verify these don't crash
print_success("test");
print_error("test");
print_warning("test");
print_info("test");
show_status("ok", "test");
print_status("info", "test");
eprint_status("warn", "test");
}
#[test]
fn test_spinner_frames_nonempty() {
assert!(!SPINNER_FRAMES.is_empty());
}
}

View File

@@ -1,38 +1,316 @@
//! Discord bot platform adapter.
//! Discord Bot adapter — REST-based message sender and poller.
//!
//! Uses the Discord REST API (no gateway WebSocket). Messages are fetched
//! via periodic polling of channel history, and sent via the Create Message
//! endpoint. Authentication uses a Bot token with the `Bot` prefix.
use async_trait::async_trait;
use gyandoot_core::PlatformResult;
use reqwest::Client;
use tracing::{debug, error, info};
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 Discord Bot API (REST + Gateway).
// ─── Discord API types (subset) ──────────────────────────────────────────
/// Generic Discord API response wrapper for list endpoints.
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct DiscordListResponse<T> {
/// The list of items (used in channel messages endpoints).
#[serde(default)]
items: Option<Vec<T>>,
}
/// A Discord message object (limited subset).
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct DiscordMessage {
id: String,
#[serde(default)]
channel_id: String,
#[serde(default)]
author: Option<DiscordUser>,
#[serde(default)]
content: String,
#[serde(default)]
timestamp: Option<String>,
}
/// A Discord user.
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct DiscordUser {
id: String,
#[serde(default)]
username: String,
#[serde(default)]
global_name: Option<String>,
#[serde(default)]
discriminator: Option<String>,
}
/// Request payload for creating a message.
#[derive(Debug, serde::Serialize)]
struct CreateMessagePayload {
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
message_reference: Option<MessageReference>,
}
/// Reference to a parent message for replies.
#[derive(Debug, serde::Serialize)]
struct MessageReference {
message_id: String,
}
/// Typing indicator request payload.
#[derive(Debug, serde::Serialize)]
#[allow(dead_code)]
struct TypingPayload;
// ─── DiscordPlatform ──────────────────────────────────────────────────────
/// Adapter for the Discord REST API.
///
/// Uses simple long-polling of recent channel messages as a lightweight
/// alternative to the Gateway WebSocket. Suitable for single-channel or
/// small-scale bots. For production multi-guild bots, prefer a Gateway
/// connection (not implemented here).
///
/// # Example
///
/// ```no_run
/// use gyandoot_platform::discord::DiscordPlatform;
///
/// let platform = DiscordPlatform::new("YOUR_BOT_TOKEN", None);
/// ```
pub struct DiscordPlatform {
/// The raw bot token (without `Bot ` prefix — added automatically).
bot_token: String,
client: Client,
guild_id: Option<u64>,
/// Base URL for the Discord API.
api_base: String,
/// Shared HTTP client.
client: reqwest::Client,
/// Optional guild ID for channel resolution.
guild_id: Option<String>,
/// Set of channel IDs being polled (holds last-seen message IDs).
last_message_ids: Arc<std::sync::Mutex<std::collections::HashMap<String, String>>>,
/// Signal to stop polling.
stopped: Arc<AtomicBool>,
}
impl DiscordPlatform {
/// Create a new Discord platform adapter.
pub fn new(bot_token: String, guild_id: Option<u64>) -> Self {
///
/// `bot_token` is the secret token from the Discord Developer Portal.
/// `guild_id` is optional — if provided, channels can be resolved
/// by name; otherwise the adapter works with raw channel IDs.
pub fn new(bot_token: impl Into<String>, guild_id: Option<u64>) -> Self {
let token: String = bot_token.into();
Self {
bot_token,
client: Client::new(),
guild_id,
bot_token: token,
api_base: "https://discord.com/api/v10".to_string(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to create reqwest Client"),
guild_id: guild_id.map(|id| id.to_string()),
last_message_ids: Arc::new(std::sync::Mutex::new(
std::collections::HashMap::new(),
)),
stopped: Arc::new(AtomicBool::new(false)),
}
}
/// Discord REST API base URL.
/// Build the full URL for a Discord API endpoint.
fn api_url(&self, path: &str) -> String {
format!("https://discord.com/api/v10{}", path)
format!("{}{}", self.api_base, path)
}
/// Authorization header for Discord API.
/// Build the auth header value.
fn auth_header(&self) -> String {
format!("Bot {}", self.bot_token)
}
/// Build a standard GET request with auth.
fn get(&self, url: &str) -> reqwest::RequestBuilder {
self.client
.get(url)
.header("Authorization", self.auth_header())
.header("User-Agent", "GyanDoot/2.0")
}
/// Build a standard POST request with auth.
fn post(&self, url: &str) -> reqwest::RequestBuilder {
self.client
.post(url)
.header("Authorization", self.auth_header())
.header("User-Agent", "GyanDoot/2.0")
.header("Content-Type", "application/json")
}
/// Fetch recent messages from a Discord channel.
///
/// Returns up to `limit` messages, sorted by most recent first.
async fn fetch_channel_messages(
&self,
channel_id: &str,
limit: u8,
) -> PlatformResult<Vec<DiscordMessage>> {
let url = self.api_url(&format!("/channels/{channel_id}/messages?limit={limit}"));
let resp = self.get(&url).send().await.map_err(|e| {
error!(error = %e, "Discord GET channel messages failed");
PlatformError::Network(e)
})?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
if status == 401 || status == 403 {
warn!(status = %status, body = %body, "Discord auth error");
return Err(PlatformError::Auth(format!("HTTP {status}: {body}")));
}
warn!(status = %status, body = %body, "Discord API error");
return Err(PlatformError::Send(format!("HTTP {status}: {body}")));
}
let messages: Vec<DiscordMessage> = resp.json().await.map_err(|e| {
error!(error = %e, "Failed to parse Discord messages response");
PlatformError::Send(format!("JSON parse error: {e}"))
})?;
Ok(messages)
}
/// Send a message to a Discord channel.
async fn send_message_internal(
&self,
channel_id: &str,
content: &str,
reply_to: Option<&str>,
) -> PlatformResult<()> {
let url = self.api_url(&format!("/channels/{channel_id}/messages"));
let payload = CreateMessagePayload {
content: content.to_string(),
message_reference: reply_to.map(|msg_id| MessageReference {
message_id: msg_id.to_string(),
}),
};
let resp = self
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| {
error!(error = %e, "Discord POST message failed");
PlatformError::Network(e)
})?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(PlatformError::Auth(format!("HTTP {status}: {body}")));
}
warn!(status = %status, body = %body, "Discord sendMessage failed");
return Err(PlatformError::Send(format!("HTTP {status}: {body}")));
}
debug!(channel_id = %channel_id, content_len = %content.len(), "Discord message sent");
Ok(())
}
/// Trigger a typing indicator in a Discord channel.
async fn trigger_typing(&self, channel_id: &str) -> PlatformResult<()> {
let url = self.api_url(&format!("/channels/{channel_id}/typing"));
let resp = self.post(&url).send().await.map_err(|e| {
debug!(error = %e, "Discord typing indicator failed (non-critical)");
PlatformError::Network(e)
})?;
if !resp.status().is_success() {
// typing indicator failures are non-critical
debug!(
status = %resp.status(),
"Discord typing indicator non-success (ignored)"
);
}
Ok(())
}
/// Get a list of channels for the configured guild.
/// Returns a map of channel name → channel ID.
async fn get_guild_channels(&self) -> PlatformResult<std::collections::HashMap<String, String>> {
let guild_id = self
.guild_id
.as_ref()
.ok_or_else(|| PlatformError::Unavailable("No guild_id configured".to_string()))?;
let url = self.api_url(&format!("/guilds/{guild_id}/channels"));
let resp = self.get(&url).send().await.map_err(|e| {
error!(error = %e, "Discord GET guild channels 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, "Discord guild channels error");
return Err(PlatformError::Send(format!("HTTP {status}: {body}")));
}
// Discord returns an array of channel objects
#[derive(serde::Deserialize)]
struct GuildChannel {
id: String,
name: String,
#[serde(rename = "type")]
channel_type: u8,
}
let channels: Vec<GuildChannel> = resp.json().await.map_err(|e| {
error!(error = %e, "Failed to parse guild channels response");
PlatformError::Send(format!("JSON parse error: {e}"))
})?;
let mut map = std::collections::HashMap::new();
for ch in channels {
// Only include text channels (type 0) and announcement channels (type 5, 13, 15)
if ch.channel_type == 0 || ch.channel_type == 5 || ch.channel_type == 13 || ch.channel_type == 15 {
map.insert(ch.name, ch.id);
}
}
Ok(map)
}
/// Resolve a channel reference (name or ID) to a channel ID.
pub async fn resolve_channel(&self, reference: &str) -> PlatformResult<String> {
// If it looks like a numeric ID, return as-is
if reference.parse::<u64>().is_ok() {
return Ok(reference.to_string());
}
// Try to resolve by name via guild channels
if let Ok(channels) = self.get_guild_channels().await {
if let Some(id) = channels.get(reference) {
return Ok(id.clone());
}
}
Err(PlatformError::Unavailable(format!(
"Channel not found: {reference}"
)))
}
}
#[async_trait]
@@ -41,49 +319,193 @@ impl Platform for DiscordPlatform {
"discord"
}
async fn start(&self) -> PlatformResult<tokio::sync::mpsc::Receiver<IncomingMessage>> {
info!("Starting Discord platform (REST polling mode)...");
// Note: Full Gateway implementation requires the 'tungstenite' crate.
// For this scaffold, we simulate via a channel that stays empty until
// proper WebSocket Gateway is connected.
let (_tx, rx) = tokio::sync::mpsc::channel(100);
info!("Discord Gateway connection not yet implemented — requires tungstenite/async-tungstenite");
async fn start(&self) -> PlatformResult<mpsc::Receiver<IncomingMessage>> {
info!("Starting Discord REST polling adapter");
self.stopped.store(false, Ordering::SeqCst);
let (tx, rx) = mpsc::channel::<IncomingMessage>(256);
let bot_token = self.bot_token.clone();
let api_base = self.api_base.clone();
let client = self.client.clone();
let guild_id = self.guild_id.clone();
let last_message_ids = Arc::clone(&self.last_message_ids);
let stopped = Arc::clone(&self.stopped);
// Spawn the polling loop.
tokio::spawn(async move {
let platform = DiscordPlatform {
bot_token,
api_base,
client,
guild_id,
last_message_ids,
stopped,
};
// Discover channels if guild_id is set
let channels: Vec<String> = if let Ok(channels) = platform.get_guild_channels().await {
info!(count = %channels.len(), "Discovered Discord guild channels");
channels.into_values().collect()
} else {
// Without guild, we can't auto-discover channels.
// The user must call start() after configuring channels manually.
warn!("No guild channels discovered — Discord adapter will listen for no channels");
Vec::new()
};
loop {
if platform.stopped.load(Ordering::SeqCst) {
info!("Discord polling loop stopped");
break;
}
for channel_id in &channels {
if platform.stopped.load(Ordering::SeqCst) {
break;
}
match platform.fetch_channel_messages(channel_id, 5).await {
Ok(messages) => {
// Track the last-seen message ID for this channel
let last_id = {
let map = platform.last_message_ids.lock().unwrap();
map.get(channel_id).cloned()
};
// Messages come newest-first; process in reverse (oldest first)
for msg in messages.iter().rev() {
// Skip messages from the bot itself
if let Some(ref author) = msg.author {
// Check if the bot is the author — we can't easily
// know our own user ID, so we check if the content
// appears bot-like. In practice, the caller filters.
let _ = author;
}
// Check if this is a new message
let is_new = match &last_id {
Some(last) => msg.id.as_str() > last.as_str(),
None => true,
};
if !is_new {
continue;
}
let text = msg.content.trim().to_string();
if text.is_empty() {
continue;
}
let user_id = msg
.author
.as_ref()
.map(|u| u.id.clone())
.unwrap_or_default();
let username = msg
.author
.as_ref()
.map(|u| {
u.global_name
.as_deref()
.unwrap_or(&u.username)
.to_string()
})
.unwrap_or_default();
let incoming = IncomingMessage {
platform: "discord".to_string(),
channel_id: msg.channel_id.clone(),
user_id,
username,
text,
message_id: msg.id.clone(),
};
if tx.send(incoming).await.is_err() {
warn!("Discord receiver channel closed");
return;
}
}
// Update last seen message ID
if let Some(newest) = messages.first() {
let mut map = platform.last_message_ids.lock().unwrap();
map.insert(channel_id.clone(), newest.id.clone());
}
}
Err(e) => {
debug!(
error = %e,
channel = %channel_id,
"Discord poll error (skipping channel)"
);
}
}
}
// Sleep between polling rounds
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
});
info!("Discord polling loop spawned with {} channels", 0);
Ok(rx)
}
async fn send(&self, message: OutgoingMessage) -> PlatformResult<()> {
let body = serde_json::json!({
"content": message.text,
});
let channel_id = &message.channel_id;
let text = &message.text;
let reply_to = message.reply_to.as_deref();
let url = self.api_url(&format!("/channels/{}/messages", message.channel_id));
let response = self
.client
.post(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await?;
if !response.status().is_success() {
error!("Discord send failed: {}", response.status());
}
Ok(())
self.send_message_internal(channel_id, text, reply_to).await
}
async fn send_typing(&self, channel_id: &str) -> PlatformResult<()> {
let url = self.api_url(&format!("/channels/{}/typing", channel_id));
self.client
.post(&url)
.header("Authorization", self.auth_header())
.send()
.await?;
Ok(())
self.trigger_typing(channel_id).await
}
async fn shutdown(&self) -> PlatformResult<()> {
info!("Discord platform shutting down");
info!("Shutting down Discord adapter");
self.stopped.store(true, Ordering::SeqCst);
Ok(())
}
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_platform() {
let p = DiscordPlatform::new("my_token", Some(12345));
assert_eq!(p.bot_token, "my_token");
assert_eq!(p.guild_id, Some("12345".to_string()));
}
#[test]
fn test_api_url() {
let p = DiscordPlatform::new("t", None);
let url = p.api_url("/channels/123/messages");
assert!(url.contains("discord.com"));
assert!(url.contains("/channels/123/messages"));
}
#[test]
fn test_auth_header() {
let p = DiscordPlatform::new("mytoken", None);
assert_eq!(p.auth_header(), "Bot mytoken");
}
#[test]
fn test_resolve_channel_numeric() {
let rt = tokio::runtime::Runtime::new().unwrap();
let p = DiscordPlatform::new("t", None);
let result = rt.block_on(p.resolve_channel("123456789"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "123456789");
}
}

View File

@@ -1,47 +1,62 @@
//! GyanDoot Platform — Platform adapters for Telegram, Discord, Webhook, and CLI.
//!
//! Each adapter implements the `Platform` trait, allowing the agent to
//! Each adapter implements the [`Platform`] trait, allowing the agent to
//! communicate over different messaging channels transparently.
use async_trait::async_trait;
use gyandoot_core::{Message, PlatformResult};
use gyandoot_core::PlatformResult;
pub mod telegram;
pub mod discord;
pub mod webhook;
pub mod cli_ux;
// Re-export key types for convenience.
pub use cli_ux::*;
/// A message received from a platform.
#[derive(Debug, Clone)]
pub struct IncomingMessage {
/// Platform identifier (e.g. "telegram", "discord", "cli").
pub platform: String,
/// Channel, chat, or conversation identifier.
pub channel_id: String,
/// User identifier on the platform.
pub user_id: String,
/// Display name of the sender.
pub username: String,
/// Message text content.
pub text: String,
/// Platform-specific message identifier.
pub message_id: String,
}
/// A message to send to a platform.
#[derive(Debug, Clone)]
pub struct OutgoingMessage {
/// Target channel / conversation identifier.
pub channel_id: String,
/// Text content to send.
pub text: String,
/// Optional message ID to reply to.
pub reply_to: Option<String>,
}
/// Trait that all platform adapters must implement.
#[async_trait]
pub trait Platform: Send + Sync {
/// The platform identifier (e.g., "telegram", "discord", "cli").
/// Human-readable platform identifier (e.g. `"telegram"`, `"discord"`, `"cli"`).
fn name(&self) -> &str;
/// Start listening for incoming messages. Returns a channel receiver.
/// Start listening for incoming messages.
///
/// Returns a channel receiver that yields [`IncomingMessage`] values as
/// they arrive from the platform.
async fn start(
&self,
) -> PlatformResult<tokio::sync::mpsc::Receiver<IncomingMessage>>;
/// Send a message to a channel.
/// Send a text message to a channel.
async fn send(&self, message: OutgoingMessage) -> PlatformResult<()>;
/// Send a typing indicator or equivalent "thinking" state.

View File

@@ -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(&params)
.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(&params)
.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(&params)
.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");
}
}

View File

@@ -1,23 +1,302 @@
//! Webhook platform adapter.
//! Webhook receiver adapter — simple HTTP callback-based platform.
//!
//! The [`WebhookPlatform`] listens for incoming HTTP POST requests on a
//! configurable address, processes incoming JSON payloads, and sends
//! responses back to a configured callback URL.
use async_trait::async_trait;
use gyandoot_core::PlatformResult;
use tracing::{info, warn};
use std::net::SocketAddr;
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 receiving messages via HTTP webhook.
// ─── WebhookPlatform ──────────────────────────────────────────────────────
/// Configuration for the webhook receiver.
#[derive(Debug, Clone)]
pub struct WebhookConfig {
/// Host address to bind the HTTP listener on.
pub host: String,
/// TCP port to listen on.
pub port: u16,
/// Optional shared secret for HMAC-based payload verification.
pub secret: Option<String>,
/// Optional callback URL to forward outgoing messages to.
pub callback_url: Option<String>,
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 8080,
secret: None,
callback_url: None,
}
}
}
/// Adapter for receiving messages via a webhook endpoint.
///
/// Starts a lightweight HTTP server that accepts POST requests at `/webhook`.
/// Incoming JSON payloads are deserialised as [`WebhookPayload`] and
/// forwarded through the platform channel.
///
/// # Example
///
/// ```no_run
/// use gyandoot_platform::webhook::{WebhookPlatform, WebhookConfig};
///
/// let config = WebhookConfig {
/// port: 9090,
/// ..Default::default()
/// };
/// let platform = WebhookPlatform::new(config);
/// ```
pub struct WebhookPlatform {
host: String,
port: u16,
secret: Option<String>,
/// Webhook configuration.
config: WebhookConfig,
/// Shared flag to signal shutdown.
stopped: Arc<std::sync::atomic::AtomicBool>,
}
/// JSON payload format accepted by the webhook endpoint.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct WebhookPayload {
/// Channel / conversation identifier.
pub channel_id: String,
/// User identifier.
pub user_id: String,
/// Display name of the sender.
pub username: String,
/// Message text content.
pub text: String,
/// Optional message ID (assigned server-side if absent).
pub message_id: Option<String>,
}
impl WebhookPlatform {
/// Create a new webhook platform adapter.
pub fn new(host: String, port: u16, secret: Option<String>) -> Self {
Self { host, port, secret }
pub fn new(config: WebhookConfig) -> Self {
Self {
config,
stopped: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
/// Return a reference to the webhook configuration.
pub fn config(&self) -> &WebhookConfig {
&self.config
}
/// Send an outgoing message to the configured callback URL.
async fn send_callback(&self, message: &OutgoingMessage) -> PlatformResult<()> {
let callback_url = match &self.config.callback_url {
Some(url) => url,
None => {
// No callback URL configured — silently drop.
warn!("No callback_url configured for webhook outgoing message");
return Ok(());
}
};
let payload = serde_json::json!({
"channel_id": message.channel_id,
"text": message.text,
"reply_to": message.reply_to,
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| {
error!(error = %e, "Failed to build HTTP client for callback");
PlatformError::Network(e)
})?;
let mut req = client
.post(callback_url)
.json(&payload)
.header("Content-Type", "application/json");
// Add HMAC signature header if secret is configured
if let Some(secret) = &self.config.secret {
let body_str = serde_json::to_string(&payload).unwrap_or_default();
let signature = compute_hmac_sha256(secret.as_bytes(), body_str.as_bytes());
req = req.header("X-Webhook-Signature", signature);
}
let resp = req.send().await.map_err(|e| {
error!(error = %e, callback_url = %callback_url, "Webhook callback 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,
"Webhook callback returned non-success"
);
return Err(PlatformError::Send(format!("HTTP {status}: {body}")));
}
debug!(callback_url = %callback_url, "Webhook callback delivered");
Ok(())
}
/// Verify the HMAC signature of an incoming request.
/// Returns true if the signature is valid or no secret is configured.
#[allow(dead_code)]
fn verify_signature(&self, body: &[u8], signature_header: Option<&str>) -> bool {
let secret = match &self.config.secret {
Some(s) => s,
None => return true, // No secret configured — skip verification
};
let provided_sig = match signature_header {
Some(s) => s,
None => {
warn!("Missing X-Webhook-Signature header but secret is configured");
return false;
}
};
let computed = compute_hmac_sha256(secret.as_bytes(), body);
computed == provided_sig
}
}
/// Compute an HMAC-SHA256 hex string.
fn compute_hmac_sha256(key: &[u8], data: &[u8]) -> String {
use hmac::Hmac;
use sha2::Sha256;
use hmac::Mac;
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC key length valid");
mac.update(data);
let result = mac.finalize();
let code = result.into_bytes();
hex::encode(code)
}
/// The actual HTTP handler for incoming webhook requests.
async fn handle_webhook(
req: hyper::Request<hyper::body::Incoming>,
tx: mpsc::Sender<IncomingMessage>,
stopped: Arc<std::sync::atomic::AtomicBool>,
config: WebhookConfig,
) -> Result<hyper::Response<String>, std::convert::Infallible> {
if stopped.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(
hyper::Response::builder()
.status(503)
.body("Shutting down".to_string())
.unwrap(),
);
}
let (parts, body) = req.into_parts();
// Read the entire body
let body_bytes = match http_body_util::BodyExt::collect(body).await {
Ok(collected) => collected.to_bytes().to_vec(),
Err(_) => {
return Ok(
hyper::Response::builder()
.status(400)
.body("Failed to read body".to_string())
.unwrap(),
);
}
};
// Verify HMAC signature if configured
if let Some(ref secret_str) = config.secret {
let sig_header = parts
.headers
.get("X-Webhook-Signature")
.and_then(|v| v.to_str().ok());
let provided_sig = match sig_header {
Some(s) => s,
None => {
warn!("Missing X-Webhook-Signature header");
return Ok(
hyper::Response::builder()
.status(401)
.body("Missing signature".to_string())
.unwrap(),
);
}
};
let computed = compute_hmac_sha256(secret_str.as_bytes(), &body_bytes);
if provided_sig != computed {
warn!("Invalid webhook signature");
return Ok(
hyper::Response::builder()
.status(401)
.body("Invalid signature".to_string())
.unwrap(),
);
}
}
// Parse the JSON payload
let payload: WebhookPayload = match serde_json::from_slice(&body_bytes) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Failed to parse webhook payload");
return Ok(
hyper::Response::builder()
.status(400)
.body(format!("Invalid JSON: {e}"))
.unwrap(),
);
}
};
let message_id = payload
.message_id
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Skip empty messages
if payload.text.trim().is_empty() {
return Ok(
hyper::Response::builder()
.status(200)
.body("OK (empty)".to_string())
.unwrap(),
);
}
let incoming = IncomingMessage {
platform: "webhook".to_string(),
channel_id: payload.channel_id,
user_id: payload.user_id,
username: payload.username,
text: payload.text,
message_id,
};
if tx.send(incoming).await.is_err() {
warn!("Webhook receiver channel closed");
return Ok(
hyper::Response::builder()
.status(503)
.body("Server shutting down".to_string())
.unwrap(),
);
}
Ok(hyper::Response::builder()
.status(200)
.body("OK".to_string())
.unwrap())
}
#[async_trait]
@@ -26,31 +305,141 @@ impl Platform for WebhookPlatform {
"webhook"
}
async fn start(&self) -> PlatformResult<tokio::sync::mpsc::Receiver<IncomingMessage>> {
async fn start(&self) -> PlatformResult<mpsc::Receiver<IncomingMessage>> {
info!(
"Starting webhook server on {}:{}",
self.host, self.port
host = %self.config.host,
port = %self.config.port,
"Starting webhook receiver"
);
self.stopped
.store(false, std::sync::atomic::Ordering::SeqCst);
let (tx, rx) = mpsc::channel::<IncomingMessage>(256);
let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port)
.parse()
.map_err(|e| {
error!(error = %e, host = %self.config.host, port = %self.config.port, "Invalid socket address");
PlatformError::Connection(format!("Invalid address: {e}"))
})?;
let tx_clone = tx.clone();
let stopped = Arc::clone(&self.stopped);
let config = self.config.clone();
// Spawn the HTTP server using hyper-util
tokio::spawn(async move {
let svc = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let tx = tx_clone.clone();
let stopped = stopped.clone();
let config = config.clone();
handle_webhook(req, tx, stopped, config)
});
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Failed to bind webhook TCP listener");
return;
}
};
info!("Webhook server listening on {addr}");
loop {
match listener.accept().await {
Ok((stream, _)) => {
let svc = svc.clone();
tokio::spawn(async move {
if let Err(e) = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(hyper_util::rt::TokioIo::new(stream), svc)
.await
{
debug!(error = %e, "Webhook connection error");
}
});
}
Err(e) => {
error!(error = %e, "Webhook accept error");
break;
}
}
}
});
info!(
"Webhook receiver started on {}:{}",
self.config.host, self.config.port
);
// Webhook server would use tokio::net::TcpListener or axum/warp.
// For this scaffold, return an empty receiver.
let (_tx, rx) = tokio::sync::mpsc::channel(100);
warn!("Webhook server not yet implemented — requires axum or warp");
Ok(rx)
}
async fn send(&self, _message: OutgoingMessage) -> PlatformResult<()> {
// Webhook outbound sends via HTTP POST to a configured callback URL.
info!("Webhook outbound send (not yet implemented)");
Ok(())
async fn send(&self, message: OutgoingMessage) -> PlatformResult<()> {
self.send_callback(&message).await
}
async fn send_typing(&self, _channel_id: &str) -> PlatformResult<()> {
// No typing indicator for webhooks
// Webhook doesn't have a typing indicator — this is a no-op.
Ok(())
}
async fn shutdown(&self) -> PlatformResult<()> {
info!("Webhook platform shutting down");
info!("Shutting down webhook adapter");
self.stopped
.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_webhook_config_default() {
let cfg = WebhookConfig::default();
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 8080);
assert!(cfg.secret.is_none());
}
#[test]
fn test_new_platform() {
let cfg = WebhookConfig {
port: 9090,
..Default::default()
};
let p = WebhookPlatform::new(cfg);
assert_eq!(p.name(), "webhook");
assert_eq!(p.config.port, 9090);
}
#[test]
fn test_hmac_signature() {
let sig = compute_hmac_sha256(b"secret", b"hello world");
assert!(!sig.is_empty());
assert_eq!(sig.len(), 64); // SHA-256 hex is 64 chars
}
#[test]
fn test_verify_signature_no_secret() {
let cfg = WebhookConfig::default();
assert!(cfg.secret.is_none());
let p = WebhookPlatform::new(cfg);
assert!(p.verify_signature(b"test", None));
}
#[test]
fn test_verify_signature_with_secret() {
let cfg = WebhookConfig {
secret: Some("mysecret".to_string()),
..Default::default()
};
let p = WebhookPlatform::new(cfg);
let sig = compute_hmac_sha256(b"mysecret", b"test body");
assert!(p.verify_signature(b"test body", Some(&sig)));
assert!(!p.verify_signature(b"test body", Some("bad_sig")));
}
}