From a08f1909cae4118ff78bb80e44d1075267cab1bb Mon Sep 17 00:00:00 2001 From: shiva Date: Sat, 11 Jul 2026 19:56:47 +0530 Subject: [PATCH 1/2] feat(channels): outbound send to Telegram/Slack/Discord (CLI + MCP tool) (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outward half of channels: a one-shot blocking POST that fits agentflare's sync/ureq model, so an agent can push a message mid-run and the inbound daemon can reuse the same path for replies. - channels.rs: Platform{Telegram,Slack,Discord}; pure build_request() per platform (Telegram token-in-URL {chat_id,text}; Slack Bearer {channel,text}; Discord 'Bot' auth {content}); pure interpret_response() that checks Slack's ok field even on HTTP 200; send()/send_message() resolve the bot token from the encrypted gateway_secrets store and POST via ureq. - cli: 'agentflare channel send --to --target '. - mcp: channel_send tool so an agent can send during a turn. Tokens are never hardcoded — read from gateway secret '_bot_token'. Plain-text only for now (no attachments/threads/parse_mode). --- src/channels.rs | 215 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/channel.rs | 51 +++++++++++ src/cli/mod.rs | 3 + src/main.rs | 1 + src/mcp_server.rs | 28 ++++++ 5 files changed, 298 insertions(+) create mode 100644 src/channels.rs create mode 100644 src/cli/channel.rs diff --git a/src/channels.rs b/src/channels.rs new file mode 100644 index 00000000..b3f27ecd --- /dev/null +++ b/src/channels.rs @@ -0,0 +1,215 @@ +//! Outbound channels: send a plain-text message out to a chat platform +//! (Telegram / Slack / Discord). This is the "outward" half of the channels +//! effort — a one-shot blocking POST that fits agentflare's sync/`ureq` model, +//! callable by an agent mid-run (MCP tool) or from the CLI. Bot tokens live in +//! the encrypted `gateway_secrets` store; the inbound daemon (flared) reuses +//! this same path to send its replies. +//! +//! Request shapes per platform: +//! - Telegram: `POST {base}/bot{token}/sendMessage` body `{chat_id, text}` (token in URL) +//! - Slack: `POST slack.com/api/chat.postMessage` body `{channel, text}` (Authorization: Bearer) +//! - Discord: `POST discord.com/api/v10/channels/{id}/messages` body `{content}` (Authorization: Bot) + +use serde_json::{json, Value}; + +/// A supported outbound chat platform. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Telegram, + Slack, + Discord, +} + +impl Platform { + /// Parse a `--to` value (case-insensitive). `None` for unknown platforms. + #[must_use] + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "telegram" => Some(Self::Telegram), + "slack" => Some(Self::Slack), + "discord" => Some(Self::Discord), + _ => None, + } + } + + /// The `gateway_secrets` entry holding this platform's bot token. + #[must_use] + pub fn secret_name(self) -> &'static str { + match self { + Self::Telegram => "telegram_bot_token", + Self::Slack => "slack_bot_token", + Self::Discord => "discord_bot_token", + } + } +} + +/// A ready-to-send outbound HTTP request: where to POST, an optional +/// `Authorization` header value, and the JSON body. +pub struct OutboundRequest { + pub url: String, + pub auth: Option, + pub body: Value, +} + +/// Build the platform-specific send request for a plain-text message. +#[must_use] +pub fn build_request(platform: Platform, target: &str, text: &str, token: &str) -> OutboundRequest { + match platform { + // Token goes in the URL path; no auth header. + Platform::Telegram => OutboundRequest { + url: format!("https://api.telegram.org/bot{token}/sendMessage"), + auth: None, + body: json!({ "chat_id": target, "text": text }), + }, + Platform::Slack => OutboundRequest { + url: "https://slack.com/api/chat.postMessage".to_string(), + auth: Some(format!("Bearer {token}")), + body: json!({ "channel": target, "text": text }), + }, + // Discord uses the literal `Bot ` auth prefix (not `Bearer`). + Platform::Discord => OutboundRequest { + url: format!("https://discord.com/api/v10/channels/{target}/messages"), + auth: Some(format!("Bot {token}")), + body: json!({ "content": text }), + }, + } +} + +/// Decide whether a send succeeded from the HTTP status and response body. +/// Telegram/Discord are status-only; Slack returns HTTP 200 with `{"ok":false}` +/// on failure, so its body must be inspected. +pub fn interpret_response(platform: Platform, status: u16, body: &str) -> Result<(), String> { + let ok_status = (200..300).contains(&status); + match platform { + Platform::Slack => { + if !ok_status { + return Err(format!("slack HTTP {status}: {body}")); + } + let parsed: Value = serde_json::from_str(body) + .map_err(|e| format!("slack response was not JSON: {e}"))?; + if parsed.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(()) + } else { + let reason = parsed + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown error"); + Err(format!("slack rejected the message: {reason}")) + } + } + Platform::Telegram | Platform::Discord => { + if ok_status { + Ok(()) + } else { + Err(format!("HTTP {status}: {body}")) + } + } + } +} + +/// Execute a built request over blocking HTTP and interpret the outcome. +fn send(platform: Platform, req: &OutboundRequest) -> Result<(), String> { + let mut r = ureq::post(&req.url); + if let Some(auth) = &req.auth { + r = r.set("Authorization", auth); + } + // ureq returns non-2xx as `Err(Status(..))`; capture status+body from both + // arms so `interpret_response` (e.g. Slack's `ok` field) sees the payload. + let (status, body) = match r.send_json(&req.body) { + Ok(resp) => (resp.status(), resp.into_string().unwrap_or_default()), + Err(ureq::Error::Status(code, resp)) => (code, resp.into_string().unwrap_or_default()), + Err(e) => return Err(format!("request to {} failed: {e}", req.url)), + }; + interpret_response(platform, status, &body) +} + +/// Resolve the platform's bot token from the encrypted `gateway_secrets` store +/// and send `text` to `target`. The one entry point CLI and MCP both call. +pub fn send_message( + conn: &rusqlite::Connection, + platform: Platform, + target: &str, + text: &str, +) -> Result<(), String> { + let name = platform.secret_name(); + let token = crate::gateway_secrets::get_secret(conn, name) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!("no {name} configured — store the bot token as the gateway secret '{name}' first") + })?; + let req = build_request(platform, target, text, &token); + send(platform, &req) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_platform_is_case_insensitive_and_rejects_unknown() { + assert_eq!(Platform::parse("telegram"), Some(Platform::Telegram)); + assert_eq!(Platform::parse("Slack"), Some(Platform::Slack)); + assert_eq!(Platform::parse("DISCORD"), Some(Platform::Discord)); + assert_eq!(Platform::parse("myspace"), None); + } + + #[test] + fn secret_name_per_platform() { + assert_eq!(Platform::Telegram.secret_name(), "telegram_bot_token"); + assert_eq!(Platform::Slack.secret_name(), "slack_bot_token"); + assert_eq!(Platform::Discord.secret_name(), "discord_bot_token"); + } + + #[test] + fn telegram_request_puts_token_in_url_and_no_auth_header() { + let r = build_request(Platform::Telegram, "12345", "hi", "TOK"); + assert_eq!(r.url, "https://api.telegram.org/botTOK/sendMessage"); + assert!(r.auth.is_none()); + assert_eq!(r.body["chat_id"], "12345"); + assert_eq!(r.body["text"], "hi"); + } + + #[test] + fn slack_request_uses_bearer_auth() { + let r = build_request(Platform::Slack, "C123", "hi", "xoxb-TOK"); + assert_eq!(r.url, "https://slack.com/api/chat.postMessage"); + assert_eq!(r.auth.as_deref(), Some("Bearer xoxb-TOK")); + assert_eq!(r.body["channel"], "C123"); + assert_eq!(r.body["text"], "hi"); + } + + #[test] + fn discord_request_uses_bot_auth_and_channel_in_url() { + let r = build_request(Platform::Discord, "999", "hi", "TOK"); + assert_eq!(r.url, "https://discord.com/api/v10/channels/999/messages"); + assert_eq!(r.auth.as_deref(), Some("Bot TOK")); + assert_eq!(r.body["content"], "hi"); + } + + #[test] + fn interpret_telegram_and_discord_are_status_only() { + assert!(interpret_response(Platform::Telegram, 200, "").is_ok()); + assert!(interpret_response(Platform::Discord, 200, "{}").is_ok()); + assert!(interpret_response(Platform::Discord, 500, "boom").is_err()); + assert!(interpret_response(Platform::Telegram, 403, "forbidden").is_err()); + } + + #[test] + fn interpret_slack_checks_the_ok_field_even_on_http_200() { + assert!(interpret_response(Platform::Slack, 200, r#"{"ok":true,"ts":"1"}"#).is_ok()); + let err = interpret_response(Platform::Slack, 200, r#"{"ok":false,"error":"channel_not_found"}"#) + .unwrap_err(); + assert!(err.contains("channel_not_found"), "error should surface Slack's reason: {err}"); + } + + #[test] + fn send_message_without_a_configured_token_errors_clearly() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::gateway_secrets::migrate(&conn).unwrap(); + let err = send_message(&conn, Platform::Telegram, "123", "hi").unwrap_err(); + assert!( + err.contains("telegram_bot_token"), + "should name the missing secret: {err}" + ); + } +} diff --git a/src/cli/channel.rs b/src/cli/channel.rs new file mode 100644 index 00000000..aa9b02ad --- /dev/null +++ b/src/cli/channel.rs @@ -0,0 +1,51 @@ +use clap::{Args, Subcommand}; + +/// Send messages out to chat platforms (Telegram / Slack / Discord). Bot tokens +/// are read from the encrypted gateway secret store. +#[derive(Args)] +pub struct ChannelArgs { + #[command(subcommand)] + pub action: ChannelAction, +} + +#[derive(Subcommand)] +pub enum ChannelAction { + /// Send a text message to a chat platform. + Send { + /// Platform: telegram | slack | discord. + #[arg(long)] + to: String, + /// Recipient id (Telegram chat_id, Slack/Discord channel id). + #[arg(long)] + target: String, + /// The message text. + message: String, + }, +} + +impl ChannelArgs { + pub fn run(self) { + match self.action { + ChannelAction::Send { to, target, message } => { + let Some(platform) = crate::channels::Platform::parse(&to) else { + eprintln!("error: unknown platform '{to}' (expected telegram, slack, or discord)"); + std::process::exit(1); + }; + let conn = match crate::db::open() { + Ok(c) => c, + Err(e) => { + eprintln!("channel: cannot open database: {e}"); + std::process::exit(1); + } + }; + match crate::channels::send_message(&conn, platform, &target, &message) { + Ok(()) => println!("sent to {to}:{target}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3e6a1081..9ecfaef1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ mod alias; mod artifacts; mod auth; mod caveman; +mod channel; mod claim; mod coaching; mod cost; @@ -54,6 +55,7 @@ pub enum Commands { Handoff(handoff::HandoffArgs), Ponytail(ponytail::PonytailArgs), Caveman(caveman::CavemanArgs), + Channel(channel::ChannelArgs), Claim(claim::ClaimArgs), Review(review::ReviewArgs), } @@ -77,6 +79,7 @@ impl Commands { Self::Handoff(cmd) => cmd.run(), Self::Ponytail(cmd) => cmd.run(), Self::Caveman(cmd) => cmd.run(), + Self::Channel(cmd) => cmd.run(), Self::Claim(cmd) => cmd.run(), Self::Review(cmd) => cmd.run(), } diff --git a/src/main.rs b/src/main.rs index 94321b9a..2f5a95b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod auth_crypt; mod auth_db; mod auth_runner; mod build_time; +mod channels; mod claims; mod gateway_integrations; mod gateway_secrets; diff --git a/src/mcp_server.rs b/src/mcp_server.rs index d612b63c..f797a455 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -102,6 +102,16 @@ struct ClaimListRequest { all_repos: bool, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ChannelSendRequest { + #[schemars(description = "Platform to send to: telegram, slack, or discord")] + platform: String, + #[schemars(description = "Recipient id: Telegram chat_id, or Slack/Discord channel id")] + target: String, + #[schemars(description = "The message text to send")] + message: String, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ReviewSubmitRequest { #[schemars(description = "Findings, each {file, line, message, severity?, category?}")] @@ -728,6 +738,24 @@ impl AgentflareMcp { Ok(serde_json::json!({ "deleted": deleted }).to_string()) } + #[tool(description = "Send a text message out to a chat platform (telegram, slack, or discord). The bot token must already be stored as the gateway secret '_bot_token'. target is the Telegram chat_id or Slack/Discord channel id.")] + fn channel_send( + &self, + Parameters(ChannelSendRequest { platform, target, message }): Parameters, + ) -> Result { + let plat = crate::channels::Platform::parse(&platform).ok_or_else(|| { + ErrorData::invalid_params( + format!("unknown platform '{platform}' (expected telegram, slack, or discord)"), + None, + ) + })?; + let conn = crate::db::open() + .map_err(|e| ErrorData::internal_error(format!("cannot open database: {e}"), None))?; + crate::channels::send_message(&conn, plat, &target, &message) + .map_err(|e| ErrorData::internal_error(e, None))?; + Ok(serde_json::json!({ "sent": true, "platform": platform, "target": target }).to_string()) + } + #[tool(description = "Claim a GitHub issue/PR so other agents don't duplicate the work. Returns 'acquired' if you now own it, or 'held' with the current owner if a live claim exists. Only stale (past-TTL) or done claims are stolen. Re-heartbeat periodically to keep it.")] fn claim_acquire( &self, From e69df0da8f0277ce5d2d402090e1a71f2ae70650 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 11 Jul 2026 23:38:53 +0530 Subject: [PATCH 2/2] fix(channels): stop leaking the bot token via the request URL in transport-error messages, and cap the outbound HTTP call with a timeout so a stalled platform can't hang the caller. --- src/channels.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index b3f27ecd..69ee24bd 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -107,9 +107,51 @@ pub fn interpret_response(platform: Platform, status: u16, body: &str) -> Result } } +/// A shared `ureq` agent with explicit connect/read timeouts so a stalled or +/// silent platform can't hang the caller indefinitely. `ureq` 2.x defaults to +/// a 30s connect timeout but leaves the read/write timeout unset, so we build +/// our own agent instead of using the bare `ureq::post` free function. +fn http_agent() -> &'static ureq::Agent { + static AGENT: std::sync::OnceLock = std::sync::OnceLock::new(); + AGENT.get_or_init(|| { + ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + }) +} + +/// Describe a transport-level send failure (DNS, connection refused, TLS, +/// timeout — anything short of getting an HTTP status back). +/// +/// This must NOT use `ureq::Error`'s own `Display` impl (`{err}`) or +/// `Transport::url()`: `ureq`'s `Display` for both `Error` and `Transport` +/// unconditionally prepends the request URL (see `ureq`'s `error.rs`), and +/// for Telegram that URL embeds the live bot token +/// (`https://api.telegram.org/bot{token}/sendMessage`). This error string can +/// end up in CLI stderr or MCP client logs, so it's built instead from the +/// safe, URL-free pieces `ureq` exposes: the error's `kind()` classification, +/// its optional higher-level `message()`, and the underlying `source()` (a +/// plain `std::io::Error`/TLS error with no knowledge of the request URL). +/// `platform`'s `Debug` output (e.g. "Telegram") stands in for the URL. +fn describe_send_error(platform: Platform, err: &ureq::Error) -> String { + let mut msg = format!("request to {platform:?} failed: {}", err.kind()); + if let ureq::Error::Transport(transport) = err { + if let Some(detail) = transport.message() { + msg.push_str(": "); + msg.push_str(detail); + } + if let Some(source) = std::error::Error::source(transport) { + msg.push_str(": "); + msg.push_str(&source.to_string()); + } + } + msg +} + /// Execute a built request over blocking HTTP and interpret the outcome. fn send(platform: Platform, req: &OutboundRequest) -> Result<(), String> { - let mut r = ureq::post(&req.url); + let mut r = http_agent().post(&req.url); if let Some(auth) = &req.auth { r = r.set("Authorization", auth); } @@ -118,7 +160,7 @@ fn send(platform: Platform, req: &OutboundRequest) -> Result<(), String> { let (status, body) = match r.send_json(&req.body) { Ok(resp) => (resp.status(), resp.into_string().unwrap_or_default()), Err(ureq::Error::Status(code, resp)) => (code, resp.into_string().unwrap_or_default()), - Err(e) => return Err(format!("request to {} failed: {e}", req.url)), + Err(e) => return Err(describe_send_error(platform, &e)), }; interpret_response(platform, status, &body) } @@ -202,6 +244,35 @@ mod tests { assert!(err.contains("channel_not_found"), "error should surface Slack's reason: {err}"); } + #[test] + fn describe_send_error_never_leaks_the_url_or_token() { + // Force a real transport-level failure (connection refused — nothing + // listens on 127.0.0.1:1) against a URL that embeds a fake bot token, + // the same shape Telegram's real URL takes. This is not a flaky + // network test: the connection is refused locally and immediately, + // with a short timeout as a backstop. + let token = "SUPER-SECRET-TELEGRAM-TOKEN"; + let url = format!("http://127.0.0.1:1/bot{token}/sendMessage"); + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_millis(500)) + .build(); + let err = agent + .post(&url) + .send_json(json!({})) + .expect_err("connecting to a closed local port must fail"); + // Sanity: confirm this really is the transport-error arm (not an HTTP + // status), i.e. the same arm `send()` routes through `describe_send_error`. + assert!( + matches!(err, ureq::Error::Transport(_)), + "expected a transport-level error, got: {err}" + ); + + let msg = describe_send_error(Platform::Telegram, &err); + assert!(!msg.contains(token), "error message must not leak the bot token: {msg}"); + assert!(!msg.contains(&url), "error message must not leak the request URL: {msg}"); + assert!(msg.contains("Telegram"), "error message should name the platform: {msg}"); + } + #[test] fn send_message_without_a_configured_token_errors_clearly() { let conn = rusqlite::Connection::open_in_memory().unwrap();