mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Remove restart infrastructure, generalize WASM channel setup (#493)
* refactor: remove restart infrastructure and generalize Telegram-specific code Remove the gateway restart mechanism (hot-activation works, restart won't fix activation failures) and generalize Telegram-specific hardcoded checks so all WASM channels get equal treatment. Part 1 - Remove restart infrastructure: - Remove needs_restart from ActionResponse, restart_requested from GatewayState - Remove gateway_restart_handler, /api/gateway/restart route, exit code 75 - Remove restart overlay JS/CSS (dead code - restartGateway() never called) - Surface actual activation errors instead of suggesting restart Part 2 - Generalize Telegram-specific code: - Replace telegram_owner_id: Option<i64> with generic wasm_channel_owner_ids: HashMap<String, i64> (backwards-compatible via TELEGRAM_OWNER_ID env var) - Pairing status check now applies to all active WASM channels - All channels get 3-step stepper in web UI, remove "coming soon" note - Remove dead setup_telegram() code (~700 lines) - Telegram's capabilities.json declares required_secrets, so the generic setup_wasm_channel() path handles it Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add Settings::set() test for wasm_channel_owner_ids Addresses review feedback: verify that setting per-channel owner IDs via the dotted-path Settings::set() API works correctly with the new HashMap<String, i64> type. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): refresh extension stepper after pairing approval loadPairingRequests only refreshed the pairing section, not the stepper status. Call loadExtensions() instead so the stepper updates from "Awaiting Pairing" to "Active" immediately after approval. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5f841554d5
commit
78878ad7ef
@@ -33,7 +33,7 @@ pub async fn extensions_list_handler(
|
||||
"failed".to_string()
|
||||
} else if !ext.authenticated {
|
||||
"installed".to_string()
|
||||
} else if ext.active && ext.name == "telegram" {
|
||||
} else if ext.active {
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
|
||||
@@ -94,7 +94,6 @@ impl GatewayChannel {
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
startup_time: std::time::Instant::now(),
|
||||
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -128,7 +127,6 @@ impl GatewayChannel {
|
||||
registry_entries: self.state.registry_entries.clone(),
|
||||
cost_guard: self.state.cost_guard.clone(),
|
||||
startup_time: self.state.startup_time,
|
||||
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
};
|
||||
mutate(&mut new_state);
|
||||
self.state = Arc::new(new_state);
|
||||
|
||||
@@ -165,8 +165,6 @@ pub struct GatewayState {
|
||||
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
|
||||
/// Server startup time for uptime calculation.
|
||||
pub startup_time: std::time::Instant,
|
||||
/// Flag set when a restart has been requested via the API.
|
||||
pub restart_requested: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// Start the gateway HTTP server.
|
||||
@@ -247,8 +245,6 @@ pub async fn start_server(
|
||||
"/api/extensions/{name}/setup",
|
||||
get(extensions_setup_handler).post(extensions_setup_submit_handler),
|
||||
)
|
||||
// Gateway management
|
||||
.route("/api/gateway/restart", post(gateway_restart_handler))
|
||||
// Pairing
|
||||
.route("/api/pairing/{channel}", get(pairing_list_handler))
|
||||
.route(
|
||||
@@ -1218,8 +1214,8 @@ async fn extensions_list_handler(
|
||||
} else if !ext.authenticated {
|
||||
// No credentials configured yet.
|
||||
"installed".to_string()
|
||||
} else if ext.active && ext.name == "telegram" {
|
||||
// Telegram: check pairing status (end-to-end setup via web UI).
|
||||
} else if ext.active {
|
||||
// Check pairing status for active channels.
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
@@ -1230,7 +1226,7 @@ async fn extensions_list_handler(
|
||||
"pairing".to_string()
|
||||
}
|
||||
} else {
|
||||
// Authenticated but not fully active (or non-Telegram).
|
||||
// Authenticated but not yet active.
|
||||
"configured".to_string()
|
||||
})
|
||||
} else {
|
||||
@@ -1552,41 +1548,12 @@ async fn extensions_setup_submit_handler(
|
||||
Ok(result) => {
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
resp.activated = Some(result.activated);
|
||||
if !result.activated {
|
||||
resp.needs_restart = Some(true);
|
||||
}
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway management handlers ---
|
||||
|
||||
async fn gateway_restart_handler(State(state): State<Arc<GatewayState>>) -> Json<ActionResponse> {
|
||||
// Idempotency guard: only allow one restart at a time.
|
||||
if state
|
||||
.restart_requested
|
||||
.compare_exchange(
|
||||
false,
|
||||
true,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Json(ActionResponse::ok("Restart already in progress"));
|
||||
}
|
||||
|
||||
// Take the shutdown sender and trigger graceful shutdown.
|
||||
if let Some(tx) = state.shutdown_tx.write().await.take() {
|
||||
let _ = tx.send(());
|
||||
tracing::info!("Gateway restart requested via API");
|
||||
}
|
||||
|
||||
Json(ActionResponse::ok("Restarting..."))
|
||||
}
|
||||
|
||||
// --- Pairing handlers ---
|
||||
|
||||
async fn pairing_list_handler(
|
||||
|
||||
@@ -1931,14 +1931,6 @@ function renderExtensionCard(ext) {
|
||||
card.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
// Show "coming soon" note for non-Telegram channels that are configured but not fully supported yet
|
||||
if (ext.kind === 'wasm_channel' && ext.name !== 'telegram'
|
||||
&& (ext.activation_status === 'configured' || ext.active)) {
|
||||
const noteDiv = document.createElement('div');
|
||||
noteDiv.className = 'ext-note';
|
||||
noteDiv.textContent = 'Full integration coming soon. Use the CLI to complete setup.';
|
||||
card.appendChild(noteDiv);
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ext-actions';
|
||||
@@ -2168,10 +2160,8 @@ function submitConfigureModal(name, fields) {
|
||||
if (res.success) {
|
||||
if (res.activated) {
|
||||
showToast('Configured and activated ' + name, 'success');
|
||||
} else if (res.needs_restart) {
|
||||
showToast('Configured ' + name + '. Use Reconfigure to re-enter credentials and activate.', 'info');
|
||||
} else {
|
||||
showToast(res.message, 'success');
|
||||
showToast(res.message || 'Configuration saved but activation failed', 'warning');
|
||||
}
|
||||
} else {
|
||||
showToast(res.message || 'Configuration failed', 'error');
|
||||
@@ -2235,7 +2225,7 @@ function approvePairing(channel, code, container) {
|
||||
}).then(res => {
|
||||
if (res.success) {
|
||||
showToast('Pairing approved', 'success');
|
||||
loadPairingRequests(channel, container);
|
||||
loadExtensions();
|
||||
} else {
|
||||
showToast(res.message || 'Approve failed', 'error');
|
||||
}
|
||||
@@ -2258,53 +2248,6 @@ function stopPairingPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway restart ---
|
||||
|
||||
function restartGateway() {
|
||||
if (!confirm('Restart IronClaw gateway? Active connections will be dropped.')) return;
|
||||
|
||||
apiFetch('/api/gateway/restart', { method: 'POST' })
|
||||
.then(function() {
|
||||
showRestartOverlay();
|
||||
})
|
||||
.catch(function() {
|
||||
showRestartOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
function showRestartOverlay() {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'restart-overlay';
|
||||
overlay.innerHTML = '<div class="restart-message">'
|
||||
+ '<div class="restart-spinner"></div>'
|
||||
+ '<h2>Restarting IronClaw...</h2>'
|
||||
+ '<p>Waiting for server to come back online</p>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var pollCount = 0;
|
||||
var pollTimer = setInterval(function() {
|
||||
pollCount++;
|
||||
if (pollCount > 30) { // 60 seconds
|
||||
clearInterval(pollTimer);
|
||||
overlay.querySelector('h2').textContent = 'Restart timed out';
|
||||
overlay.querySelector('p').textContent = 'Server did not come back within 60 seconds. Check logs.';
|
||||
overlay.querySelector('.restart-spinner').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fetch('/api/gateway/status', {
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) {
|
||||
clearInterval(pollTimer);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() { /* still restarting */ });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// --- WASM channel stepper ---
|
||||
|
||||
function renderWasmChannelStepper(ext) {
|
||||
@@ -2312,23 +2255,17 @@ function renderWasmChannelStepper(ext) {
|
||||
stepper.className = 'ext-stepper';
|
||||
|
||||
var status = ext.activation_status || 'installed';
|
||||
var isTelegram = ext.name === 'telegram';
|
||||
|
||||
// Telegram gets a 3-step stepper (Installed → Configured → Active/Pairing).
|
||||
// Other channels only get 2 steps (Installed → Configured) since full
|
||||
// integration isn't available in the web UI yet.
|
||||
var steps = [
|
||||
{ label: 'Installed', key: 'installed' },
|
||||
{ label: 'Configured', key: 'configured' },
|
||||
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
|
||||
];
|
||||
if (isTelegram) {
|
||||
steps.push({ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' });
|
||||
}
|
||||
|
||||
var reachedIdx;
|
||||
if (status === 'active') reachedIdx = isTelegram ? 2 : 1;
|
||||
if (status === 'active') reachedIdx = 2;
|
||||
else if (status === 'pairing') reachedIdx = 2;
|
||||
else if (status === 'failed') reachedIdx = isTelegram ? 2 : 1;
|
||||
else if (status === 'failed') reachedIdx = 2;
|
||||
else if (status === 'configured') reachedIdx = 1;
|
||||
else reachedIdx = 0;
|
||||
|
||||
|
||||
@@ -2312,43 +2312,6 @@ body {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Restart overlay */
|
||||
.restart-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.restart-message {
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.restart-message h2 {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.restart-message p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.restart-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -451,9 +451,6 @@ pub struct ActionResponse {
|
||||
/// Whether the channel was successfully activated after setup.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activated: Option<bool>,
|
||||
/// Whether a gateway restart is needed (activation failed).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub needs_restart: Option<bool>,
|
||||
}
|
||||
|
||||
impl ActionResponse {
|
||||
@@ -465,7 +462,6 @@ impl ActionResponse {
|
||||
awaiting_token: None,
|
||||
instructions: None,
|
||||
activated: None,
|
||||
needs_restart: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +473,6 @@ impl ActionResponse {
|
||||
awaiting_token: None,
|
||||
instructions: None,
|
||||
activated: None,
|
||||
needs_restart: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,7 +493,6 @@ mod tests {
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
startup_time: std::time::Instant::now(),
|
||||
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
@@ -18,8 +19,9 @@ pub struct ChannelsConfig {
|
||||
pub wasm_channels_dir: std::path::PathBuf,
|
||||
/// Whether WASM channels are enabled.
|
||||
pub wasm_channels_enabled: bool,
|
||||
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||
pub telegram_owner_id: Option<i64>,
|
||||
/// Per-channel owner user IDs. When set, the channel only responds to this user.
|
||||
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
|
||||
pub wasm_channel_owner_ids: HashMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -180,14 +182,20 @@ impl ChannelsConfig {
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
||||
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
})?
|
||||
.or(settings.channels.telegram_owner_id),
|
||||
wasm_channel_owner_ids: {
|
||||
let mut ids = settings.channels.wasm_channel_owner_ids.clone();
|
||||
// Backwards compat: TELEGRAM_OWNER_ID env var
|
||||
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
|
||||
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
|
||||
ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
}
|
||||
})?;
|
||||
ids.insert("telegram".to_string(), id);
|
||||
}
|
||||
ids
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ struct ChannelRuntimeState {
|
||||
wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
wasm_channel_router: Arc<WasmChannelRouter>,
|
||||
telegram_owner_id: Option<i64>,
|
||||
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
||||
}
|
||||
|
||||
/// Result of saving setup secrets and attempting activation.
|
||||
@@ -150,14 +150,14 @@ impl ExtensionManager {
|
||||
wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
wasm_channel_router: Arc<WasmChannelRouter>,
|
||||
telegram_owner_id: Option<i64>,
|
||||
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
||||
) {
|
||||
*self.channel_runtime.write().await = Some(ChannelRuntimeState {
|
||||
channel_manager,
|
||||
wasm_channel_runtime,
|
||||
pairing_store,
|
||||
wasm_channel_router,
|
||||
telegram_owner_id,
|
||||
wasm_channel_owner_ids,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1872,21 +1872,18 @@ impl ExtensionManager {
|
||||
channel_manager,
|
||||
pairing_store,
|
||||
wasm_channel_router,
|
||||
telegram_owner_id,
|
||||
wasm_channel_owner_ids,
|
||||
) = {
|
||||
let rt_guard = self.channel_runtime.read().await;
|
||||
let rt = rt_guard.as_ref().ok_or_else(|| {
|
||||
ExtensionError::ActivationFailed(
|
||||
"WASM channel runtime not configured. Restart IronClaw to activate."
|
||||
.to_string(),
|
||||
)
|
||||
ExtensionError::ActivationFailed("WASM channel runtime not configured".to_string())
|
||||
})?;
|
||||
(
|
||||
Arc::clone(&rt.wasm_channel_runtime),
|
||||
Arc::clone(&rt.channel_manager),
|
||||
Arc::clone(&rt.pairing_store),
|
||||
Arc::clone(&rt.wasm_channel_router),
|
||||
rt.telegram_owner_id,
|
||||
rt.wasm_channel_owner_ids.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1956,9 +1953,7 @@ impl ExtensionManager {
|
||||
);
|
||||
}
|
||||
|
||||
if channel_name == "telegram"
|
||||
&& let Some(owner_id) = telegram_owner_id
|
||||
{
|
||||
if let Some(&owner_id) = wasm_channel_owner_ids.get(channel_name.as_str()) {
|
||||
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
|
||||
}
|
||||
|
||||
@@ -2527,7 +2522,7 @@ impl ExtensionManager {
|
||||
tracing::warn!(
|
||||
channel = name,
|
||||
error = %e,
|
||||
"Saved configuration but hot-activation failed, restart may be needed"
|
||||
"Saved configuration but hot-activation failed"
|
||||
);
|
||||
self.activation_errors
|
||||
.write()
|
||||
@@ -2537,8 +2532,7 @@ impl ExtensionManager {
|
||||
.await;
|
||||
Ok(SetupResult {
|
||||
message: format!(
|
||||
"Configuration saved for '{}'. \
|
||||
Automatic activation failed ({}), restart IronClaw to activate.",
|
||||
"Configuration saved for '{}'. Activation failed: {}",
|
||||
name, e
|
||||
),
|
||||
activated: false,
|
||||
|
||||
+6
-17
@@ -484,8 +484,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let mut sse_sender: Option<
|
||||
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
|
||||
> = None;
|
||||
let mut gateway_state: Option<std::sync::Arc<ironclaw::channels::web::server::GatewayState>> =
|
||||
None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw =
|
||||
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
|
||||
@@ -542,7 +540,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||
// creates a new SseManager, which would orphan this sender.
|
||||
sse_sender = Some(gw.state().sse.sender());
|
||||
gateway_state = Some(Arc::clone(gw.state()));
|
||||
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw)).await;
|
||||
@@ -618,7 +615,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
rt,
|
||||
ps,
|
||||
router,
|
||||
config.channels.telegram_owner_id,
|
||||
config.channels.wasm_channel_owner_ids.clone(),
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Channel runtime wired into extension manager for hot-activation");
|
||||
@@ -700,16 +697,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!("Agent shutdown complete");
|
||||
|
||||
// Check if a restart was requested via the gateway API.
|
||||
if let Some(ref gw_state) = gateway_state
|
||||
&& gw_state
|
||||
.restart_requested
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
eprintln!("Restarting IronClaw (exit code 75)...");
|
||||
std::process::exit(75);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -982,9 +969,11 @@ async fn setup_wasm_channels(
|
||||
);
|
||||
}
|
||||
|
||||
// Inject owner_id for Telegram so the bot only responds to the bound user.
|
||||
if channel_name == "telegram"
|
||||
&& let Some(owner_id) = config.channels.telegram_owner_id
|
||||
// Inject owner_id if configured for this channel.
|
||||
if let Some(&owner_id) = config
|
||||
.channels
|
||||
.wasm_channel_owner_ids
|
||||
.get(channel_name.as_str())
|
||||
{
|
||||
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
|
||||
}
|
||||
|
||||
+28
-15
@@ -249,10 +249,10 @@ pub struct ChannelSettings {
|
||||
#[serde(default)]
|
||||
pub signal_group_allow_from: Option<String>,
|
||||
|
||||
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||
/// Captured during setup by having the user message the bot.
|
||||
/// Per-channel owner user IDs. When set, the channel only responds to this user.
|
||||
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
|
||||
#[serde(default)]
|
||||
pub telegram_owner_id: Option<i64>,
|
||||
pub wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
||||
|
||||
/// Enabled WASM channels by name.
|
||||
/// Channels not in this list but present in the channels directory will still load.
|
||||
@@ -1049,28 +1049,37 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_owner_id_db_round_trip() {
|
||||
fn test_wasm_channel_owner_ids_db_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
settings.channels.telegram_owner_id = Some(123456789);
|
||||
settings
|
||||
.channels
|
||||
.wasm_channel_owner_ids
|
||||
.insert("telegram".to_string(), 123456789);
|
||||
|
||||
let map = settings.to_db_map();
|
||||
let restored = Settings::from_db_map(&map);
|
||||
assert_eq!(restored.channels.telegram_owner_id, Some(123456789));
|
||||
assert_eq!(
|
||||
restored.channels.wasm_channel_owner_ids.get("telegram"),
|
||||
Some(&123456789)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_owner_id_default_none() {
|
||||
fn test_wasm_channel_owner_ids_default_empty() {
|
||||
let settings = Settings::default();
|
||||
assert_eq!(settings.channels.telegram_owner_id, None);
|
||||
assert!(settings.channels.wasm_channel_owner_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_owner_id_via_set() {
|
||||
fn test_wasm_channel_owner_ids_via_set() {
|
||||
let mut settings = Settings::default();
|
||||
settings
|
||||
.set("channels.telegram_owner_id", "987654321")
|
||||
.set("channels.wasm_channel_owner_ids.telegram", "987654321")
|
||||
.unwrap();
|
||||
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
||||
assert_eq!(
|
||||
settings.channels.wasm_channel_owner_ids.get("telegram"),
|
||||
Some(&987654321)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1406,7 +1415,11 @@ mod tests {
|
||||
channels: ChannelSettings {
|
||||
http_enabled: true,
|
||||
http_port: Some(9090),
|
||||
telegram_owner_id: Some(12345),
|
||||
wasm_channel_owner_ids: {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("telegram".to_string(), 12345);
|
||||
m
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
heartbeat: HeartbeatSettings {
|
||||
@@ -1473,9 +1486,9 @@ mod tests {
|
||||
assert!(restored.channels.http_enabled, "http_enabled lost");
|
||||
assert_eq!(restored.channels.http_port, Some(9090), "http_port lost");
|
||||
assert_eq!(
|
||||
restored.channels.telegram_owner_id,
|
||||
Some(12345),
|
||||
"telegram_owner_id lost"
|
||||
restored.channels.wasm_channel_owner_ids.get("telegram"),
|
||||
Some(&12345),
|
||||
"wasm_channel_owner_ids lost"
|
||||
);
|
||||
assert!(restored.heartbeat.enabled, "heartbeat.enabled lost");
|
||||
assert_eq!(
|
||||
|
||||
+2
-337
@@ -1,6 +1,6 @@
|
||||
//! Channel-specific setup flows.
|
||||
//! Channel setup flows.
|
||||
//!
|
||||
//! Each channel (Telegram, HTTP, etc.) has its own setup function that:
|
||||
//! Each channel (HTTP, Signal, WASM, etc.) has its own setup function that:
|
||||
//! 1. Displays setup instructions
|
||||
//! 2. Collects configuration (tokens, ports, etc.)
|
||||
//! 3. Validates the configuration
|
||||
@@ -9,9 +9,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use reqwest::Client;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -105,261 +103,6 @@ impl SecretsContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of Telegram setup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TelegramSetupResult {
|
||||
pub enabled: bool,
|
||||
pub bot_username: Option<String>,
|
||||
pub webhook_secret: Option<String>,
|
||||
pub owner_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Telegram Bot API response for getMe.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramGetMeResponse {
|
||||
ok: bool,
|
||||
result: Option<TelegramUser>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramUser {
|
||||
username: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
first_name: String,
|
||||
}
|
||||
|
||||
/// Telegram Bot API response for getUpdates.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramGetUpdatesResponse {
|
||||
ok: bool,
|
||||
result: Vec<TelegramUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramUpdate {
|
||||
update_id: i64,
|
||||
message: Option<TelegramUpdateMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramUpdateMessage {
|
||||
from: Option<TelegramUpdateUser>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramUpdateUser {
|
||||
id: i64,
|
||||
first_name: String,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
/// Set up Telegram bot channel.
|
||||
///
|
||||
/// Guides the user through:
|
||||
/// 1. Creating a bot with @BotFather
|
||||
/// 2. Entering the bot token
|
||||
/// 3. Validating the token
|
||||
/// 4. Saving the token to the database
|
||||
pub async fn setup_telegram(
|
||||
secrets: &SecretsContext,
|
||||
settings: &Settings,
|
||||
) -> Result<TelegramSetupResult, ChannelSetupError> {
|
||||
println!("Telegram Setup:");
|
||||
println!();
|
||||
print_info("To create a Telegram bot:");
|
||||
print_info("1. Open Telegram and message @BotFather");
|
||||
print_info("2. Send /newbot and follow the prompts");
|
||||
print_info("3. Copy the bot token (looks like 123456:ABC-DEF...)");
|
||||
println!();
|
||||
|
||||
// Check if token already exists
|
||||
if secrets.secret_exists("telegram_bot_token").await {
|
||||
print_info("Existing Telegram token found in database.");
|
||||
if !confirm("Replace existing token?", false)? {
|
||||
// Still offer to configure webhook secret and owner binding
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
|
||||
return Ok(TelegramSetupResult {
|
||||
enabled: true,
|
||||
bot_username: None,
|
||||
webhook_secret,
|
||||
owner_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let token = secret_input("Bot token (from @BotFather)")?;
|
||||
|
||||
// Validate the token
|
||||
print_info("Validating bot token...");
|
||||
|
||||
match validate_telegram_token(&token).await {
|
||||
Ok(username) => {
|
||||
print_success(&format!(
|
||||
"Bot validated: @{}",
|
||||
username.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
|
||||
// Save to database
|
||||
secrets.save_secret("telegram_bot_token", &token).await?;
|
||||
print_success("Token saved to database");
|
||||
|
||||
// Bind bot to owner's Telegram account
|
||||
let owner_id = bind_telegram_owner(&token).await?;
|
||||
|
||||
// Offer webhook secret configuration
|
||||
let webhook_secret =
|
||||
setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||
|
||||
return Ok(TelegramSetupResult {
|
||||
enabled: true,
|
||||
bot_username: username,
|
||||
webhook_secret,
|
||||
owner_id,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
print_error(&format!("Token validation failed: {}", e));
|
||||
|
||||
if !confirm("Try again?", true)? {
|
||||
return Ok(TelegramSetupResult {
|
||||
enabled: false,
|
||||
bot_username: None,
|
||||
webhook_secret: None,
|
||||
owner_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the bot to the owner's Telegram account by having them send a message.
|
||||
///
|
||||
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
|
||||
/// Returns `None` if the user declines or the flow times out.
|
||||
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, ChannelSetupError> {
|
||||
println!();
|
||||
print_info("Account Binding (recommended):");
|
||||
print_info("Binding restricts the bot so only YOU can use it.");
|
||||
print_info("Without this, anyone who finds your bot can send it messages.");
|
||||
println!();
|
||||
|
||||
if !confirm("Bind bot to your Telegram account?", true)? {
|
||||
print_info("Skipping account binding. Bot will accept messages from all users.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
print_info("Send any message (e.g. /start) to your bot in Telegram.");
|
||||
print_info("Waiting for your message (up to 120 seconds)...");
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.build()
|
||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
// Clear any existing webhook so getUpdates works
|
||||
let delete_url = format!(
|
||||
"https://api.telegram.org/bot{}/deleteWebhook",
|
||||
token.expose_secret()
|
||||
);
|
||||
if let Err(e) = client.post(&delete_url).send().await {
|
||||
tracing::warn!("Failed to delete webhook (getUpdates may not work): {e}");
|
||||
}
|
||||
|
||||
let updates_url = format!(
|
||||
"https://api.telegram.org/bot{}/getUpdates",
|
||||
token.expose_secret()
|
||||
);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
let response = client
|
||||
.get(&updates_url)
|
||||
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelSetupError::Network(format!("getUpdates request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ChannelSetupError::Network(format!(
|
||||
"getUpdates returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: TelegramGetUpdatesResponse = response.json().await.map_err(|e| {
|
||||
ChannelSetupError::Network(format!("Failed to parse getUpdates response: {}", e))
|
||||
})?;
|
||||
|
||||
if !body.ok {
|
||||
return Err(ChannelSetupError::Network(
|
||||
"Telegram API returned error for getUpdates".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Find the first message with a sender
|
||||
for update in &body.result {
|
||||
if let Some(ref msg) = update.message
|
||||
&& let Some(ref from) = msg.from
|
||||
{
|
||||
let display_name = from
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| format!("@{}", u))
|
||||
.unwrap_or_else(|| from.first_name.clone());
|
||||
|
||||
print_success(&format!(
|
||||
"Received message from {} (ID: {})",
|
||||
display_name, from.id
|
||||
));
|
||||
|
||||
// Acknowledge the update so it doesn't pile up
|
||||
let ack_url = format!(
|
||||
"https://api.telegram.org/bot{}/getUpdates",
|
||||
token.expose_secret()
|
||||
);
|
||||
if let Err(e) = client
|
||||
.get(&ack_url)
|
||||
.query(&[("offset", &(update.update_id + 1).to_string())])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to acknowledge Telegram update: {e}");
|
||||
}
|
||||
|
||||
return Ok(Some(from.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_error("Timed out waiting for a message. You can re-run setup to try again.");
|
||||
print_info("Bot will accept messages from all users until owner is bound.");
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Bind flow when the token already exists (reads from secrets store).
|
||||
///
|
||||
/// Retrieves the saved bot token and delegates to `bind_telegram_owner`.
|
||||
async fn bind_telegram_owner_flow(
|
||||
secrets: &SecretsContext,
|
||||
settings: &Settings,
|
||||
) -> Result<Option<i64>, ChannelSetupError> {
|
||||
if settings.channels.telegram_owner_id.is_some() {
|
||||
print_info("Bot is already bound to a Telegram account.");
|
||||
if !confirm("Re-bind to a different account?", false)? {
|
||||
return Ok(settings.channels.telegram_owner_id);
|
||||
}
|
||||
}
|
||||
|
||||
// We need the token to poll getUpdates
|
||||
let token = secrets.get_secret("telegram_bot_token").await?;
|
||||
|
||||
bind_telegram_owner(&token).await
|
||||
}
|
||||
|
||||
/// Set up a tunnel for exposing the agent to the internet.
|
||||
///
|
||||
/// This is shared across all channels that need webhook endpoints.
|
||||
@@ -725,84 +468,6 @@ fn setup_tunnel_static() -> Result<TunnelSettings, ChannelSetupError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set up Telegram webhook secret for signature validation.
|
||||
///
|
||||
/// Returns the webhook secret if configured.
|
||||
async fn setup_telegram_webhook_secret(
|
||||
secrets: &SecretsContext,
|
||||
tunnel: &TunnelSettings,
|
||||
) -> Result<Option<String>, ChannelSetupError> {
|
||||
if tunnel.public_url.is_none() {
|
||||
print_info("");
|
||||
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
||||
print_info("Run setup again to configure a tunnel for instant delivery.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
println!();
|
||||
print_info("Telegram Webhook Security:");
|
||||
print_info("A webhook secret adds an extra layer of security by validating");
|
||||
print_info("that requests actually come from Telegram's servers.");
|
||||
|
||||
if !confirm("Generate a webhook secret?", true)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let secret = generate_webhook_secret();
|
||||
secrets
|
||||
.save_secret(
|
||||
"telegram_webhook_secret",
|
||||
&SecretString::from(secret.clone()),
|
||||
)
|
||||
.await?;
|
||||
print_success("Webhook secret generated and saved");
|
||||
|
||||
Ok(Some(secret))
|
||||
}
|
||||
|
||||
/// Validate a Telegram bot token by calling the getMe API.
|
||||
///
|
||||
/// Returns the bot's username if valid.
|
||||
pub async fn validate_telegram_token(
|
||||
token: &SecretString,
|
||||
) -> Result<Option<String>, ChannelSetupError> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/getMe",
|
||||
token.expose_secret()
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelSetupError::Network(format!("Request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ChannelSetupError::Network(format!(
|
||||
"API returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: TelegramGetMeResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
if body.ok {
|
||||
Ok(body.result.and_then(|u| u.username))
|
||||
} else {
|
||||
Err(ChannelSetupError::Network(
|
||||
"Telegram API returned error".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of HTTP webhook setup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpSetupResult {
|
||||
|
||||
+1
-4
@@ -24,10 +24,7 @@ mod prompts;
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
mod wizard;
|
||||
|
||||
pub use channels::{
|
||||
ChannelSetupError, SecretsContext, setup_http, setup_telegram, setup_tunnel,
|
||||
validate_telegram_token,
|
||||
};
|
||||
pub use channels::{ChannelSetupError, SecretsContext, setup_http, setup_tunnel};
|
||||
pub use prompts::{
|
||||
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
||||
print_success, secret_input, select_many, select_one,
|
||||
|
||||
+1
-10
@@ -26,7 +26,7 @@ use crate::llm::{SessionConfig, SessionManager};
|
||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||
use crate::settings::{KeySource, Settings};
|
||||
use crate::setup::channels::{
|
||||
SecretsContext, setup_http, setup_signal, setup_telegram, setup_tunnel, setup_wasm_channel,
|
||||
SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel,
|
||||
};
|
||||
use crate::setup::prompts::{
|
||||
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
||||
@@ -1670,15 +1670,6 @@ impl SetupWizard {
|
||||
let result = if let Some(cap_file) = discovered_by_name.get(&channel_name) {
|
||||
if !cap_file.setup.required_secrets.is_empty() {
|
||||
setup_wasm_channel(ctx, &channel_name, &cap_file.setup).await?
|
||||
} else if channel_name == "telegram" {
|
||||
let telegram_result = setup_telegram(ctx, &self.settings).await?;
|
||||
if let Some(owner_id) = telegram_result.owner_id {
|
||||
self.settings.channels.telegram_owner_id = Some(owner_id);
|
||||
}
|
||||
crate::setup::channels::WasmChannelSetupResult {
|
||||
enabled: telegram_result.enabled,
|
||||
channel_name: "telegram".to_string(),
|
||||
}
|
||||
} else {
|
||||
print_info(&format!(
|
||||
"No setup configuration found for {}",
|
||||
|
||||
Reference in New Issue
Block a user