pub mod actions;
pub use actions::XmppClientProtocol;
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, trace, warn};
use crate::client::llm_budget::call_llm_for_client;
use crate::client::xmpp::actions::{
XMPP_CLIENT_CONNECTED_EVENT, XMPP_CLIENT_MESSAGE_RECEIVED_EVENT,
XMPP_CLIENT_PRESENCE_RECEIVED_EVENT,
};
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::Event;
use crate::protocol::StartupParams;
use crate::state::app_state::AppState;
use crate::state::{ClientId, ClientStatus};
use crate::console_error;
use futures::StreamExt;
use sasl::common::ChannelBinding;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use tokio_xmpp::connect::{DnsConfig, ServerConnector, StartTlsServerConnector};
use tokio_xmpp::jid::Jid;
use tokio_xmpp::xmlstream::{PendingFeaturesRecv, Timeouts};
use tokio_xmpp::{Client as XmppClient, Event as XmppEvent};
use xmpp_parsers::{
message::{Lang, Message, MessageType},
presence::{Presence, Show as PresenceShow, Type as PresenceType},
};
#[derive(Debug, Clone, PartialEq)]
enum ConnectionState {
Idle,
Processing,
Accumulating,
}
struct ClientData {
state: ConnectionState,
queued_events: Vec<XmppEvent>,
memory: String,
}
pub const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
pub const MAX_SESSION_TIMEOUT_SECS: u64 = 600;
pub const XMPP_CLIENT_PORT: u16 = 5222;
const STREAM_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
struct EndableConnector {
inner: StartTlsServerConnector,
ended: CancellationToken,
client_id: ClientId,
}
type ConnectResult<S> =
std::result::Result<(PendingFeaturesRecv<S>, ChannelBinding), tokio_xmpp::Error>;
impl ServerConnector for EndableConnector {
type Stream = <StartTlsServerConnector as ServerConnector>::Stream;
async fn connect(
&self,
jid: &Jid,
ns: &'static str,
timeouts: Timeouts,
) -> ConnectResult<Self::Stream> {
tokio::select! {
biased;
_ = self.ended.cancelled() => {
debug!(
"XMPP client {} has ended; refusing tokio-xmpp's connection attempt",
self.client_id
);
return std::future::pending().await;
}
result = self.inner.connect(jid, ns, timeouts) => result,
}
}
}
pub async fn resolve_target(remote_addr: &str) -> Result<SocketAddr> {
let addr = remote_addr.trim();
if addr.is_empty() {
anyhow::bail!(
"the XMPP client needs the server's address as remote_addr (\"host:port\", e.g. \
\"127.0.0.1:5222\"); it does not look a server up from the JID's domain"
);
}
if addr.contains('@') {
anyhow::bail!(
"remote_addr must be the XMPP server's address (\"host:port\"), not an account: \
pass the account as the 'jid' and 'password' startup parameters"
);
}
if let Ok(socket) = addr.parse::<SocketAddr>() {
return Ok(socket);
}
if let Ok(ip) = addr.parse::<std::net::IpAddr>() {
return Ok(SocketAddr::new(ip, XMPP_CLIENT_PORT));
}
let (host, port) = match addr.rsplit_once(':') {
Some((host, port)) => {
let port: u16 = port
.parse()
.with_context(|| format!("remote_addr {:?} has an unusable port", addr))?;
(host, port)
}
None => (addr, XMPP_CLIENT_PORT),
};
if host.is_empty() || host.contains(':') {
anyhow::bail!(
"remote_addr {:?} is not \"host:port\" (e.g. \"127.0.0.1:5222\")",
addr
);
}
tokio::net::lookup_host((host, port))
.await
.with_context(|| format!("could not resolve XMPP server host {:?}", host))?
.next()
.with_context(|| format!("XMPP server host {:?} resolved to no address", host))
}
pub struct XmppClientConnection;
impl XmppClientConnection {
pub async fn connect_with_llm_actions(
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
startup_params: Option<StartupParams>,
) -> Result<SocketAddr> {
let (jid, password) = Self::credentials(startup_params.as_ref())?;
let session_timeout = Self::session_timeout(startup_params.as_ref())?;
let target = resolve_target(&remote_addr).await?;
info!(
"XMPP client {} connecting as {} to {}",
client_id, jid, target
);
let _ = status_tx.send(format!(
"[CLIENT] XMPP client {} connecting to {} as {}...",
client_id, target, jid
));
let ended = CancellationToken::new();
let mut xmpp_client = XmppClient::new_with_connector(
jid.clone(),
password,
EndableConnector {
inner: StartTlsServerConnector::from(DnsConfig::addr(&target.to_string())),
ended: ended.clone(),
client_id,
},
Timeouts::default(),
);
app_state
.with_client_mut(client_id, |client| {
client.set_protocol_field("jid".to_string(), serde_json::json!(jid.to_string()));
})
.await;
let client_data = Arc::new(Mutex::new(ClientData {
state: ConnectionState::Idle,
queued_events: Vec::new(),
memory: String::new(),
}));
let protocol = Arc::new(crate::client::xmpp::actions::XmppClientProtocol::new());
let (stanza_tx, mut stanza_rx) = mpsc::unbounded_channel::<StanzaRequest>();
let stanza_tx = Arc::new(stanza_tx);
let (session_tx, session_rx) =
tokio::sync::oneshot::channel::<std::result::Result<Jid, String>>();
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let cmd_stanza_tx = stanza_tx.clone();
let cmd_state = app_state.clone();
let cmd_status_tx = status_tx.clone();
let cmd_ended = ended.clone();
let cmd_task = tokio::spawn(async move {
Self::command_loop(
command_rx,
cmd_stanza_tx,
cmd_ended,
client_id,
cmd_state,
cmd_status_tx,
)
.await;
});
app_state.register_client_task(client_id, cmd_task).await;
let task_registrar = app_state.clone();
let loop_llm = llm_client.clone();
let loop_state = app_state.clone();
let loop_status_tx = status_tx.clone();
let loop_protocol = protocol.clone();
let loop_stanza_tx = stanza_tx.clone();
let loop_data = client_data.clone();
let loop_ended = ended.clone();
let task_handle = tokio::spawn(async move {
let llm_client = loop_llm;
let app_state = loop_state;
let status_tx = loop_status_tx;
let protocol = loop_protocol;
let stanza_tx = loop_stanza_tx;
let client_data = loop_data;
let ended = loop_ended;
let _end_on_drop = ended.clone().drop_guard();
let mut session_tx = Some(session_tx);
let end_reason: &'static str = loop {
tokio::select! {
biased;
Some(request) = stanza_rx.recv() => {
let result = tokio::select! {
result = xmpp_client.send_stanza(request.stanza) => result
.map(|_token| ())
.map_err(|e| e.to_string()),
_ = async {
ended.cancelled().await;
tokio::time::sleep(STREAM_CLOSE_TIMEOUT).await;
} => Err(
"the client ended before the stanza could be written".to_string()
),
};
if let Err(e) = &result {
console_error!(status_tx, "Failed to send XMPP stanza: {}", e);
}
if let Some(ack) = request.ack {
let _ = ack.send(result);
}
}
_ = ended.cancelled() => {
info!("XMPP client {} closing the stream on request", client_id);
break "the client was closed";
}
next_event = xmpp_client.next() => {
let Some(xmpp_event) = next_event else {
info!("XMPP client {} connection closed", client_id);
break "the XMPP stream ended";
};
trace!("XMPP client {} received event: {:?}", client_id, xmpp_event);
let xmpp_event = match xmpp_event {
XmppEvent::Online { bound_jid, resumed } => {
info!(
"XMPP client {} online as {} (resumed={})",
client_id, bound_jid, resumed
);
let bound = bound_jid.to_string();
app_state
.with_client_mut(client_id, |client| {
client.set_protocol_field(
"bound_jid".to_string(),
serde_json::json!(bound),
);
})
.await;
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] XMPP client {} online as {}",
client_id, bound_jid
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
if let Some(tx) = session_tx.take() {
let _ = tx.send(Ok(bound_jid));
}
continue;
}
other => other,
};
let mut client_data_lock = client_data.lock().await;
match client_data_lock.state {
ConnectionState::Idle => {
client_data_lock.state = ConnectionState::Processing;
drop(client_data_lock);
Self::handle_xmpp_event(
xmpp_event,
&llm_client,
&app_state,
client_id,
&client_data,
protocol.clone(),
stanza_tx.clone(),
&status_tx,
&ended,
).await;
let mut client_data_lock = client_data.lock().await;
let queued = std::mem::take(&mut client_data_lock.queued_events);
client_data_lock.state = ConnectionState::Idle;
drop(client_data_lock);
for queued_event in queued {
if ended.is_cancelled() {
break;
}
Self::handle_xmpp_event(
queued_event,
&llm_client,
&app_state,
client_id,
&client_data,
protocol.clone(),
stanza_tx.clone(),
&status_tx,
&ended,
).await;
}
}
ConnectionState::Processing => {
client_data_lock.queued_events.push(xmpp_event);
client_data_lock.state = ConnectionState::Accumulating;
}
ConnectionState::Accumulating => {
client_data_lock.queued_events.push(xmpp_event);
}
}
}
}
};
ended.cancel();
app_state.remove_client_handle(client_id).await;
match session_tx.take() {
Some(tx) => {
info!(
"XMPP client {} stopped before the session was established: {}",
client_id, end_reason
);
let _ = tx.send(Err(end_reason.to_string()));
}
None => {
info!("XMPP client {} disconnected", client_id);
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
}
}
match tokio::time::timeout(STREAM_CLOSE_TIMEOUT, xmpp_client.send_end()).await {
Ok(Err(e)) => debug!("XMPP client {} stream close: {}", client_id, e),
Err(_) => debug!("XMPP client {} stream close timed out", client_id),
Ok(Ok(())) => {}
}
let _ = status_tx.send(format!("[CLIENT] XMPP client {} disconnected", client_id));
let _ = status_tx.send("__UPDATE_UI__".to_string());
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
let end_unless_online = ended.clone().drop_guard();
let bound_jid = match tokio::time::timeout(session_timeout, session_rx).await {
Ok(Ok(Ok(bound_jid))) => bound_jid,
Ok(Ok(Err(reason))) => {
return Err(anyhow::anyhow!(
"XMPP client could not establish a session with {}: {}",
target,
reason
));
}
Ok(Err(_)) => {
return Err(anyhow::anyhow!(
"XMPP client's event loop ended before a session with {} was established",
target
));
}
Err(_) => {
ended.cancel();
return Err(anyhow::anyhow!(
"XMPP server at {} did not establish a session within {}s (TCP connect, \
STARTTLS, SASL authentication and resource binding all have to succeed; \
tokio-xmpp's reason for a failed attempt is in netget.log)",
target,
session_timeout.as_secs()
));
}
};
let _ = end_unless_online.disarm();
let connect_llm = llm_client.clone();
let connect_state = app_state.clone();
let connect_status_tx = status_tx.clone();
let connect_protocol = protocol.clone();
let connect_stanza_tx = stanza_tx.clone();
let connect_data = client_data.clone();
let connect_jid = bound_jid.to_string();
let connect_ended = ended.clone();
let connect_task = tokio::spawn(async move {
let Some(instruction) = connect_state.get_instruction_for_client(client_id).await
else {
return;
};
let event = Event::new(
&XMPP_CLIENT_CONNECTED_EVENT,
serde_json::json!({
"jid": connect_jid,
}),
);
let memory = { connect_data.lock().await.memory.clone() };
match call_llm_for_client(
&connect_llm,
&connect_state,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
connect_protocol.as_ref(),
&connect_status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
connect_data.lock().await.memory = mem;
}
for action in actions {
if connect_ended.is_cancelled() {
break;
}
Self::execute_action_result(
action,
connect_protocol.clone(),
connect_stanza_tx.clone(),
client_id,
&connect_state,
&connect_status_tx,
&connect_ended,
)
.await;
}
}
Err(e) => {
error!("LLM error on XMPP connect for client {}: {}", client_id, e);
}
}
});
app_state
.register_client_task(client_id, connect_task)
.await;
Ok("0.0.0.0:0".parse().unwrap())
}
fn session_timeout(startup_params: Option<&StartupParams>) -> Result<Duration> {
let secs = match startup_params {
Some(params) => params.get_optional_u64("session_timeout_secs")?,
None => None,
};
match secs {
None => Ok(SESSION_TIMEOUT),
Some(secs) if (1..=MAX_SESSION_TIMEOUT_SECS).contains(&secs) => {
Ok(Duration::from_secs(secs))
}
Some(secs) => Err(anyhow::anyhow!(
"session_timeout_secs must be between 1 and {}, got {}",
MAX_SESSION_TIMEOUT_SECS,
secs
)),
}
}
fn credentials(startup_params: Option<&StartupParams>) -> Result<(Jid, String)> {
let (jid_str, password) = match startup_params {
Some(params) => (
params.get_optional_string("jid")?,
params.get_optional_string("password")?,
),
None => (None, None),
};
let jid_str = jid_str.context(
"the XMPP client needs the 'jid' startup parameter (e.g. \"alice@example.com\")",
)?;
let password =
password.context("the XMPP client needs the 'password' startup parameter")?;
let jid: Jid = jid_str
.parse()
.with_context(|| format!("Invalid JID {:?}", jid_str))?;
anyhow::ensure!(
jid.node().is_some(),
"the JID {jid_str:?} has no local part; the XMPP client logs in as an account, \
so 'jid' must look like \"alice@example.com\""
);
Ok((jid, password))
}
async fn handle_xmpp_event(
xmpp_event: XmppEvent,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
client_id: ClientId,
client_data: &Arc<Mutex<ClientData>>,
protocol: Arc<XmppClientProtocol>,
stanza_tx: Arc<mpsc::UnboundedSender<StanzaRequest>>,
status_tx: &mpsc::UnboundedSender<String>,
ended: &CancellationToken,
) {
let event_opt = match xmpp_event {
XmppEvent::Online { .. } => None,
XmppEvent::Disconnected(e) => {
warn!("XMPP client {} disconnected: {}", client_id, e);
None
}
XmppEvent::Stanza(stanza) => {
use tokio_xmpp::Stanza;
match stanza {
Stanza::Message(msg) => {
let from = msg.from.as_ref().map(|j| j.to_string()).unwrap_or_default();
let to = msg.to.as_ref().map(|j| j.to_string()).unwrap_or_default();
let body = msg
.bodies
.get(&Lang::default())
.cloned()
.unwrap_or_default();
let msg_type = format!("{:?}", msg.type_);
info!(
"XMPP client {} received message from {}: {}",
client_id, from, body
);
Some(Event::new(
&XMPP_CLIENT_MESSAGE_RECEIVED_EVENT,
serde_json::json!({
"from": from,
"to": to,
"body": body,
"message_type": msg_type,
}),
))
}
Stanza::Presence(presence) => {
let from = presence
.from
.as_ref()
.map(|j| j.to_string())
.unwrap_or_default();
let presence_type = format!("{:?}", presence.type_);
let show = presence
.show
.as_ref()
.map(|s| format!("{:?}", s))
.unwrap_or_default();
let status = presence
.statuses
.get(&Lang::default())
.cloned()
.unwrap_or_default();
debug!(
"XMPP client {} received presence from {}: {:?}",
client_id, from, presence_type
);
Some(Event::new(
&XMPP_CLIENT_PRESENCE_RECEIVED_EVENT,
serde_json::json!({
"from": from,
"presence_type": presence_type,
"show": show,
"status": status,
}),
))
}
Stanza::Iq(_iq) => {
debug!(
"XMPP client {} received IQ stanza (not yet supported)",
client_id
);
None
}
}
}
};
if let Some(event) = event_opt {
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
match call_llm_for_client(
llm_client,
app_state,
client_id.to_string(),
&instruction,
&client_data.lock().await.memory,
Some(&event),
protocol.as_ref(),
status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
client_data.lock().await.memory = mem;
}
for action in actions {
if ended.is_cancelled() {
break;
}
Self::execute_action_result(
action,
protocol.clone(),
stanza_tx.clone(),
client_id,
app_state,
status_tx,
ended,
)
.await;
}
}
Err(e) => {
error!("LLM error for XMPP client {}: {}", client_id, e);
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_action_result(
action: serde_json::Value,
protocol: Arc<XmppClientProtocol>,
stanza_tx: Arc<mpsc::UnboundedSender<StanzaRequest>>,
client_id: ClientId,
app_state: &AppState,
status_tx: &mpsc::UnboundedSender<String>,
ended: &CancellationToken,
) {
use crate::llm::actions::client_trait::Client;
match protocol.as_ref().execute_action(action) {
Ok(result) => {
match Self::apply_action(result, &stanza_tx, client_id, false).await {
Ok(XmppApplied::Disconnect) => {
let _ = status_tx.send(format!(
"[CLIENT] XMPP client {} disconnecting at the model's request",
client_id
));
Self::end_session(ended, app_state, client_id).await;
}
Ok(applied) => trace!("XMPP client {}: {}", client_id, applied.detail()),
Err(e) => {
console_error!(status_tx, "XMPP client {} action failed: {}", client_id, e)
}
}
}
Err(e) => {
error!(
"Action execution error for XMPP client {}: {}",
client_id, e
);
}
}
}
async fn apply_action(
action_result: crate::llm::actions::client_trait::ClientActionResult,
stanza_tx: &Arc<mpsc::UnboundedSender<StanzaRequest>>,
client_id: ClientId,
wait_for_write: bool,
) -> Result<XmppApplied> {
use crate::llm::actions::client_trait::ClientActionResult;
use tokio_xmpp::Stanza;
match action_result {
ClientActionResult::Custom { name, data } => match name.as_str() {
"send_message" => {
let to = data
.get("to")
.and_then(|v| v.as_str())
.context("send_message result has no 'to'")?;
let body = data
.get("body")
.and_then(|v| v.as_str())
.context("send_message result has no 'body'")?;
let jid: Jid = to.parse().context("Invalid JID")?;
let mut message = Message::new(Some(jid));
message.type_ = MessageType::Chat;
message.bodies.insert(Lang::default(), body.to_string());
Self::dispatch(
Stanza::Message(message),
stanza_tx,
wait_for_write,
format!("<message/> to {} ({} byte body)", to, body.len()),
)
.await
}
"send_presence" => {
let show = data.get("show").and_then(|v| v.as_str());
let status = data.get("status").and_then(|v| v.as_str());
let mut presence = Presence::new(PresenceType::None);
if let Some(show_str) = show {
presence.show = match show_str {
"away" => Some(PresenceShow::Away),
"chat" => Some(PresenceShow::Chat),
"dnd" => Some(PresenceShow::Dnd),
"xa" => Some(PresenceShow::Xa),
_ => None,
};
}
if let Some(status_str) = status {
presence
.statuses
.insert(Lang::default(), status_str.to_string());
}
Self::dispatch(
Stanza::Presence(presence),
stanza_tx,
wait_for_write,
format!("<presence/> show={:?} status={:?}", show, status),
)
.await
}
other => Err(anyhow::anyhow!(
"XMPP client cannot apply custom result '{}'",
other
)),
},
ClientActionResult::Disconnect => {
info!("XMPP client {} disconnecting", client_id);
Ok(XmppApplied::Disconnect)
}
ClientActionResult::WaitForMore => Ok(XmppApplied::NoWire(
"waiting for the next stanza; nothing sent".to_string(),
)),
ClientActionResult::NoAction => {
Ok(XmppApplied::NoWire("no_action: nothing sent".to_string()))
}
ClientActionResult::SendData(_) => Err(anyhow::anyhow!(
"XMPP speaks stanzas, not raw bytes; use send_message"
)),
ClientActionResult::Multiple(_) => Err(anyhow::anyhow!(
"XMPP client does not support Multiple action results"
)),
}
}
async fn dispatch(
stanza: tokio_xmpp::Stanza,
stanza_tx: &Arc<mpsc::UnboundedSender<StanzaRequest>>,
wait_for_write: bool,
what: String,
) -> Result<XmppApplied> {
if !wait_for_write {
stanza_tx
.send(StanzaRequest { stanza, ack: None })
.map_err(|_| anyhow::anyhow!("XMPP connection is already closed"))?;
return Ok(XmppApplied::Queued(format!("{what} queued for the stream")));
}
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
stanza_tx
.send(StanzaRequest {
stanza,
ack: Some(ack_tx),
})
.map_err(|_| anyhow::anyhow!("XMPP connection is already closed"))?;
match ack_rx.await {
Ok(Ok(())) => Ok(XmppApplied::Wrote(format!(
"{what} written to the XMPP transport"
))),
Ok(Err(e)) => Err(anyhow::anyhow!("send_stanza failed: {e}")),
Err(_) => Err(anyhow::anyhow!(
"the XMPP event loop ended before the stanza could be written"
)),
}
}
async fn end_session(ended: &CancellationToken, app_state: &AppState, client_id: ClientId) {
info!("XMPP client {} ending its session on disconnect", client_id);
ended.cancel();
app_state.remove_client_handle(client_id).await;
}
async fn command_loop(
mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
stanza_tx: Arc<mpsc::UnboundedSender<StanzaRequest>>,
ended: CancellationToken,
client_id: ClientId,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
) {
use crate::llm::actions::client_trait::Client;
use crate::llm::actions::protocol_trait::Protocol;
use crate::state::client_handles::ClientSendOutcome;
use crate::state::AccessLogOwner;
let protocol = XmppClientProtocol::new();
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(result) => match Self::apply_action(result, &stanza_tx, client_id, true).await {
Ok(XmppApplied::Wrote(detail)) => Ok(ClientSendOutcome::Executed { detail }),
Ok(XmppApplied::Queued(detail)) => Ok(ClientSendOutcome::Executed { detail }),
Ok(XmppApplied::NoWire(detail)) => Ok(ClientSendOutcome::Executed { detail }),
Ok(XmppApplied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
Err(e) => Err(e),
},
};
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({"error": e.to_string()}),
};
app_state
.record_access_log(
AccessLogOwner::Client(client_id.as_u32()),
protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
if let Err(e) = &outcome {
error!("XMPP client {} injected action failed: {}", client_id, e);
let _ = status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
client_id, e
));
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
Self::end_session(&ended, &app_state, client_id).await;
break;
}
}
}
}
struct StanzaRequest {
stanza: tokio_xmpp::Stanza,
ack: Option<tokio::sync::oneshot::Sender<std::result::Result<(), String>>>,
}
enum XmppApplied {
Wrote(String),
Queued(String),
NoWire(String),
Disconnect,
}
impl XmppApplied {
fn detail(&self) -> &str {
match self {
XmppApplied::Wrote(d) | XmppApplied::Queued(d) | XmppApplied::NoWire(d) => d,
XmppApplied::Disconnect => "disconnect requested",
}
}
}