use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use async_channel::{Receiver, Sender};
use crate::wire::{Inbound, Outbound};
#[derive(Clone)]
pub struct CliHub {
inner: Arc<HubInner>,
}
struct HubInner {
inbound: Sender<Inbound>,
sinks: Mutex<Vec<Sender<String>>>,
next_message_id: AtomicI64,
}
impl CliHub {
pub(crate) fn new() -> (Self, Receiver<Inbound>) {
let (inbound_tx, inbound_rx) = async_channel::unbounded();
let hub = Self {
inner: Arc::new(HubInner {
inbound: inbound_tx,
sinks: Mutex::new(Vec::new()),
next_message_id: AtomicI64::new(1),
}),
};
(hub, inbound_rx)
}
pub fn next_message_id(&self) -> i64 {
self.inner.next_message_id.fetch_add(1, Ordering::Relaxed)
}
pub(crate) fn inject(&self, mut event: Inbound) -> i64 {
event.ensure_message_id(|| self.next_message_id());
let id = event.message_id().unwrap_or(0);
if self.inner.inbound.try_send(event).is_err() {
self.emit(Outbound::Error(crate::wire::OutboundError {
message: "dispatcher is gone; event dropped".to_string(),
}));
}
id
}
pub fn subscribe(&self) -> Receiver<String> {
let (tx, rx) = async_channel::unbounded();
self.inner.sinks.lock().expect("sinks poisoned").push(tx);
rx
}
pub fn emit(&self, outbound: Outbound) {
let Ok(line) = serde_json::to_string(&outbound) else {
return;
};
self.emit_line(&line);
}
pub fn emit_line(&self, line: &str) {
let mut sinks = self.inner.sinks.lock().expect("sinks poisoned");
sinks.retain(|sink| sink.try_send(line.to_string()).is_ok());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire::{InboundMessage, OutboundMessage, WireUser};
fn event() -> Inbound {
Inbound::Message(Box::new(InboundMessage {
chat: "c".to_string(),
user: WireUser {
id: "u".to_string(),
name: "n".to_string(),
},
message_id: None,
text: Some("hi".to_string()),
caption: None,
thread_id: None,
reply_to: None,
files: vec![],
sticker: None,
ambient: false,
}))
}
#[test]
fn inject_assigns_message_ids() {
let (hub, rx) = CliHub::new();
let first = hub.inject(event());
let second = hub.inject(event());
assert_eq!((first, second), (1, 2));
assert_eq!(rx.try_recv().unwrap().message_id(), Some(1));
assert_eq!(rx.try_recv().unwrap().message_id(), Some(2));
}
#[test]
fn emit_reaches_subscribers_as_jsonl() {
let (hub, _rx) = CliHub::new();
let sink = hub.subscribe();
hub.emit(Outbound::Message(OutboundMessage {
chat: "c".to_string(),
message_id: 1,
text: "hello".to_string(),
buttons: vec![],
reply_to: None,
thread_id: None,
extras: None,
}));
let line = sink.try_recv().unwrap();
let parsed: Outbound = serde_json::from_str(&line).unwrap();
assert!(matches!(parsed, Outbound::Message(_)));
}
#[test]
fn dead_sinks_are_pruned() {
let (hub, _rx) = CliHub::new();
let sink = hub.subscribe();
drop(sink);
hub.emit(Outbound::Ack(crate::wire::OutboundAck { message_id: None }));
assert!(hub.inner.sinks.lock().unwrap().is_empty());
}
}