huskies: merge 566_story_gateway_ui_bot_configuration_page
This commit is contained in:
@@ -58,6 +58,10 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
|
|||||||
/// announce the shutdown to all configured rooms before the process exits.
|
/// announce the shutdown to all configured rooms before the process exits.
|
||||||
///
|
///
|
||||||
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
pub fn spawn_bot(
|
pub fn spawn_bot(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||||
@@ -66,12 +70,12 @@ pub fn spawn_bot(
|
|||||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
gateway_projects: Vec<String>,
|
gateway_projects: Vec<String>,
|
||||||
) {
|
) -> Option<tokio::task::AbortHandle> {
|
||||||
let config = match BotConfig::load(project_root) {
|
let config = match BotConfig::load(project_root) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped");
|
crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped");
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ pub fn spawn_bot(
|
|||||||
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
|
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
|
||||||
config.transport
|
config.transport
|
||||||
);
|
);
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
@@ -93,7 +97,7 @@ pub fn spawn_bot(
|
|||||||
let root = project_root.to_path_buf();
|
let root = project_root.to_path_buf();
|
||||||
let watcher_rx = watcher_tx.subscribe();
|
let watcher_rx = watcher_tx.subscribe();
|
||||||
let watcher_rx_auto = watcher_tx.subscribe();
|
let watcher_rx_auto = watcher_tx.subscribe();
|
||||||
tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
if let Err(e) = bot::run_bot(
|
if let Err(e) = bot::run_bot(
|
||||||
config,
|
config,
|
||||||
root,
|
root,
|
||||||
@@ -110,4 +114,5 @@ pub fn spawn_bot(
|
|||||||
crate::slog!("[matrix-bot] Fatal error: {e}");
|
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Some(handle.abort_handle())
|
||||||
}
|
}
|
||||||
|
|||||||
+372
-12
@@ -19,6 +19,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -106,8 +107,13 @@ pub struct GatewayState {
|
|||||||
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
|
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
|
||||||
/// One-time join tokens that have been issued but not yet consumed.
|
/// One-time join tokens that have been issued but not yet consumed.
|
||||||
pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
||||||
/// Directory containing `projects.toml`, used for persisting agent data.
|
/// Directory containing `projects.toml` and the `.huskies/` subfolder.
|
||||||
pub config_dir: PathBuf,
|
pub config_dir: PathBuf,
|
||||||
|
/// HTTP port the gateway is listening on.
|
||||||
|
pub port: u16,
|
||||||
|
/// Abort handle for the running Matrix bot task (if any).
|
||||||
|
/// Stored so the bot can be restarted when credentials change.
|
||||||
|
pub bot_handle: Arc<TokioMutex<Option<tokio::task::AbortHandle>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load persisted agents from `<config_dir>/gateway_agents.json`.
|
/// Load persisted agents from `<config_dir>/gateway_agents.json`.
|
||||||
@@ -138,7 +144,7 @@ impl GatewayState {
|
|||||||
/// The first project in the config becomes the active project by default.
|
/// The first project in the config becomes the active project by default.
|
||||||
/// Previously registered agents are loaded from `gateway_agents.json` in
|
/// Previously registered agents are loaded from `gateway_agents.json` in
|
||||||
/// `config_dir` if the file exists.
|
/// `config_dir` if the file exists.
|
||||||
pub fn new(config: GatewayConfig, config_dir: PathBuf) -> Result<Self, String> {
|
pub fn new(config: GatewayConfig, config_dir: PathBuf, port: u16) -> Result<Self, String> {
|
||||||
if config.projects.is_empty() {
|
if config.projects.is_empty() {
|
||||||
return Err("projects.toml must define at least one project".to_string());
|
return Err("projects.toml must define at least one project".to_string());
|
||||||
}
|
}
|
||||||
@@ -151,6 +157,8 @@ impl GatewayState {
|
|||||||
joined_agents: Arc::new(RwLock::new(agents)),
|
joined_agents: Arc::new(RwLock::new(agents)),
|
||||||
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||||
config_dir,
|
config_dir,
|
||||||
|
port,
|
||||||
|
bot_handle: Arc::new(TokioMutex::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,6 +906,9 @@ const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
|
|||||||
.status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
|
.status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
|
||||||
.status.ok { color: #4ade80; }
|
.status.ok { color: #4ade80; }
|
||||||
.status.err { color: #f87171; }
|
.status.err { color: #f87171; }
|
||||||
|
.nav { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid #334155; display: flex; gap: 1rem; }
|
||||||
|
.nav a { font-size: 0.8rem; color: #64748b; text-decoration: none; }
|
||||||
|
.nav a:hover { color: #94a3b8; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -918,6 +929,9 @@ const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
|
|||||||
<span id="active-name"></span>
|
<span id="active-name"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status"></div>
|
<div id="status" class="status"></div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/bot-config">🤖 Bot Configuration</a>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
async function loadState() {
|
async function loadState() {
|
||||||
@@ -1051,6 +1065,343 @@ pub async fn gateway_switch_handler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bot configuration API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Request/response body for the bot configuration API.
|
||||||
|
#[derive(Deserialize, Serialize, Default)]
|
||||||
|
struct BotConfigPayload {
|
||||||
|
/// Chat transport: `"matrix"` or `"slack"`.
|
||||||
|
transport: String,
|
||||||
|
// Matrix fields
|
||||||
|
homeserver: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
// Slack fields
|
||||||
|
slack_bot_token: Option<String>,
|
||||||
|
slack_signing_secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current raw bot.toml (without validation) as key/value pairs for
|
||||||
|
/// the configuration UI. Returns an empty payload if the file does not exist.
|
||||||
|
fn read_bot_config_raw(config_dir: &Path) -> BotConfigPayload {
|
||||||
|
let path = config_dir.join(".huskies").join("bot.toml");
|
||||||
|
let content = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return BotConfigPayload::default(),
|
||||||
|
};
|
||||||
|
let table: toml::Value = match toml::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return BotConfigPayload::default(),
|
||||||
|
};
|
||||||
|
let s = |key: &str| -> Option<String> {
|
||||||
|
table
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
};
|
||||||
|
BotConfigPayload {
|
||||||
|
transport: s("transport").unwrap_or_else(|| "matrix".to_string()),
|
||||||
|
homeserver: s("homeserver"),
|
||||||
|
username: s("username"),
|
||||||
|
password: s("password"),
|
||||||
|
slack_bot_token: s("slack_bot_token"),
|
||||||
|
slack_signing_secret: s("slack_signing_secret"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a `bot.toml` from the given payload.
|
||||||
|
fn write_bot_config(config_dir: &Path, payload: &BotConfigPayload) -> Result<(), String> {
|
||||||
|
let huskies_dir = config_dir.join(".huskies");
|
||||||
|
std::fs::create_dir_all(&huskies_dir)
|
||||||
|
.map_err(|e| format!("cannot create .huskies dir: {e}"))?;
|
||||||
|
let path = huskies_dir.join("bot.toml");
|
||||||
|
|
||||||
|
let content = match payload.transport.as_str() {
|
||||||
|
"slack" => {
|
||||||
|
format!(
|
||||||
|
"enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n",
|
||||||
|
toml_string(payload.slack_bot_token.as_deref().unwrap_or("")),
|
||||||
|
toml_string(payload.slack_signing_secret.as_deref().unwrap_or("")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Default to matrix
|
||||||
|
format!(
|
||||||
|
"enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n",
|
||||||
|
toml_string(payload.homeserver.as_deref().unwrap_or("")),
|
||||||
|
toml_string(payload.username.as_deref().unwrap_or("")),
|
||||||
|
toml_string(payload.password.as_deref().unwrap_or("")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::write(&path, content).map_err(|e| format!("cannot write bot.toml: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape a string as a TOML quoted string.
|
||||||
|
fn toml_string(s: &str) -> String {
|
||||||
|
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_bot_config_get_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||||
|
let payload = read_bot_config_raw(&state.config_dir);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&payload).unwrap_or_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/gateway/bot-config` — write new bot.toml and restart the bot.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_bot_config_save_handler(
|
||||||
|
state: Data<&Arc<GatewayState>>,
|
||||||
|
body: Json<BotConfigPayload>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(e) = write_bot_config(&state.config_dir, &body) {
|
||||||
|
let err = json!({ "ok": false, "error": e });
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&err).unwrap_or_default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort the existing bot task (if any) and spawn a fresh one with the new config.
|
||||||
|
{
|
||||||
|
let mut handle = state.bot_handle.lock().await;
|
||||||
|
if let Some(h) = handle.take() {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
let gateway_projects: Vec<String> = state.config.projects.keys().cloned().collect();
|
||||||
|
let new_handle = spawn_gateway_bot(
|
||||||
|
&state.config_dir,
|
||||||
|
Arc::clone(&state.active_project),
|
||||||
|
gateway_projects,
|
||||||
|
state.port,
|
||||||
|
);
|
||||||
|
*handle = new_handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::slog!("[gateway] Bot configuration saved; bot restarted");
|
||||||
|
let ok = json!({ "ok": true });
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&ok).unwrap_or_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Self-contained HTML page for bot configuration.
|
||||||
|
const GATEWAY_BOT_CONFIG_HTML: &str = r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bot Configuration — Huskies Gateway</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
color: #64748b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.back:hover { color: #94a3b8; }
|
||||||
|
.logo { font-size: 1.5rem; }
|
||||||
|
h1 { font-size: 1.2rem; font-weight: 600; color: #f8fafc; }
|
||||||
|
.field { margin-bottom: 1rem; }
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.875rem center;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
.section { margin-top: 1rem; }
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
button:hover { background: #4f46e5; }
|
||||||
|
button:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 0.875rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
|
||||||
|
.status.ok { color: #4ade80; }
|
||||||
|
.status.err { color: #f87171; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<a href="/" class="back">← Gateway</a>
|
||||||
|
<span class="logo">🤖</span>
|
||||||
|
<h1>Bot Configuration</h1>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="transport">Transport</label>
|
||||||
|
<select id="transport" onchange="onTransportChange(this.value)">
|
||||||
|
<option value="matrix">Matrix</option>
|
||||||
|
<option value="slack">Slack</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<hr class="divider">
|
||||||
|
<div id="matrix-fields" class="section">
|
||||||
|
<div class="field">
|
||||||
|
<label for="homeserver">Homeserver URL</label>
|
||||||
|
<input type="text" id="homeserver" placeholder="https://matrix.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Bot Username</label>
|
||||||
|
<input type="text" id="username" placeholder="@bot:example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="slack-fields" class="section" style="display:none">
|
||||||
|
<div class="field">
|
||||||
|
<label for="slack-bot-token">Bot Token</label>
|
||||||
|
<input type="password" id="slack-bot-token" placeholder="xoxb-…">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="slack-signing-secret">App / Signing Secret</label>
|
||||||
|
<input type="password" id="slack-signing-secret" placeholder="Your signing secret">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="save-btn" onclick="save()">Save & Restart Bot</button>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function onTransportChange(v) {
|
||||||
|
document.getElementById('matrix-fields').style.display = v === 'matrix' ? '' : 'none';
|
||||||
|
document.getElementById('slack-fields').style.display = v === 'slack' ? '' : 'none';
|
||||||
|
}
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/gateway/bot-config');
|
||||||
|
const d = await r.json();
|
||||||
|
document.getElementById('transport').value = d.transport || 'matrix';
|
||||||
|
onTransportChange(d.transport || 'matrix');
|
||||||
|
document.getElementById('homeserver').value = d.homeserver || '';
|
||||||
|
document.getElementById('username').value = d.username || '';
|
||||||
|
document.getElementById('password').value = d.password || '';
|
||||||
|
document.getElementById('slack-bot-token').value = d.slack_bot_token || '';
|
||||||
|
document.getElementById('slack-signing-secret').value = d.slack_signing_secret || '';
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('status').textContent = 'Failed to load config: ' + e;
|
||||||
|
document.getElementById('status').className = 'status err';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const btn = document.getElementById('save-btn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving…';
|
||||||
|
statusEl.className = 'status';
|
||||||
|
statusEl.textContent = '';
|
||||||
|
const transport = document.getElementById('transport').value;
|
||||||
|
const payload = { transport };
|
||||||
|
if (transport === 'matrix') {
|
||||||
|
payload.homeserver = document.getElementById('homeserver').value;
|
||||||
|
payload.username = document.getElementById('username').value;
|
||||||
|
payload.password = document.getElementById('password').value;
|
||||||
|
} else {
|
||||||
|
payload.slack_bot_token = document.getElementById('slack-bot-token').value;
|
||||||
|
payload.slack_signing_secret = document.getElementById('slack-signing-secret').value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/gateway/bot-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
statusEl.className = 'status ok';
|
||||||
|
statusEl.textContent = 'Saved — bot restarted with new credentials.';
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'status err';
|
||||||
|
statusEl.textContent = d.error || 'Save failed';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.className = 'status err';
|
||||||
|
statusEl.textContent = 'Error: ' + e;
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Save & Restart Bot';
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Serve the bot configuration HTML page at `GET /bot-config`.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_bot_config_page_handler() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
.body(Body::from(GATEWAY_BOT_CONFIG_HTML))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gateway server startup ───────────────────────────────────────────
|
// ── Gateway server startup ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
||||||
@@ -1062,7 +1413,8 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
|
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
|
||||||
let state = GatewayState::new(config, config_dir.clone()).map_err(std::io::Error::other)?;
|
let state =
|
||||||
|
GatewayState::new(config, config_dir.clone(), port).map_err(std::io::Error::other)?;
|
||||||
let state_arc = Arc::new(state);
|
let state_arc = Arc::new(state);
|
||||||
|
|
||||||
let active = state_arc.active_project.read().await.clone();
|
let active = state_arc.active_project.read().await.clone();
|
||||||
@@ -1086,17 +1438,23 @@ 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.
|
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
||||||
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
|
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
|
||||||
spawn_gateway_bot(
|
let bot_abort = spawn_gateway_bot(
|
||||||
&config_dir,
|
&config_dir,
|
||||||
Arc::clone(&state_arc.active_project),
|
Arc::clone(&state_arc.active_project),
|
||||||
gateway_projects,
|
gateway_projects,
|
||||||
port,
|
port,
|
||||||
);
|
);
|
||||||
|
*state_arc.bot_handle.lock().await = bot_abort;
|
||||||
|
|
||||||
let route = poem::Route::new()
|
let route = poem::Route::new()
|
||||||
.at("/", poem::get(gateway_index_handler))
|
.at("/", poem::get(gateway_index_handler))
|
||||||
|
.at("/bot-config", poem::get(gateway_bot_config_page_handler))
|
||||||
.at("/api/gateway", poem::get(gateway_api_handler))
|
.at("/api/gateway", poem::get(gateway_api_handler))
|
||||||
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
|
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
|
||||||
|
.at(
|
||||||
|
"/api/gateway/bot-config",
|
||||||
|
poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),
|
||||||
|
)
|
||||||
.at(
|
.at(
|
||||||
"/mcp",
|
"/mcp",
|
||||||
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
||||||
@@ -1167,12 +1525,14 @@ fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::E
|
|||||||
/// returns immediately without spawning anything. When the bot is enabled it
|
/// returns immediately without spawning anything. When the bot is enabled it
|
||||||
/// receives a shared reference to the gateway's active-project `RwLock` so the
|
/// receives a shared reference to the gateway's active-project `RwLock` so the
|
||||||
/// `switch` command can change the active project without going through HTTP.
|
/// `switch` command can change the active project without going through HTTP.
|
||||||
|
///
|
||||||
|
/// Returns an [`tokio::task::AbortHandle`] if the bot task was spawned, `None` otherwise.
|
||||||
fn spawn_gateway_bot(
|
fn spawn_gateway_bot(
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
active_project: ActiveProject,
|
active_project: ActiveProject,
|
||||||
gateway_projects: Vec<String>,
|
gateway_projects: Vec<String>,
|
||||||
port: u16,
|
port: u16,
|
||||||
) {
|
) -> Option<tokio::task::AbortHandle> {
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
@@ -1202,7 +1562,7 @@ fn spawn_gateway_bot(
|
|||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
Some(active_project),
|
Some(active_project),
|
||||||
gateway_projects,
|
gateway_projects,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
@@ -1238,7 +1598,7 @@ url = "http://localhost:3002"
|
|||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
projects: BTreeMap::new(),
|
projects: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
assert!(GatewayState::new(config, PathBuf::new()).is_err());
|
assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1257,7 +1617,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
|
||||||
let active = state.active_project.blocking_read().clone();
|
let active = state.active_project.blocking_read().clone();
|
||||||
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
|
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
|
||||||
}
|
}
|
||||||
@@ -1290,7 +1650,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
|
||||||
|
|
||||||
let params = json!({ "arguments": { "project": "beta" } });
|
let params = json!({ "arguments": { "project": "beta" } });
|
||||||
let resp = handle_switch_project(¶ms, &state).await;
|
let resp = handle_switch_project(¶ms, &state).await;
|
||||||
@@ -1310,7 +1670,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
|
||||||
|
|
||||||
let params = json!({ "arguments": { "project": "nonexistent" } });
|
let params = json!({ "arguments": { "project": "nonexistent" } });
|
||||||
let resp = handle_switch_project(¶ms, &state).await;
|
let resp = handle_switch_project(¶ms, &state).await;
|
||||||
@@ -1327,7 +1687,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
|
||||||
|
|
||||||
let url = state.active_url().await.unwrap();
|
let url = state.active_url().await.unwrap();
|
||||||
assert_eq!(url, "http://my:3001");
|
assert_eq!(url, "http://my:3001");
|
||||||
@@ -1463,7 +1823,7 @@ enabled = false
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
Arc::new(GatewayState::new(config, PathBuf::new()).unwrap())
|
Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+1
-1
@@ -860,7 +860,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
// messages if `.huskies/bot.toml` is present and enabled.
|
// messages if `.huskies/bot.toml` is present and enabled.
|
||||||
if let Some(ref root) = startup_root {
|
if let Some(ref root) = startup_root {
|
||||||
chat::transport::matrix::spawn_bot(
|
let _ = chat::transport::matrix::spawn_bot(
|
||||||
root,
|
root,
|
||||||
watcher_tx_for_bot,
|
watcher_tx_for_bot,
|
||||||
perm_rx_for_bot,
|
perm_rx_for_bot,
|
||||||
|
|||||||
Reference in New Issue
Block a user