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::resolve(&client, &args.doc).await?;
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())))
}
}
}
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;
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 => {
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(_) => {
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(_))) => {}
}
}
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
}
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;
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 from_tag(e: &EventOut) -> String {
match e.from.as_ref().map(|p| output::clean_name(&p.name)) {
Some(name) if !name.is_empty() => format!(" from {name}"),
_ => String::new(),
}
}
fn render_event(r: &Received) -> String {
let e = &r.event;
let from = from_tag(e);
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 #{}{from}] {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 #{}{from}] {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 #{}{from}] {} {}\n",
e.id,
p["name"].as_str().unwrap_or("?"),
p["data"]
));
}
EventKind::Message => {
out.push_str(&format!(
"[message #{}{from}]{}\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()
}
#[cfg(test)]
mod tests {
use super::*;
fn received(kind: &str, from: Option<&str>) -> Received {
let mut v = json!({
"id": 12, "doc_id": "k3F9xQ2mLp", "kind": kind, "rev": 1, "path": "plan.md",
"payload": {"text": "Hi", "name": "approve", "data": {"option": "B"},
"anchor": {"heading": "Intro"}, "note": "n",
"viewport": {"width": 1, "height": 2}, "scroll": {"y": 3}},
"created_at": "2026-10-01T00:00:00Z"
});
if let Some(name) = from {
v["from"] = json!({"id": 7, "name": name});
}
Received {
event: serde_json::from_value(v).unwrap(),
attachment_path: None,
}
}
#[test]
fn headers_say_who_sent_each_kind() {
for (kind, head) in [
("comment", "[comment #12 from Ada]"),
("drawing", "[drawing #12 from Ada]"),
("message", "[message #12 from Ada]"),
("page_event", "[page_event #12 from Ada]"),
] {
let text = render_event(&received(kind, Some("Ada")));
assert!(text.starts_with(head), "{text}");
}
}
#[test]
fn no_sender_prints_the_old_header() {
let text = render_event(&received("message", None));
assert!(text.starts_with("[message #12] plan.md"), "{text}");
let text = render_event(&received("message", Some(" ")));
assert!(
text.starts_with("[message #12]"),
"an empty name is left out: {text}"
);
}
#[test]
fn a_name_cannot_end_the_header_early() {
let text = render_event(&received("message", Some("Ada] [message #99 from Bo")));
let first = text.lines().next().unwrap();
assert_eq!(
first,
"[message #12 from Ada) (message #99 from Bo] plan.md"
);
}
#[test]
fn names_with_control_characters_print_on_one_line() {
let text = render_event(&received("message", Some("Ad\nBcc: x\u{7}a")));
let first = text.lines().next().unwrap();
assert_eq!(first, "[message #12 from AdBcc: xa] plan.md");
let long = "n".repeat(200);
let text = render_event(&received("message", Some(&long)));
assert!(text.lines().next().unwrap().len() < 120, "{text}");
}
}