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,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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user