semora-cli 0.3.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `semora wait`: block until feedback arrives on a document's channel.
//!
//! Joins `/ws/d/{id}` as an agent, collects events for a short window after
//! the first one (so a burst arrives together), prints them, acks them, and
//! exits 0. Exit 2 on timeout, 3 on auth errors, 4 when the document is missing.

use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;

use anyhow::Context;
use futures_util::{SinkExt, StreamExt};
use semora_proto::{EventKind, EventOut, SUBPROTOCOL, ServerMsg};
use serde_json::{Value, json};
use tokio::time::Instant;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::{self, Message};

use crate::args::{WaitArgs, WaitOpts};
use crate::client::{Client, USER_AGENT};
use crate::{Ctx, docref, exit, output};

type Ws =
    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
type WsSink = futures_util::stream::SplitSink<Ws, Message>;

pub async fn run(ctx: &Ctx, args: WaitArgs) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let id = docref::parse(&args.doc)?;
    wait_for(ctx, &client, &id, &args.opts).await
}

fn ws_url(client: &Client, id: &str, kinds: &[EventKind]) -> String {
    let base = client.origin();
    let base = if let Some(rest) = base.strip_prefix("https://") {
        format!("wss://{rest}")
    } else {
        base.replacen("http://", "ws://", 1)
    };
    let mut url = format!("{base}/ws/d/{id}");
    if !kinds.is_empty() {
        let list: Vec<&str> = kinds.iter().map(|k| k.as_str()).collect();
        url.push_str(&format!("?kinds={}", list.join(",")));
    }
    url
}

async fn connect(client: &Client, id: &str, kinds: &[EventKind]) -> anyhow::Result<Ws> {
    let mut req = ws_url(client, id, kinds).into_client_request()?;
    let h = req.headers_mut();
    h.insert(
        "authorization",
        HeaderValue::from_str(&format!("Bearer {}", client.key()))?,
    );
    h.insert(
        "sec-websocket-protocol",
        HeaderValue::from_static(SUBPROTOCOL),
    );
    h.insert("user-agent", HeaderValue::from_static(USER_AGENT));
    match tokio_tungstenite::connect_async(req).await {
        Ok((ws, _)) => Ok(ws),
        Err(tungstenite::Error::Http(resp)) => {
            let status = resp.status().as_u16();
            let body = resp
                .body()
                .as_ref()
                .map(|b| String::from_utf8_lossy(b).into_owned())
                .unwrap_or_default();
            let message = serde_json::from_str::<semora_proto::ApiErrorBody>(&body)
                .map(|b| b.message)
                .unwrap_or(body);
            Err(match status {
                401 => exit::auth(format!(
                    "{message} (the API key was rejected; run `semora login` or check SEMORA_API_KEY)"
                )),
                403 => exit::auth(message),
                404 => exit::not_found(format!("document {id}: not found")),
                _ => anyhow::anyhow!("the server refused the websocket ({status}): {message}"),
            })
        }
        Err(e) => {
            Err(anyhow::Error::new(e).context(format!("could not reach {}", client.origin())))
        }
    }
}

/// An event plus where its screenshot was saved.
struct Received {
    event: EventOut,
    attachment_path: Option<PathBuf>,
}

impl Received {
    fn to_json(&self) -> Value {
        let mut v = serde_json::to_value(&self.event).expect("events serialize");
        if let Some(p) = &self.attachment_path {
            v["attachment_path"] = json!(p.to_string_lossy());
        }
        v
    }
}

async fn fetch_attachment(client: &Client, dir: &PathBuf, e: &EventOut) -> Option<PathBuf> {
    let url = e.attachment_url.as_ref()?;
    let result: anyhow::Result<PathBuf> = async {
        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
        let bytes = client.download(url).await?;
        let path = dir.join(format!("{}-event-{}.png", e.doc_id, e.id));
        std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?;
        Ok(path)
    }
    .await;
    match result {
        Ok(p) => Some(p),
        Err(err) => {
            output::warn(format!(
                "could not download the screenshot for event {}: {err:#}",
                e.id
            ));
            None
        }
    }
}

pub async fn wait_for(
    ctx: &Ctx,
    client: &Client,
    id: &str,
    opts: &WaitOpts,
) -> anyhow::Result<i32> {
    let kinds = opts
        .kinds
        .as_deref()
        .map(EventKind::parse_list)
        .transpose()
        .map_err(anyhow::Error::msg)?
        .unwrap_or_default();
    let follow = opts.follow;
    // --follow runs until stopped unless a timeout is given explicitly.
    let total = if follow && opts.timeout.is_none() {
        None
    } else {
        Some(Duration::from_secs(
            opts.timeout.unwrap_or(ctx.settings.wait_timeout),
        ))
    };
    let deadline = total.map(|t| Instant::now() + t);
    let collect = Duration::from_secs_f64(opts.collect.clamp(0.0, 300.0));
    let save_dir = opts
        .save_dir
        .clone()
        .unwrap_or_else(|| std::env::temp_dir().join("semora"));

    if !ctx.json && !follow {
        match total {
            Some(t) => eprintln!("Waiting up to {}s for feedback on {id}…", t.as_secs()),
            None => eprintln!("Waiting for feedback on {id}…"),
        }
    }

    let mut collected: Vec<Received> = Vec::new();
    let mut seen: HashSet<i64> = HashSet::new();
    let mut burst_ends: Option<Instant> = None;
    let mut connected_once = false;
    let mut backoff = Duration::from_millis(500);

    loop {
        let ws = match connect(client, id, &kinds).await {
            Ok(ws) => ws,
            Err(e) if connected_once && exit::code_of(&e) == exit::ERROR => {
                // The server went away mid-wait; stored events survive, so reconnect.
                if deadline.is_some_and(|d| Instant::now() + backoff >= d) {
                    return finish_or_timeout(ctx, id, collected, None).await;
                }
                tokio::time::sleep(backoff).await;
                backoff = (backoff * 2).min(Duration::from_secs(10));
                continue;
            }
            Err(e) => return Err(e),
        };
        connected_once = true;
        backoff = Duration::from_millis(500);
        let (mut sink, mut stream) = ws.split();

        loop {
            let until = match (deadline, burst_ends) {
                (Some(d), Some(b)) => Some(d.min(b)),
                (d, b) => d.or(b),
            };
            let next = match until {
                Some(u) => tokio::time::timeout_at(u, stream.next()).await,
                None => Ok(stream.next().await),
            };
            match next {
                Err(_) => {
                    // A timer fired: the burst window closed, or the overall timeout passed.
                    let result = finish_or_timeout(ctx, id, collected, Some(&mut sink)).await;
                    drop(stream);
                    return result;
                }
                Ok(Some(Ok(Message::Text(t)))) => {
                    let msg: ServerMsg = match serde_json::from_str(t.as_str()) {
                        Ok(m) => m,
                        Err(_) => continue,
                    };
                    match msg {
                        ServerMsg::Event(e) => {
                            if !seen.insert(e.id) {
                                continue;
                            }
                            let attachment_path = fetch_attachment(client, &save_dir, &e).await;
                            let r = Received {
                                event: e,
                                attachment_path,
                            };
                            if follow {
                                println!("{}", serde_json::to_string(&r.to_json()).expect("json"));
                                let _ = ack(&mut sink, r.event.id).await;
                            } else {
                                collected.push(r);
                                burst_ends.get_or_insert(Instant::now() + collect);
                            }
                        }
                        ServerMsg::Error { message } => output::warn(format!("server: {message}")),
                        _ => {}
                    }
                }
                Ok(Some(Ok(Message::Close(_)))) | Ok(None) | Ok(Some(Err(_))) => break,
                Ok(Some(Ok(_))) => {}
            }
        }
        // Dropped connection: reconnect unless time is up.
        if deadline.is_some_and(|d| Instant::now() >= d) {
            return finish_or_timeout(ctx, id, collected, None).await;
        }
    }
}

async fn ack(sink: &mut WsSink, id: i64) -> Result<(), tungstenite::Error> {
    sink.send(Message::text(
        json!({ "type": "ack", "id": id }).to_string(),
    ))
    .await
}

/// Prints what was collected and acks it, or reports a timeout.
async fn finish_or_timeout(
    ctx: &Ctx,
    id: &str,
    collected: Vec<Received>,
    sink: Option<&mut WsSink>,
) -> anyhow::Result<i32> {
    if collected.is_empty() {
        if let Some(s) = sink {
            let _ = s.close().await;
        }
        return Err(exit::timeout(format!(
            "no feedback on {id} before the timeout"
        )));
    }
    print_events(ctx, id, &collected);
    if let Some(s) = sink {
        for r in &collected {
            let _ = ack(s, r.event.id).await;
        }
        let _ = s.flush().await;
        let _ = s.close().await;
        // Give the server a moment to record the acks before we exit.
        tokio::time::sleep(Duration::from_millis(150)).await;
    }
    Ok(exit::OK)
}

fn print_events(ctx: &Ctx, id: &str, events: &[Received]) {
    if ctx.json {
        let list: Vec<Value> = events.iter().map(Received::to_json).collect();
        output::json(&json!({ "doc_id": id, "events": list }));
        return;
    }
    let n = events.len();
    let rev = events.last().map(|r| r.event.rev).unwrap_or(0);
    println!(
        "{n} {} on {id} (r{rev}):\n",
        if n == 1 { "event" } else { "events" }
    );
    for r in events {
        print!("{}", render_event(r));
    }
    println!("Reply in the page: semora say {id} \"…\"   Wait again: semora wait {id}");
}

fn render_event(r: &Received) -> String {
    let e = &r.event;
    let p = &e.payload;
    let path = e.path.as_deref().unwrap_or("");
    let mut out = String::new();
    match e.kind {
        EventKind::Comment => {
            let a = &p["anchor"];
            let heading = a["heading"]
                .as_str()
                .map(|h| format!(" — under \"{h}\""))
                .unwrap_or_default();
            out.push_str(&format!("[comment #{}] {path}{heading}\n", e.id));
            if let Some(q) = a["quote"].as_str().filter(|q| !q.is_empty()) {
                let prefix = a["prefix"].as_str().unwrap_or("");
                let suffix = a["suffix"].as_str().unwrap_or("");
                out.push_str(&format!("  on: \"{q}\"\n"));
                if !prefix.is_empty() || !suffix.is_empty() {
                    out.push_str(&format!("  context: …{prefix}[{q}]{suffix}…\n"));
                }
            }
            if let Some(sel) = a["selector"].as_str() {
                out.push_str(&format!("  element: {sel}\n"));
            }
            out.push_str(&indent(p["text"].as_str().unwrap_or("")));
        }
        EventKind::Drawing => {
            out.push_str(&format!("[drawing #{}] {path}\n", e.id));
            if let Some(n) = p["note"].as_str() {
                out.push_str(&indent(n));
            }
            match &r.attachment_path {
                Some(path) => out.push_str(&format!("  screenshot: {}\n", path.display())),
                None => {
                    if let Some(u) = &e.attachment_url {
                        out.push_str(&format!("  screenshot: {u}\n"));
                    }
                }
            }
            out.push_str(&format!(
                "  viewport {}x{}, scrolled to {}\n",
                p["viewport"]["width"], p["viewport"]["height"], p["scroll"]["y"]
            ));
        }
        EventKind::PageEvent => {
            out.push_str(&format!(
                "[page_event #{}] {} {}\n",
                e.id,
                p["name"].as_str().unwrap_or("?"),
                p["data"]
            ));
        }
        EventKind::Message => {
            out.push_str(&format!(
                "[message #{}]{}\n",
                e.id,
                if path.is_empty() {
                    String::new()
                } else {
                    format!(" {path}")
                }
            ));
            out.push_str(&indent(p["text"].as_str().unwrap_or("")));
        }
        EventKind::Say => {}
    }
    out.push('\n');
    out
}

fn indent(text: &str) -> String {
    text.lines().map(|l| format!("  {l}\n")).collect()
}