Gateway bot: proxy commands to active project instead of reading local state
In gateway mode the bot has no local CRDT or project filesystem, so all bot commands (status, backlog, start, assign, etc.) returned empty or broken results. Now the gateway bot proxies non-local commands via HTTP to the active project's /api/bot/command endpoint, which already exists on every project server. Only a small set of gateway-local commands (help, ambient, reset, switch) are still handled directly by the gateway. Everything else is forwarded automatically, so new commands added in the future will work through the proxy without additional gateway changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ use crate::chat::ChatTransport;
|
||||
use crate::chat::timer::TimerStore;
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
@@ -65,6 +65,10 @@ pub struct BotContext {
|
||||
/// In gateway mode: valid project names accepted by the `switch` command.
|
||||
/// Empty in standalone mode.
|
||||
pub gateway_projects: Vec<String>,
|
||||
/// In gateway mode: mapping of project name → base URL (e.g. `"http://localhost:3001"`).
|
||||
/// Used to proxy bot commands to the active project's `/api/bot/command` endpoint.
|
||||
/// Empty in standalone mode.
|
||||
pub gateway_project_urls: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl BotContext {
|
||||
@@ -82,6 +86,46 @@ impl BotContext {
|
||||
self.project_root.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the bot is running in gateway mode.
|
||||
pub fn is_gateway(&self) -> bool {
|
||||
self.gateway_active_project.is_some()
|
||||
}
|
||||
|
||||
/// Return the base URL for the currently active project, if in gateway mode.
|
||||
pub async fn active_project_url(&self) -> Option<String> {
|
||||
let ap = self.gateway_active_project.as_ref()?;
|
||||
let name = ap.read().await.clone();
|
||||
self.gateway_project_urls.get(&name).cloned()
|
||||
}
|
||||
|
||||
/// Proxy a bot command to the active project's `/api/bot/command` endpoint.
|
||||
///
|
||||
/// Returns the Markdown response from the project server, or an error
|
||||
/// message if the request failed.
|
||||
pub async fn proxy_bot_command(&self, command: &str, args: &str) -> Option<String> {
|
||||
let base_url = self.active_project_url().await?;
|
||||
let url = format!("{base_url}/api/bot/command");
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"command": command,
|
||||
"args": args,
|
||||
});
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.json::<serde_json::Value>().await {
|
||||
Ok(json) => json.get("response").and_then(|v| v.as_str()).map(String::from),
|
||||
Err(e) => Some(format!("Failed to parse response from project server: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(resp) => Some(format!(
|
||||
"Project server returned HTTP {}: {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
)),
|
||||
Err(e) => Some(format!("Failed to reach project server at {url}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,6 +179,7 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: None,
|
||||
gateway_projects: vec![],
|
||||
gateway_project_urls: BTreeMap::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
ctx.effective_project_root().await,
|
||||
@@ -172,6 +217,10 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: Some(Arc::clone(&active)),
|
||||
gateway_projects: vec!["huskies".into(), "robot-studio".into()],
|
||||
gateway_project_urls: BTreeMap::from([
|
||||
("huskies".into(), "http://localhost:3001".into()),
|
||||
("robot-studio".into(), "http://localhost:3002".into()),
|
||||
]),
|
||||
};
|
||||
assert_eq!(
|
||||
ctx.effective_project_root().await,
|
||||
@@ -209,6 +258,10 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: Some(Arc::clone(&active)),
|
||||
gateway_projects: vec!["huskies".into(), "robot-studio".into()],
|
||||
gateway_project_urls: BTreeMap::from([
|
||||
("huskies".into(), "http://localhost:3001".into()),
|
||||
("robot-studio".into(), "http://localhost:3002".into()),
|
||||
]),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -255,6 +308,7 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: None,
|
||||
gateway_projects: vec![],
|
||||
gateway_project_urls: BTreeMap::new(),
|
||||
};
|
||||
// Clone must work (required by Matrix SDK event handler injection).
|
||||
let _cloned = ctx.clone();
|
||||
|
||||
@@ -179,6 +179,57 @@ pub(super) async fn on_room_message(
|
||||
// a subdirectory named after the project. Standalone mode is unaffected.
|
||||
let effective_root = ctx.effective_project_root().await;
|
||||
|
||||
// ── Gateway command proxy ───────────────────────────────────────────
|
||||
// In gateway mode the bot has no local CRDT or project filesystem, so most
|
||||
// commands must be forwarded to the active project's `/api/bot/command`
|
||||
// endpoint. Only a small set of gateway-local commands are handled here.
|
||||
if ctx.is_gateway() {
|
||||
// Commands that are meaningful on the gateway itself (no project state needed).
|
||||
const GATEWAY_LOCAL_COMMANDS: &[&str] = &["help", "ambient", "reset", "switch"];
|
||||
|
||||
let stripped = crate::chat::util::strip_bot_mention(
|
||||
&user_message,
|
||||
&ctx.bot_name,
|
||||
ctx.bot_user_id.as_str(),
|
||||
)
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric())
|
||||
.to_string();
|
||||
|
||||
let (cmd, args) = match stripped.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c.to_ascii_lowercase(), a.trim().to_string()),
|
||||
None => (stripped.to_ascii_lowercase(), String::new()),
|
||||
};
|
||||
|
||||
if !cmd.is_empty() && !GATEWAY_LOCAL_COMMANDS.contains(&cmd.as_str()) {
|
||||
// Proxy to the active project server.
|
||||
let response = match ctx.proxy_bot_command(&cmd, &args).await {
|
||||
Some(r) => r,
|
||||
None => "No active project selected or project URL not configured.".to_string(),
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
// If the command was recognized by the project server, we're done.
|
||||
// If it was not a command at all (freeform text), fall through to the LLM.
|
||||
if crate::chat::commands::commands()
|
||||
.iter()
|
||||
.any(|c| c.name == cmd)
|
||||
|| ["assign", "start", "delete", "rebuild", "rmtree", "htop", "timer"]
|
||||
.contains(&cmd.as_str())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Gateway-local commands and freeform text fall through to normal handling below.
|
||||
}
|
||||
|
||||
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||
// needed here.
|
||||
|
||||
@@ -30,6 +30,7 @@ pub async fn run_bot(
|
||||
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let store_path = project_root.join(".huskies").join("matrix_store");
|
||||
let client = Client::builder()
|
||||
@@ -247,6 +248,7 @@ pub async fn run_bot(
|
||||
timer_store,
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
};
|
||||
|
||||
slog!(
|
||||
|
||||
@@ -62,6 +62,7 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
|
||||
/// Returns an [`tokio::task::AbortHandle`] if the bot was actually spawned (Matrix/Discord
|
||||
/// transports), or `None` if the config is absent, disabled, or uses a webhook-based
|
||||
/// transport (Slack/WhatsApp) that does not require a persistent background task.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_bot(
|
||||
project_root: &Path,
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
@@ -70,6 +71,7 @@ pub fn spawn_bot(
|
||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||
) -> Option<tokio::task::AbortHandle> {
|
||||
let config = match BotConfig::load(project_root) {
|
||||
Some(c) => c,
|
||||
@@ -108,6 +110,7 @@ pub fn spawn_bot(
|
||||
shutdown_rx,
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1410,10 +1410,18 @@ pub async fn gateway_bot_config_save_handler(
|
||||
h.abort();
|
||||
}
|
||||
let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
|
||||
let gateway_project_urls: std::collections::BTreeMap<String, String> = state
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(name, entry)| (name.clone(), entry.url.clone()))
|
||||
.collect();
|
||||
let new_handle = spawn_gateway_bot(
|
||||
&state.config_dir,
|
||||
Arc::clone(&state.active_project),
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
state.port,
|
||||
);
|
||||
*handle = new_handle;
|
||||
@@ -1738,10 +1746,18 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
|
||||
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
||||
let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
|
||||
let gateway_project_urls: std::collections::BTreeMap<String, String> = state_arc
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(name, entry)| (name.clone(), entry.url.clone()))
|
||||
.collect();
|
||||
let bot_abort = spawn_gateway_bot(
|
||||
&config_dir,
|
||||
Arc::clone(&state_arc.active_project),
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
port,
|
||||
);
|
||||
*state_arc.bot_handle.lock().await = bot_abort;
|
||||
@@ -1791,6 +1807,7 @@ fn spawn_gateway_bot(
|
||||
config_dir: &Path,
|
||||
active_project: ActiveProject,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||
port: u16,
|
||||
) -> Option<tokio::task::AbortHandle> {
|
||||
use crate::agents::AgentPool;
|
||||
@@ -1822,6 +1839,7 @@ fn spawn_gateway_bot(
|
||||
shutdown_rx,
|
||||
Some(active_project),
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -868,6 +868,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
matrix_shutdown_rx,
|
||||
None,
|
||||
vec![],
|
||||
std::collections::BTreeMap::new(),
|
||||
);
|
||||
} else {
|
||||
// Keep the receiver alive (drop it) so the sender never errors.
|
||||
|
||||
Reference in New Issue
Block a user