Skip to main content

roboticus_cli/cli/admin/misc/
channel_ops.rs

1pub async fn cmd_circuit_status(url: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
2    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
3    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
4    let c = RoboticusClient::new(url)?;
5    let data = c.get("/api/breaker/status").await.map_err(|e| {
6        RoboticusClient::check_connectivity_hint(&*e);
7        e
8    })?;
9    if json {
10        println!("{}", serde_json::to_string_pretty(&data)?);
11        return Ok(());
12    }
13
14    heading("Circuit Breaker Status");
15
16    if let Some(providers) = data["providers"].as_object() {
17        if providers.is_empty() {
18            empty_state("No providers registered yet");
19        } else {
20            for (name, status) in providers {
21                let state = status["state"].as_str().unwrap_or("unknown");
22                kv_accent(name, &status_badge(state));
23            }
24        }
25    } else {
26        empty_state("No providers registered yet");
27    }
28
29    if let Some(note) = data["note"].as_str() {
30        eprintln!();
31        eprintln!("    {DIM}\u{2139}  {note}{RESET}");
32    }
33
34    eprintln!();
35    Ok(())
36}
37
38pub async fn cmd_circuit_reset(
39    url: &str,
40    provider: Option<&str>,
41) -> Result<(), Box<dyn std::error::Error>> {
42    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
43    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
44    let client = super::http_client()?;
45    heading("Circuit Breaker Reset");
46
47    let providers: Vec<String> = if let Some(single) = provider {
48        vec![single.to_string()]
49    } else {
50        let status = client
51            .get(format!("{url}/api/breaker/status"))
52            .send()
53            .await
54            .inspect_err(|_| {
55                eprintln!("  {ERR} Cannot reach gateway at {url}");
56            })?;
57
58        if !status.status().is_success() {
59            eprintln!("    {WARN} Status returned HTTP {}", status.status());
60            eprintln!();
61            return Ok(());
62        }
63
64        let body: serde_json::Value = status.json().await.unwrap_or_else(|e| {
65            tracing::warn!("failed to parse breaker status response: {e}");
66            serde_json::Value::default()
67        });
68        body.get("providers")
69            .and_then(|v| v.as_object())
70            .map(|m| m.keys().cloned().collect())
71            .unwrap_or_default()
72    };
73
74    if providers.is_empty() {
75        eprintln!("    {WARN} No providers reported by gateway");
76        eprintln!();
77        return Ok(());
78    }
79
80    let mut reset_ok = 0usize;
81    for provider in &providers {
82        let resp = client
83            .post(format!("{url}/api/breaker/reset/{provider}"))
84            .send()
85            .await;
86        match resp {
87            Ok(r) if r.status().is_success() => {
88                reset_ok += 1;
89            }
90            Ok(r) => {
91                eprintln!("    {WARN} reset {} returned HTTP {}", provider, r.status());
92            }
93            Err(e) => {
94                eprintln!("    {WARN} reset {} failed: {}", provider, e);
95            }
96        }
97    }
98
99    if reset_ok == providers.len() {
100        eprintln!(
101            "    {OK} Reset {} providers to closed state",
102            providers.len()
103        );
104    } else {
105        eprintln!(
106            "    {WARN} Reset {}/{} providers",
107            reset_ok,
108            providers.len()
109        );
110    }
111
112    eprintln!();
113    Ok(())
114}
115
116// ── Agents, channels ──────────────────────────────────────────
117
118pub async fn cmd_agent_start(base_url: &str, id: &str) -> Result<(), Box<dyn std::error::Error>> {
119    let client = super::http_client()?;
120    let resp = client
121        .post(format!("{base_url}/api/agents/{id}/start"))
122        .send()
123        .await?;
124    if !resp.status().is_success() {
125        let status = resp.status();
126        let body = resp.text().await.unwrap_or_else(|e| {
127            tracing::warn!("failed to read agent start error body: {e}");
128            String::new()
129        });
130        return Err(format!("HTTP {status}: {body}").into());
131    }
132    eprintln!("  Agent {id} started");
133    Ok(())
134}
135
136pub async fn cmd_agent_stop(base_url: &str, id: &str) -> Result<(), Box<dyn std::error::Error>> {
137    let client = super::http_client()?;
138    let resp = client
139        .post(format!("{base_url}/api/agents/{id}/stop"))
140        .send()
141        .await?;
142    if !resp.status().is_success() {
143        let status = resp.status();
144        let body = resp.text().await.unwrap_or_else(|e| {
145            tracing::warn!("failed to read agent stop error body: {e}");
146            String::new()
147        });
148        return Err(format!("HTTP {status}: {body}").into());
149    }
150    eprintln!("  Agent {id} stopped");
151    Ok(())
152}
153
154pub async fn cmd_agents_list(base_url: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
155    let client = super::http_client()?;
156    let resp = client.get(format!("{base_url}/api/agents")).send().await?;
157    let body: serde_json::Value = resp.json().await?;
158    if json {
159        println!("{}", serde_json::to_string_pretty(&body)?);
160        return Ok(());
161    }
162
163    let agents = body
164        .get("agents")
165        .and_then(|v| v.as_array())
166        .cloned()
167        .unwrap_or_default();
168
169    if agents.is_empty() {
170        println!("\n  No agents registered.\n");
171        return Ok(());
172    }
173
174    println!(
175        "\n  {:<15} {:<20} {:<10} {:<15}",
176        "ID", "Name", "State", "Model"
177    );
178    println!("  {}", "─".repeat(65));
179    for a in &agents {
180        let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("?");
181        let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("?");
182        let state = a.get("state").and_then(|v| v.as_str()).unwrap_or("?");
183        let model = a.get("model").and_then(|v| v.as_str()).unwrap_or("?");
184        println!("  {:<15} {:<20} {:<10} {:<15}", id, name, state, model);
185    }
186    println!();
187    Ok(())
188}
189
190pub async fn cmd_channels_status(
191    base_url: &str,
192    json: bool,
193) -> Result<(), Box<dyn std::error::Error>> {
194    let resp = super::http_client()?
195        .get(format!("{base_url}/api/channels/status"))
196        .send()
197        .await?;
198    let body: serde_json::Value = resp.json().await?;
199    if json {
200        println!("{}", serde_json::to_string_pretty(&body)?);
201        return Ok(());
202    }
203    let channels: Vec<serde_json::Value> =
204        serde_json::from_value(body).unwrap_or_default();
205
206    if channels.is_empty() {
207        println!("  No channels configured.");
208        return Ok(());
209    }
210
211    println!(
212        "\n  {:<15} {:<10} {:<10} {:<10}",
213        "Channel", "Status", "Recv", "Sent"
214    );
215    println!("  {}", "─".repeat(50));
216    for ch in &channels {
217        let name = ch.get("name").and_then(|v| v.as_str()).unwrap_or("?");
218        let connected = ch
219            .get("connected")
220            .and_then(|v| v.as_bool())
221            .unwrap_or(false);
222        let status_str = if connected { "✓ up" } else { "✗ down" };
223        let recv = ch
224            .get("messages_received")
225            .and_then(|v| v.as_u64())
226            .unwrap_or(0);
227        let sent = ch
228            .get("messages_sent")
229            .and_then(|v| v.as_u64())
230            .unwrap_or(0);
231        println!(
232            "  {:<15} {:<10} {:<10} {:<10}",
233            name, status_str, recv, sent
234        );
235    }
236    println!();
237    Ok(())
238}
239
240pub async fn cmd_integrations_test(
241    base_url: &str,
242    platform: &str,
243) -> Result<(), Box<dyn std::error::Error>> {
244    let resp = super::http_client()?
245        .post(format!("{base_url}/api/channels/{platform}/test"))
246        .send()
247        .await?;
248    let status = resp.status();
249    let body: serde_json::Value = resp.json().await?;
250    if status.is_success() {
251        let ok = body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
252        let details = body
253            .pointer("/diagnostics/details")
254            .and_then(|v| v.as_str())
255            .unwrap_or("(no details)");
256        let icon = if ok { "✓" } else { "✗" };
257        println!("\n  {icon} {platform}: {details}\n");
258    } else {
259        let err = body
260            .get("error")
261            .and_then(|v| v.as_str())
262            .unwrap_or("unknown error");
263        println!("\n  ✗ {platform}: {err}\n");
264    }
265    Ok(())
266}
267
268pub async fn cmd_channels_dead_letter(
269    base_url: &str,
270    limit: usize,
271    json: bool,
272) -> Result<(), Box<dyn std::error::Error>> {
273    let resp = super::http_client()?
274        .get(format!("{base_url}/api/channels/dead-letter?limit={limit}"))
275        .send()
276        .await?;
277    let body: serde_json::Value = resp.json().await?;
278    if json {
279        println!("{}", serde_json::to_string_pretty(&body)?);
280        return Ok(());
281    }
282    let items = body
283        .get("items")
284        .and_then(|v| v.as_array())
285        .cloned()
286        .unwrap_or_default();
287
288    if items.is_empty() {
289        println!("  No dead-letter deliveries.");
290        return Ok(());
291    }
292
293    println!(
294        "\n  {:<38} {:<12} {:<10} {:<40}",
295        "ID", "Channel", "Attempts", "Last error"
296    );
297    println!("  {}", "─".repeat(108));
298    for item in items {
299        let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("?");
300        let channel = item.get("channel").and_then(|v| v.as_str()).unwrap_or("?");
301        let attempts = item
302            .get("attempts")
303            .and_then(|v| v.as_u64())
304            .unwrap_or_default();
305        let max_attempts = item
306            .get("max_attempts")
307            .and_then(|v| v.as_u64())
308            .unwrap_or_default();
309        let last_error = item
310            .get("last_error")
311            .and_then(|v| v.as_str())
312            .unwrap_or("-");
313        println!(
314            "  {:<38} {:<12} {:<10} {:<40}",
315            truncate_id(id, 35),
316            channel,
317            format!("{attempts}/{max_attempts}"),
318            truncate_id(last_error, 37),
319        );
320    }
321    println!();
322    Ok(())
323}
324
325pub async fn cmd_channels_replay(
326    base_url: &str,
327    id: &str,
328) -> Result<(), Box<dyn std::error::Error>> {
329    let client = super::http_client()?;
330    let resp = client
331        .post(format!("{base_url}/api/channels/dead-letter/{id}/replay"))
332        .send()
333        .await?;
334    if resp.status().is_success() {
335        println!("  Replayed dead-letter item: {id}");
336    } else if resp.status() == reqwest::StatusCode::NOT_FOUND {
337        println!("  Dead-letter item not found: {id}");
338    } else {
339        println!("  Replay failed for {id}: HTTP {}", resp.status());
340    }
341    Ok(())
342}
343
344// ── Integration connect / disconnect guidance ────────────────
345
346pub async fn cmd_integrations_connect(
347    _url: &str,
348    channel: &str,
349) -> Result<(), Box<dyn std::error::Error>> {
350    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
351    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
352    heading(&format!("Connect: {channel}"));
353
354    let snippet = match channel.to_ascii_lowercase().as_str() {
355        "telegram" => r#"[channels.telegram]
356enabled = true
357token_env = "TELEGRAM_BOT_TOKEN"
358# allowed_chat_ids = [123456789]
359# webhook_mode = false"#,
360        "discord" => r#"[channels.discord]
361enabled = true
362token_env = "DISCORD_BOT_TOKEN"
363application_id = "YOUR_APPLICATION_ID"
364# allowed_guild_ids = []"#,
365        "whatsapp" => r#"[channels.whatsapp]
366enabled = true
367token_env = "WHATSAPP_TOKEN"
368phone_number_id = "YOUR_PHONE_NUMBER_ID"
369verify_token = "YOUR_VERIFY_TOKEN"
370# app_secret_env = "WHATSAPP_APP_SECRET""#,
371        "signal" => r#"[channels.signal]
372enabled = true
373phone_number = "+15551234567"
374daemon_url = "http://localhost:8080"
375# allowed_numbers = []"#,
376        "email" => r#"[channels.email]
377enabled = true
378imap_host = "imap.example.com"
379smtp_host = "smtp.example.com"
380username = "bot@example.com"
381password_env = "EMAIL_PASSWORD"
382from_address = "bot@example.com"
383# allowed_senders = []"#,
384        "matrix" => r#"[channels.matrix]
385enabled = true
386homeserver_url = "https://matrix.example.com"
387access_token_env = "MATRIX_ACCESS_TOKEN"
388# allowed_rooms = []
389# auto_join = false"#,
390        other => {
391            eprintln!("    {ERR} Unknown platform: {other}");
392            eprintln!("    {DIM}Available: telegram, discord, whatsapp, signal, email, matrix{RESET}");
393            eprintln!();
394            return Ok(());
395        }
396    };
397
398    eprintln!("    {ACTION} Add the following to your {ACCENT}roboticus.toml{RESET}:\n");
399    eprintln!("{MONO}{snippet}{RESET}\n");
400    eprintln!("    {DETAIL} Then set the referenced environment variable(s) and restart.");
401    eprintln!("    {DIM}After restart, verify with: roboticus integrations test {channel}{RESET}");
402    eprintln!();
403    Ok(())
404}
405
406pub async fn cmd_integrations_disconnect(
407    _url: &str,
408    channel: &str,
409) -> Result<(), Box<dyn std::error::Error>> {
410    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
411    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
412    heading(&format!("Disconnect: {channel}"));
413
414    let section = format!("[channels.{}]", channel.to_ascii_lowercase());
415
416    eprintln!("    {ACTION} To disconnect {ACCENT}{channel}{RESET}, either:");
417    eprintln!();
418    eprintln!("    {DETAIL} 1. Set {MONO}enabled = false{RESET} in the {MONO}{section}{RESET} block");
419    eprintln!("    {DETAIL} 2. Or remove the entire {MONO}{section}{RESET} block from roboticus.toml");
420    eprintln!();
421    eprintln!("    {DIM}Then restart the server for changes to take effect.{RESET}");
422    eprintln!();
423    Ok(())
424}
425
426// ── Mechanic ──────────────────────────────────────────────────
427