Skip to main content

netget/client/xmpp/
mod.rs

1//! XMPP (Jabber) client implementation
2pub mod actions;
3
4pub use actions::XmppClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::{mpsc, Mutex};
10use tracing::{debug, error, info, trace, warn};
11
12use crate::client::llm_budget::call_llm_for_client;
13use crate::client::xmpp::actions::{
14    XMPP_CLIENT_CONNECTED_EVENT, XMPP_CLIENT_MESSAGE_RECEIVED_EVENT,
15    XMPP_CLIENT_PRESENCE_RECEIVED_EVENT,
16};
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::Event;
20use crate::protocol::StartupParams;
21use crate::state::app_state::AppState;
22use crate::state::{ClientId, ClientStatus};
23
24use crate::console_error;
25use futures::StreamExt;
26use sasl::common::ChannelBinding;
27use std::time::Duration;
28use tokio_util::sync::CancellationToken;
29use tokio_xmpp::connect::{DnsConfig, ServerConnector, StartTlsServerConnector};
30use tokio_xmpp::jid::Jid;
31use tokio_xmpp::xmlstream::{PendingFeaturesRecv, Timeouts};
32use tokio_xmpp::{Client as XmppClient, Event as XmppEvent};
33use xmpp_parsers::{
34    message::{Lang, Message, MessageType},
35    presence::{Presence, Show as PresenceShow, Type as PresenceType},
36};
37
38/// Connection state for LLM processing
39#[derive(Debug, Clone, PartialEq)]
40enum ConnectionState {
41    Idle,
42    Processing,
43    Accumulating,
44}
45
46/// Per-client data for LLM handling
47struct ClientData {
48    state: ConnectionState,
49    queued_events: Vec<XmppEvent>,
50    memory: String,
51}
52
53/// How long `connect()` waits for the server to accept the session when the
54/// `session_timeout_secs` startup parameter is not given.
55pub const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
56
57/// Largest accepted `session_timeout_secs`.
58pub const MAX_SESSION_TIMEOUT_SECS: u64 = 600;
59
60/// XMPP's client-to-server port (RFC 6120 §14.7), used when `remote_addr` names no port.
61pub const XMPP_CLIENT_PORT: u16 = 5222;
62
63/// How long the event loop lets a stanza write or the orderly stream close run once the
64/// session has been ended. Both wait on a peer that may never answer.
65const STREAM_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
66
67/// tokio-xmpp's STARTTLS connector, refusing to dial once the client has ended.
68///
69/// tokio-xmpp runs its connect-and-login loop in a task it spawns itself, retries every failed
70/// attempt with a backoff (1s doubling to 30s) until a login succeeds, and is not stopped by
71/// dropping the `Client`. Every attempt starts with [`ServerConnector::connect`], so this is the
72/// one place a dial can be refused. Once `ended` is cancelled - the session timed out, an
73/// injected or model-produced `disconnect`, the client stopped, or the event loop gone - an
74/// attempt in progress is dropped (its socket with it) and every later one waits forever
75/// without dialling. Returning an error instead would make the library log a failure and
76/// retry every 30 seconds for the life of the process; waiting leaves one idle task, holding no
77/// socket and no timer.
78///
79/// The cancellation covers the transport (TCP connect, stream header, STARTTLS, TLS). An attempt
80/// that is already past it - in SASL or resource binding - runs to its end; if it then succeeds,
81/// tokio-xmpp finds the client gone and closes that stream itself.
82#[derive(Debug, Clone)]
83struct EndableConnector {
84    inner: StartTlsServerConnector,
85    ended: CancellationToken,
86    client_id: ClientId,
87}
88
89/// What [`ServerConnector::connect`] returns.
90type ConnectResult<S> =
91    std::result::Result<(PendingFeaturesRecv<S>, ChannelBinding), tokio_xmpp::Error>;
92
93impl ServerConnector for EndableConnector {
94    type Stream = <StartTlsServerConnector as ServerConnector>::Stream;
95
96    async fn connect(
97        &self,
98        jid: &Jid,
99        ns: &'static str,
100        timeouts: Timeouts,
101    ) -> ConnectResult<Self::Stream> {
102        tokio::select! {
103            biased;
104            _ = self.ended.cancelled() => {
105                debug!(
106                    "XMPP client {} has ended; refusing tokio-xmpp's connection attempt",
107                    self.client_id
108                );
109                return std::future::pending().await;
110            }
111            result = self.inner.connect(jid, ns, timeouts) => result,
112        }
113    }
114}
115
116/// The socket address the XMPP client connects to, taken from `remote_addr` alone.
117///
118/// Accepts `IP:port`, `[IPv6]:port`, a bare IP, `host:port` or a bare host; a missing port
119/// is [`XMPP_CLIENT_PORT`]. A host name is resolved by the system resolver as an ordinary
120/// address lookup. The JID plays no part: no SRV record is queried and the JID's domain is
121/// never resolved, so the client connects where it was pointed or not at all.
122///
123/// An empty `remote_addr` is refused, and so is one containing `@`, which is an account
124/// rather than an address; that error does not echo the value, since it may hold a password.
125pub async fn resolve_target(remote_addr: &str) -> Result<SocketAddr> {
126    let addr = remote_addr.trim();
127    if addr.is_empty() {
128        anyhow::bail!(
129            "the XMPP client needs the server's address as remote_addr (\"host:port\", e.g. \
130             \"127.0.0.1:5222\"); it does not look a server up from the JID's domain"
131        );
132    }
133    if addr.contains('@') {
134        anyhow::bail!(
135            "remote_addr must be the XMPP server's address (\"host:port\"), not an account: \
136             pass the account as the 'jid' and 'password' startup parameters"
137        );
138    }
139    if let Ok(socket) = addr.parse::<SocketAddr>() {
140        return Ok(socket);
141    }
142    if let Ok(ip) = addr.parse::<std::net::IpAddr>() {
143        return Ok(SocketAddr::new(ip, XMPP_CLIENT_PORT));
144    }
145    let (host, port) = match addr.rsplit_once(':') {
146        Some((host, port)) => {
147            let port: u16 = port
148                .parse()
149                .with_context(|| format!("remote_addr {:?} has an unusable port", addr))?;
150            (host, port)
151        }
152        None => (addr, XMPP_CLIENT_PORT),
153    };
154    if host.is_empty() || host.contains(':') {
155        anyhow::bail!(
156            "remote_addr {:?} is not \"host:port\" (e.g. \"127.0.0.1:5222\")",
157            addr
158        );
159    }
160    tokio::net::lookup_host((host, port))
161        .await
162        .with_context(|| format!("could not resolve XMPP server host {:?}", host))?
163        .next()
164        .with_context(|| format!("XMPP server host {:?} resolved to no address", host))
165}
166
167/// XMPP client that connects to an XMPP/Jabber server
168pub struct XmppClientConnection;
169
170impl XmppClientConnection {
171    /// Connect to the XMPP server at `remote_addr` and run the session until it ends.
172    ///
173    /// Returns only once the server has accepted the session - tokio-xmpp's `Online` event,
174    /// which follows the TCP connect, STARTTLS, SASL authentication and resource binding - or
175    /// with `Err` when that does not happen within the session timeout. The generic startup
176    /// path marks a client `Connected` as soon as this returns `Ok`, so returning earlier
177    /// would report a session that does not exist.
178    pub async fn connect_with_llm_actions(
179        remote_addr: String,
180        llm_client: OllamaClient,
181        app_state: Arc<AppState>,
182        status_tx: mpsc::UnboundedSender<String>,
183        client_id: ClientId,
184        startup_params: Option<StartupParams>,
185    ) -> Result<SocketAddr> {
186        let (jid, password) = Self::credentials(startup_params.as_ref())?;
187        let session_timeout = Self::session_timeout(startup_params.as_ref())?;
188        let target = resolve_target(&remote_addr).await?;
189
190        info!(
191            "XMPP client {} connecting as {} to {}",
192            client_id, jid, target
193        );
194        let _ = status_tx.send(format!(
195            "[CLIENT] XMPP client {} connecting to {} as {}...",
196            client_id, target, jid
197        ));
198
199        // Cancelled when the client ends, by whichever path ends it: the session timeout or a
200        // dropped `connect()` (the guard below), an injected or model-produced `disconnect`
201        // (`end_session`), the stream ending, or the event loop task being aborted or finishing
202        // (its own guard). The event loop closes the stream on it, and the connector refuses
203        // tokio-xmpp's reconnect attempts after it.
204        let ended = CancellationToken::new();
205
206        // `DnsConfig::Addr` is a plain TCP connect to exactly this socket address: no SRV
207        // lookup and no resolution of the JID's domain, which only names the account and the
208        // stream's `to`.
209        let mut xmpp_client = XmppClient::new_with_connector(
210            jid.clone(),
211            password,
212            EndableConnector {
213                inner: StartTlsServerConnector::from(DnsConfig::addr(&target.to_string())),
214                ended: ended.clone(),
215                client_id,
216            },
217            Timeouts::default(),
218        );
219
220        // Store JID in app state
221        app_state
222            .with_client_mut(client_id, |client| {
223                client.set_protocol_field("jid".to_string(), serde_json::json!(jid.to_string()));
224            })
225            .await;
226
227        // Initialize client data
228        let client_data = Arc::new(Mutex::new(ClientData {
229            state: ConnectionState::Idle,
230            queued_events: Vec::new(),
231            memory: String::new(),
232        }));
233
234        let protocol = Arc::new(crate::client::xmpp::actions::XmppClientProtocol::new());
235
236        // Create channel for sending stanzas to the event loop
237        let (stanza_tx, mut stanza_rx) = mpsc::unbounded_channel::<StanzaRequest>();
238        let stanza_tx = Arc::new(stanza_tx);
239
240        // The event loop answers this exactly once: the bound JID on the first `Online`, or
241        // why the stream ended before one.
242        let (session_tx, session_rx) =
243            tokio::sync::oneshot::channel::<std::result::Result<Jid, String>>();
244
245        // Command channel for injected actions (the dashboard's [ send ]). Registered before
246        // the session is up, so [ send ] and `disconnect` are reachable while it is pending,
247        // and before the connected-event LLM call, which a manual `*` rule can park.
248        let command_rx =
249            crate::client::command_support::register_command_channel(&app_state, client_id).await;
250        let cmd_stanza_tx = stanza_tx.clone();
251        let cmd_state = app_state.clone();
252        let cmd_status_tx = status_tx.clone();
253        let cmd_ended = ended.clone();
254        let cmd_task = tokio::spawn(async move {
255            Self::command_loop(
256                command_rx,
257                cmd_stanza_tx,
258                cmd_ended,
259                client_id,
260                cmd_state,
261                cmd_status_tx,
262            )
263            .await;
264        });
265        app_state.register_client_task(client_id, cmd_task).await;
266
267        // Spawn event loop that handles both sending and receiving
268        // Registered with AppState so stop_client can abort this task —
269        // dropping a JoinHandle only detaches it in Tokio.
270        let task_registrar = app_state.clone();
271        let loop_llm = llm_client.clone();
272        let loop_state = app_state.clone();
273        let loop_status_tx = status_tx.clone();
274        let loop_protocol = protocol.clone();
275        let loop_stanza_tx = stanza_tx.clone();
276        let loop_data = client_data.clone();
277        let loop_ended = ended.clone();
278        let task_handle = tokio::spawn(async move {
279            let llm_client = loop_llm;
280            let app_state = loop_state;
281            let status_tx = loop_status_tx;
282            let protocol = loop_protocol;
283            let stanza_tx = loop_stanza_tx;
284            let client_data = loop_data;
285            let ended = loop_ended;
286            // Aborting this task (`remove_client`) drops the guard, which ends the client and
287            // so stops tokio-xmpp dialling, even though the code below never runs.
288            let _end_on_drop = ended.clone().drop_guard();
289            let mut session_tx = Some(session_tx);
290            let end_reason: &'static str = loop {
291                tokio::select! {
292                    // Stanzas first, then the end of the session: actions are queued in the
293                    // order they were produced, so a `send_message` the model put before its
294                    // `disconnect` is written before the stream closes.
295                    biased;
296                    // Handle outgoing stanzas
297                    Some(request) = stanza_rx.recv() => {
298                        // `send_stanza` resolves only once the stanza has been written to the
299                        // XMPP transport, so an injected send can be reported truthfully
300                        // rather than as "handed to a channel". It waits for a session that
301                        // may never come, so once the client has ended it gets
302                        // `STREAM_CLOSE_TIMEOUT` and no more.
303                        let result = tokio::select! {
304                            result = xmpp_client.send_stanza(request.stanza) => result
305                                .map(|_token| ())
306                                .map_err(|e| e.to_string()),
307                            _ = async {
308                                ended.cancelled().await;
309                                tokio::time::sleep(STREAM_CLOSE_TIMEOUT).await;
310                            } => Err(
311                                "the client ended before the stanza could be written".to_string()
312                            ),
313                        };
314                        if let Err(e) = &result {
315                            console_error!(status_tx, "Failed to send XMPP stanza: {}", e);
316                        }
317                        if let Some(ack) = request.ack {
318                            let _ = ack.send(result);
319                        }
320                    }
321                    // Close the stream once the client has ended.
322                    _ = ended.cancelled() => {
323                        info!("XMPP client {} closing the stream on request", client_id);
324                        break "the client was closed";
325                    }
326                    // Handle incoming events. Matched in the handler rather than the pattern: a pattern mismatch only
327                    // disables the branch, and the select would then wait on the others for
328                    // ever with the stream already gone.
329                    next_event = xmpp_client.next() => {
330                let Some(xmpp_event) = next_event else {
331                    info!("XMPP client {} connection closed", client_id);
332                    break "the XMPP stream ended";
333                };
334                trace!("XMPP client {} received event: {:?}", client_id, xmpp_event);
335
336                // `Online` is stream lifecycle, not a stanza: it is acted on here, at once,
337                // rather than queued behind an LLM call.
338                let xmpp_event = match xmpp_event {
339                    XmppEvent::Online { bound_jid, resumed } => {
340                        info!(
341                            "XMPP client {} online as {} (resumed={})",
342                            client_id, bound_jid, resumed
343                        );
344                        let bound = bound_jid.to_string();
345                        app_state
346                            .with_client_mut(client_id, |client| {
347                                client.set_protocol_field(
348                                    "bound_jid".to_string(),
349                                    serde_json::json!(bound),
350                                );
351                            })
352                            .await;
353                        // Neither the first `Online` nor a later one (tokio-xmpp's own
354                        // reconnect to the same address) comes before the server has accepted
355                        // the session, which is what `Connected` means.
356                        app_state
357                            .update_client_status(client_id, ClientStatus::Connected)
358                            .await;
359                        let _ = status_tx.send(format!(
360                            "[CLIENT] XMPP client {} online as {}",
361                            client_id, bound_jid
362                        ));
363                        let _ = status_tx.send("__UPDATE_UI__".to_string());
364                        // The first `Online` releases `connect()`.
365                        if let Some(tx) = session_tx.take() {
366                            let _ = tx.send(Ok(bound_jid));
367                        }
368                        continue;
369                    }
370                    other => other,
371                };
372
373                // Handle event with LLM
374                let mut client_data_lock = client_data.lock().await;
375
376                match client_data_lock.state {
377                    ConnectionState::Idle => {
378                        // Process immediately
379                        client_data_lock.state = ConnectionState::Processing;
380                        drop(client_data_lock);
381
382                        Self::handle_xmpp_event(
383                            xmpp_event,
384                            &llm_client,
385                            &app_state,
386                            client_id,
387                            &client_data,
388                            protocol.clone(),
389                            stanza_tx.clone(),
390                            &status_tx,
391                            &ended,
392                        ).await;
393
394                        // Process queued events
395                        let mut client_data_lock = client_data.lock().await;
396                        let queued = std::mem::take(&mut client_data_lock.queued_events);
397                        client_data_lock.state = ConnectionState::Idle;
398                        drop(client_data_lock);
399
400                        for queued_event in queued {
401                            // A session the model has just ended asks it nothing more.
402                            if ended.is_cancelled() {
403                                break;
404                            }
405                            Self::handle_xmpp_event(
406                                queued_event,
407                                &llm_client,
408                                &app_state,
409                                client_id,
410                                &client_data,
411                                protocol.clone(),
412                                stanza_tx.clone(),
413                                &status_tx,
414                                &ended,
415                            ).await;
416                        }
417                    }
418                    ConnectionState::Processing => {
419                        // Queue event
420                        client_data_lock.queued_events.push(xmpp_event);
421                        client_data_lock.state = ConnectionState::Accumulating;
422                    }
423                    ConnectionState::Accumulating => {
424                        // Continue queuing
425                        client_data_lock.queued_events.push(xmpp_event);
426                    }
427                }
428                    }
429                }
430            };
431
432            // Every exit path lands here. The client has ended whichever way it ended, so
433            // tokio-xmpp may dial no more. Then drop the command handle so the dashboard stops
434            // offering [ send ] on a dead connection, which also ends `command_loop`. Both are
435            // done before the stream shutdown below, which waits on a peer that may be gone.
436            ended.cancel();
437            app_state.remove_client_handle(client_id).await;
438            match session_tx.take() {
439                // The session was never established. `connect()` is still waiting and turns
440                // this into its `Err`, which is what sets the client's status, so the status
441                // is left alone here.
442                Some(tx) => {
443                    info!(
444                        "XMPP client {} stopped before the session was established: {}",
445                        client_id, end_reason
446                    );
447                    let _ = tx.send(Err(end_reason.to_string()));
448                }
449                None => {
450                    info!("XMPP client {} disconnected", client_id);
451                    app_state
452                        .update_client_status(client_id, ClientStatus::Disconnected)
453                        .await;
454                }
455            }
456            // Orderly stream shutdown; harmless if the peer already went away, and bounded
457            // because `close()` waits for a peer that never answers when the stream was never
458            // established.
459            match tokio::time::timeout(STREAM_CLOSE_TIMEOUT, xmpp_client.send_end()).await {
460                Ok(Err(e)) => debug!("XMPP client {} stream close: {}", client_id, e),
461                Err(_) => debug!("XMPP client {} stream close timed out", client_id),
462                Ok(Ok(())) => {}
463            }
464            let _ = status_tx.send(format!("[CLIENT] XMPP client {} disconnected", client_id));
465            let _ = status_tx.send("__UPDATE_UI__".to_string());
466        });
467        task_registrar
468            .register_client_task(client_id, task_handle)
469            .await;
470
471        // Every way out of this function short of a session ends the client, including the
472        // caller dropping this future; the success path disarms it.
473        let end_unless_online = ended.clone().drop_guard();
474
475        // Wait for the server to accept the session.
476        let bound_jid = match tokio::time::timeout(session_timeout, session_rx).await {
477            Ok(Ok(Ok(bound_jid))) => bound_jid,
478            Ok(Ok(Err(reason))) => {
479                return Err(anyhow::anyhow!(
480                    "XMPP client could not establish a session with {}: {}",
481                    target,
482                    reason
483                ));
484            }
485            Ok(Err(_)) => {
486                return Err(anyhow::anyhow!(
487                    "XMPP client's event loop ended before a session with {} was established",
488                    target
489                ));
490            }
491            Err(_) => {
492                ended.cancel();
493                return Err(anyhow::anyhow!(
494                    "XMPP server at {} did not establish a session within {}s (TCP connect, \
495                     STARTTLS, SASL authentication and resource binding all have to succeed; \
496                     tokio-xmpp's reason for a failed attempt is in netget.log)",
497                    target,
498                    session_timeout.as_secs()
499                ));
500            }
501        };
502        let _ = end_unless_online.disarm();
503
504        // The connected-event LLM call runs in its own task, so a parked (manual-rule) or
505        // slow call holds up neither `connect()` nor the event loop that writes stanzas.
506        let connect_llm = llm_client.clone();
507        let connect_state = app_state.clone();
508        let connect_status_tx = status_tx.clone();
509        let connect_protocol = protocol.clone();
510        let connect_stanza_tx = stanza_tx.clone();
511        let connect_data = client_data.clone();
512        let connect_jid = bound_jid.to_string();
513        let connect_ended = ended.clone();
514        let connect_task = tokio::spawn(async move {
515            let Some(instruction) = connect_state.get_instruction_for_client(client_id).await
516            else {
517                return;
518            };
519            let event = Event::new(
520                &XMPP_CLIENT_CONNECTED_EVENT,
521                serde_json::json!({
522                    "jid": connect_jid,
523                }),
524            );
525            let memory = { connect_data.lock().await.memory.clone() };
526            match call_llm_for_client(
527                &connect_llm,
528                &connect_state,
529                client_id.to_string(),
530                &instruction,
531                &memory,
532                Some(&event),
533                connect_protocol.as_ref(),
534                &connect_status_tx,
535            )
536            .await
537            {
538                Ok(ClientLlmResult {
539                    actions,
540                    memory_updates,
541                }) => {
542                    if let Some(mem) = memory_updates {
543                        connect_data.lock().await.memory = mem;
544                    }
545
546                    // Execute initial actions; nothing after a `disconnect` runs.
547                    for action in actions {
548                        if connect_ended.is_cancelled() {
549                            break;
550                        }
551                        Self::execute_action_result(
552                            action,
553                            connect_protocol.clone(),
554                            connect_stanza_tx.clone(),
555                            client_id,
556                            &connect_state,
557                            &connect_status_tx,
558                            &connect_ended,
559                        )
560                        .await;
561                    }
562                }
563                Err(e) => {
564                    error!("LLM error on XMPP connect for client {}: {}", client_id, e);
565                }
566            }
567        });
568        app_state
569            .register_client_task(client_id, connect_task)
570            .await;
571
572        // tokio-xmpp owns the socket and does not expose its local address.
573        Ok("0.0.0.0:0".parse().unwrap())
574    }
575
576    /// The session timeout: the `session_timeout_secs` startup parameter, or
577    /// [`SESSION_TIMEOUT`].
578    fn session_timeout(startup_params: Option<&StartupParams>) -> Result<Duration> {
579        let secs = match startup_params {
580            Some(params) => params.get_optional_u64("session_timeout_secs")?,
581            None => None,
582        };
583        match secs {
584            None => Ok(SESSION_TIMEOUT),
585            Some(secs) if (1..=MAX_SESSION_TIMEOUT_SECS).contains(&secs) => {
586                Ok(Duration::from_secs(secs))
587            }
588            Some(secs) => Err(anyhow::anyhow!(
589                "session_timeout_secs must be between 1 and {}, got {}",
590                MAX_SESSION_TIMEOUT_SECS,
591                secs
592            )),
593        }
594    }
595
596    /// The JID and password to authenticate with, from the `jid` and `password` startup
597    /// parameters.
598    ///
599    /// `remote_addr` is never a source: it is the server's address and nothing else. Both
600    /// parameters are declared required, so the startup path normally refuses their absence
601    /// before `connect()` runs; the check here covers a direct call.
602    fn credentials(startup_params: Option<&StartupParams>) -> Result<(Jid, String)> {
603        // Propagated, never unwrapped: a wrong-typed value must name itself in the client's
604        // Error status, not kill the task that started it.
605        let (jid_str, password) = match startup_params {
606            Some(params) => (
607                params.get_optional_string("jid")?,
608                params.get_optional_string("password")?,
609            ),
610            None => (None, None),
611        };
612        let jid_str = jid_str.context(
613            "the XMPP client needs the 'jid' startup parameter (e.g. \"alice@example.com\")",
614        )?;
615        let password =
616            password.context("the XMPP client needs the 'password' startup parameter")?;
617
618        let jid: Jid = jid_str
619            .parse()
620            .with_context(|| format!("Invalid JID {:?}", jid_str))?;
621        // A client logs in as an account, so the JID needs a local part. tokio-xmpp's login
622        // unwraps it inside a task of its own, where a missing one panics instead of failing.
623        anyhow::ensure!(
624            jid.node().is_some(),
625            "the JID {jid_str:?} has no local part; the XMPP client logs in as an account, \
626             so 'jid' must look like \"alice@example.com\""
627        );
628        Ok((jid, password))
629    }
630
631    /// Handle an XMPP event with LLM
632    async fn handle_xmpp_event(
633        xmpp_event: XmppEvent,
634        llm_client: &OllamaClient,
635        app_state: &Arc<AppState>,
636        client_id: ClientId,
637        client_data: &Arc<Mutex<ClientData>>,
638        protocol: Arc<XmppClientProtocol>,
639        stanza_tx: Arc<mpsc::UnboundedSender<StanzaRequest>>,
640        status_tx: &mpsc::UnboundedSender<String>,
641        ended: &CancellationToken,
642    ) {
643        let event_opt = match xmpp_event {
644            // Acted on by the event loop before an event gets here.
645            XmppEvent::Online { .. } => None,
646            XmppEvent::Disconnected(e) => {
647                warn!("XMPP client {} disconnected: {}", client_id, e);
648                None
649            }
650            XmppEvent::Stanza(stanza) => {
651                // Match on stanza type to avoid clone
652                use tokio_xmpp::Stanza;
653                match stanza {
654                    Stanza::Message(msg) => {
655                        let from = msg.from.as_ref().map(|j| j.to_string()).unwrap_or_default();
656                        let to = msg.to.as_ref().map(|j| j.to_string()).unwrap_or_default();
657                        let body = msg
658                            .bodies
659                            .get(&Lang::default())
660                            .cloned()
661                            .unwrap_or_default();
662                        let msg_type = format!("{:?}", msg.type_);
663
664                        info!(
665                            "XMPP client {} received message from {}: {}",
666                            client_id, from, body
667                        );
668
669                        Some(Event::new(
670                            &XMPP_CLIENT_MESSAGE_RECEIVED_EVENT,
671                            serde_json::json!({
672                                "from": from,
673                                "to": to,
674                                "body": body,
675                                "message_type": msg_type,
676                            }),
677                        ))
678                    }
679                    Stanza::Presence(presence) => {
680                        let from = presence
681                            .from
682                            .as_ref()
683                            .map(|j| j.to_string())
684                            .unwrap_or_default();
685                        let presence_type = format!("{:?}", presence.type_);
686                        let show = presence
687                            .show
688                            .as_ref()
689                            .map(|s| format!("{:?}", s))
690                            .unwrap_or_default();
691                        let status = presence
692                            .statuses
693                            .get(&Lang::default())
694                            .cloned()
695                            .unwrap_or_default();
696
697                        debug!(
698                            "XMPP client {} received presence from {}: {:?}",
699                            client_id, from, presence_type
700                        );
701
702                        Some(Event::new(
703                            &XMPP_CLIENT_PRESENCE_RECEIVED_EVENT,
704                            serde_json::json!({
705                                "from": from,
706                                "presence_type": presence_type,
707                                "show": show,
708                                "status": status,
709                            }),
710                        ))
711                    }
712                    Stanza::Iq(_iq) => {
713                        debug!(
714                            "XMPP client {} received IQ stanza (not yet supported)",
715                            client_id
716                        );
717                        None
718                    }
719                }
720            }
721        };
722
723        if let Some(event) = event_opt {
724            if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
725                match call_llm_for_client(
726                    llm_client,
727                    app_state,
728                    client_id.to_string(),
729                    &instruction,
730                    &client_data.lock().await.memory,
731                    Some(&event),
732                    protocol.as_ref(),
733                    status_tx,
734                )
735                .await
736                {
737                    Ok(ClientLlmResult {
738                        actions,
739                        memory_updates,
740                    }) => {
741                        if let Some(mem) = memory_updates {
742                            client_data.lock().await.memory = mem;
743                        }
744
745                        // Execute actions; nothing after a `disconnect` runs.
746                        for action in actions {
747                            if ended.is_cancelled() {
748                                break;
749                            }
750                            Self::execute_action_result(
751                                action,
752                                protocol.clone(),
753                                stanza_tx.clone(),
754                                client_id,
755                                app_state,
756                                status_tx,
757                                ended,
758                            )
759                            .await;
760                        }
761                    }
762                    Err(e) => {
763                        error!("LLM error for XMPP client {}: {}", client_id, e);
764                    }
765                }
766            }
767        }
768    }
769
770    /// Execute an action and put whatever stanza it produced on the wire.
771    ///
772    /// Thin wrapper over [`Self::apply_action`] for the LLM paths, which have nowhere to
773    /// report an outcome and only log. A `disconnect` ends the session through
774    /// [`Self::end_session`], the same path an injected one takes.
775    #[allow(clippy::too_many_arguments)]
776    async fn execute_action_result(
777        action: serde_json::Value,
778        protocol: Arc<XmppClientProtocol>,
779        stanza_tx: Arc<mpsc::UnboundedSender<StanzaRequest>>,
780        client_id: ClientId,
781        app_state: &AppState,
782        status_tx: &mpsc::UnboundedSender<String>,
783        ended: &CancellationToken,
784    ) {
785        use crate::llm::actions::client_trait::Client;
786
787        match protocol.as_ref().execute_action(action) {
788            Ok(result) => {
789                // The LLM path does not wait for the write: it has no caller to answer, and
790                // waiting would serialise every action behind the event loop's next turn.
791                match Self::apply_action(result, &stanza_tx, client_id, false).await {
792                    Ok(XmppApplied::Disconnect) => {
793                        let _ = status_tx.send(format!(
794                            "[CLIENT] XMPP client {} disconnecting at the model's request",
795                            client_id
796                        ));
797                        Self::end_session(ended, app_state, client_id).await;
798                    }
799                    Ok(applied) => trace!("XMPP client {}: {}", client_id, applied.detail()),
800                    Err(e) => {
801                        console_error!(status_tx, "XMPP client {} action failed: {}", client_id, e)
802                    }
803                }
804            }
805            Err(e) => {
806                error!(
807                    "Action execution error for XMPP client {}: {}",
808                    client_id, e
809                );
810            }
811        }
812    }
813
814    /// Turn one executed action into a stanza and hand it to the event loop.
815    ///
816    /// Shared by the connected-event path, the incoming-stanza path and injected commands, so
817    /// the mapping from `send_message`/`send_presence` onto `xmpp_parsers` stanzas exists
818    /// exactly once. With `wait_for_write` set the caller is told whether the stanza actually
819    /// reached the transport, which is what an injected command needs to answer honestly.
820    async fn apply_action(
821        action_result: crate::llm::actions::client_trait::ClientActionResult,
822        stanza_tx: &Arc<mpsc::UnboundedSender<StanzaRequest>>,
823        client_id: ClientId,
824        wait_for_write: bool,
825    ) -> Result<XmppApplied> {
826        use crate::llm::actions::client_trait::ClientActionResult;
827        use tokio_xmpp::Stanza;
828
829        match action_result {
830            ClientActionResult::Custom { name, data } => match name.as_str() {
831                "send_message" => {
832                    let to = data
833                        .get("to")
834                        .and_then(|v| v.as_str())
835                        .context("send_message result has no 'to'")?;
836                    let body = data
837                        .get("body")
838                        .and_then(|v| v.as_str())
839                        .context("send_message result has no 'body'")?;
840                    let jid: Jid = to.parse().context("Invalid JID")?;
841
842                    let mut message = Message::new(Some(jid));
843                    message.type_ = MessageType::Chat;
844                    message.bodies.insert(Lang::default(), body.to_string());
845
846                    Self::dispatch(
847                        Stanza::Message(message),
848                        stanza_tx,
849                        wait_for_write,
850                        format!("<message/> to {} ({} byte body)", to, body.len()),
851                    )
852                    .await
853                }
854                "send_presence" => {
855                    let show = data.get("show").and_then(|v| v.as_str());
856                    let status = data.get("status").and_then(|v| v.as_str());
857
858                    let mut presence = Presence::new(PresenceType::None);
859                    if let Some(show_str) = show {
860                        presence.show = match show_str {
861                            "away" => Some(PresenceShow::Away),
862                            "chat" => Some(PresenceShow::Chat),
863                            "dnd" => Some(PresenceShow::Dnd),
864                            "xa" => Some(PresenceShow::Xa),
865                            _ => None,
866                        };
867                    }
868                    if let Some(status_str) = status {
869                        presence
870                            .statuses
871                            .insert(Lang::default(), status_str.to_string());
872                    }
873
874                    Self::dispatch(
875                        Stanza::Presence(presence),
876                        stanza_tx,
877                        wait_for_write,
878                        format!("<presence/> show={:?} status={:?}", show, status),
879                    )
880                    .await
881                }
882                other => Err(anyhow::anyhow!(
883                    "XMPP client cannot apply custom result '{}'",
884                    other
885                )),
886            },
887            ClientActionResult::Disconnect => {
888                info!("XMPP client {} disconnecting", client_id);
889                Ok(XmppApplied::Disconnect)
890            }
891            ClientActionResult::WaitForMore => Ok(XmppApplied::NoWire(
892                "waiting for the next stanza; nothing sent".to_string(),
893            )),
894            ClientActionResult::NoAction => {
895                Ok(XmppApplied::NoWire("no_action: nothing sent".to_string()))
896            }
897            ClientActionResult::SendData(_) => Err(anyhow::anyhow!(
898                "XMPP speaks stanzas, not raw bytes; use send_message"
899            )),
900            ClientActionResult::Multiple(_) => Err(anyhow::anyhow!(
901                "XMPP client does not support Multiple action results"
902            )),
903        }
904    }
905
906    /// Hand one stanza to the event loop, optionally waiting until it has been written.
907    async fn dispatch(
908        stanza: tokio_xmpp::Stanza,
909        stanza_tx: &Arc<mpsc::UnboundedSender<StanzaRequest>>,
910        wait_for_write: bool,
911        what: String,
912    ) -> Result<XmppApplied> {
913        if !wait_for_write {
914            stanza_tx
915                .send(StanzaRequest { stanza, ack: None })
916                .map_err(|_| anyhow::anyhow!("XMPP connection is already closed"))?;
917            return Ok(XmppApplied::Queued(format!("{what} queued for the stream")));
918        }
919
920        let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
921        stanza_tx
922            .send(StanzaRequest {
923                stanza,
924                ack: Some(ack_tx),
925            })
926            .map_err(|_| anyhow::anyhow!("XMPP connection is already closed"))?;
927        match ack_rx.await {
928            Ok(Ok(())) => Ok(XmppApplied::Wrote(format!(
929                "{what} written to the XMPP transport"
930            ))),
931            Ok(Err(e)) => Err(anyhow::anyhow!("send_stanza failed: {e}")),
932            Err(_) => Err(anyhow::anyhow!(
933                "the XMPP event loop ended before the stanza could be written"
934            )),
935        }
936    }
937
938    /// End the session on a `disconnect`, whether the model produced it or it was injected.
939    ///
940    /// Cancelling `ended` tells the event loop to close the stream, after which it runs its
941    /// normal exit path (status, handle removal, status_tx), and stops tokio-xmpp dialling
942    /// again. The command handle is dropped here as well, so \[ send \] disappears at once
943    /// rather than after the stream shutdown, and the command loop ends.
944    async fn end_session(ended: &CancellationToken, app_state: &AppState, client_id: ClientId) {
945        info!("XMPP client {} ending its session on disconnect", client_id);
946        ended.cancel();
947        app_state.remove_client_handle(client_id).await;
948    }
949
950    /// Drain injected commands (the dashboard's \[ send \]) until the channel closes - the
951    /// client was removed, or the event loop exited - or an injected `disconnect` ends the
952    /// session.
953    ///
954    /// `tokio_xmpp::Client` is owned by the event loop task and is not clonable, so the
955    /// command loop reaches the stream the way every other producer here does: through the
956    /// stanza channel. What makes the outcome honest is the per-stanza ack - the event loop
957    /// reports back what `send_stanza` returned, and `send_stanza` resolves only once the
958    /// stanza has reached the transport.
959    async fn command_loop(
960        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
961        stanza_tx: Arc<mpsc::UnboundedSender<StanzaRequest>>,
962        ended: CancellationToken,
963        client_id: ClientId,
964        app_state: Arc<AppState>,
965        status_tx: mpsc::UnboundedSender<String>,
966    ) {
967        use crate::llm::actions::client_trait::Client;
968        use crate::llm::actions::protocol_trait::Protocol;
969        use crate::state::client_handles::ClientSendOutcome;
970        use crate::state::AccessLogOwner;
971
972        let protocol = XmppClientProtocol::new();
973
974        while let Some(command) = command_rx.recv().await {
975            let action = command.action.clone();
976            let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
977                Err(e) => Ok(ClientSendOutcome::Rejected {
978                    error: e.to_string(),
979                }),
980                Ok(result) => match Self::apply_action(result, &stanza_tx, client_id, true).await {
981                    // Never `Sent`: the stanza really did reach the transport, but tokio-xmpp
982                    // serialises and writes it internally and reports no byte count, so there
983                    // is no honest number for `bytes_sent`.
984                    Ok(XmppApplied::Wrote(detail)) => Ok(ClientSendOutcome::Executed { detail }),
985                    Ok(XmppApplied::Queued(detail)) => Ok(ClientSendOutcome::Executed { detail }),
986                    Ok(XmppApplied::NoWire(detail)) => Ok(ClientSendOutcome::Executed { detail }),
987                    Ok(XmppApplied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
988                    Err(e) => Err(e),
989                },
990            };
991
992            let outcome_json = match &outcome {
993                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
994                Err(e) => serde_json::json!({"error": e.to_string()}),
995            };
996            app_state
997                .record_access_log(
998                    AccessLogOwner::Client(client_id.as_u32()),
999                    protocol.protocol_name(),
1000                    None,
1001                    "injected_action",
1002                    action,
1003                    vec![outcome_json],
1004                )
1005                .await;
1006
1007            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1008            if let Err(e) = &outcome {
1009                error!("XMPP client {} injected action failed: {}", client_id, e);
1010                let _ = status_tx.send(format!(
1011                    "[WARN] Client {} injected action failed: {}",
1012                    client_id, e
1013                ));
1014            }
1015            let _ = status_tx.send("__UPDATE_UI__".to_string());
1016            crate::client::command_support::reply(command, outcome);
1017
1018            if disconnect {
1019                Self::end_session(&ended, &app_state, client_id).await;
1020                break;
1021            }
1022        }
1023    }
1024}
1025
1026/// One stanza on its way to the event loop, with an optional slot for the write result.
1027///
1028/// The ack is what lets an injected command report the truth: `Client::send_stanza` resolves
1029/// only once the stanza has been written to the transport, and this carries that result back
1030/// to whoever asked for the send.
1031struct StanzaRequest {
1032    stanza: tokio_xmpp::Stanza,
1033    ack: Option<tokio::sync::oneshot::Sender<std::result::Result<(), String>>>,
1034}
1035
1036/// What [`XmppClientConnection::apply_action`] did with one executed action.
1037enum XmppApplied {
1038    /// Written to the XMPP transport (the caller asked to wait for it).
1039    Wrote(String),
1040    /// Handed to the event loop without waiting.
1041    Queued(String),
1042    /// Ran, but nothing goes on the wire.
1043    NoWire(String),
1044    /// The session should end.
1045    Disconnect,
1046}
1047
1048impl XmppApplied {
1049    fn detail(&self) -> &str {
1050        match self {
1051            XmppApplied::Wrote(d) | XmppApplied::Queued(d) | XmppApplied::NoWire(d) => d,
1052            XmppApplied::Disconnect => "disconnect requested",
1053        }
1054    }
1055}