1pub 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#[derive(Debug, Clone, PartialEq)]
40enum ConnectionState {
41 Idle,
42 Processing,
43 Accumulating,
44}
45
46struct ClientData {
48 state: ConnectionState,
49 queued_events: Vec<XmppEvent>,
50 memory: String,
51}
52
53pub const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
56
57pub const MAX_SESSION_TIMEOUT_SECS: u64 = 600;
59
60pub const XMPP_CLIENT_PORT: u16 = 5222;
62
63const STREAM_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
66
67#[derive(Debug, Clone)]
83struct EndableConnector {
84 inner: StartTlsServerConnector,
85 ended: CancellationToken,
86 client_id: ClientId,
87}
88
89type 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
116pub 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
167pub struct XmppClientConnection;
169
170impl XmppClientConnection {
171 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 let ended = CancellationToken::new();
205
206 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 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 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 let (stanza_tx, mut stanza_rx) = mpsc::unbounded_channel::<StanzaRequest>();
238 let stanza_tx = Arc::new(stanza_tx);
239
240 let (session_tx, session_rx) =
243 tokio::sync::oneshot::channel::<std::result::Result<Jid, String>>();
244
245 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 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 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 biased;
296 Some(request) = stanza_rx.recv() => {
298 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 _ = ended.cancelled() => {
323 info!("XMPP client {} closing the stream on request", client_id);
324 break "the client was closed";
325 }
326 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 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 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 if let Some(tx) = session_tx.take() {
366 let _ = tx.send(Ok(bound_jid));
367 }
368 continue;
369 }
370 other => other,
371 };
372
373 let mut client_data_lock = client_data.lock().await;
375
376 match client_data_lock.state {
377 ConnectionState::Idle => {
378 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 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 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 client_data_lock.queued_events.push(xmpp_event);
421 client_data_lock.state = ConnectionState::Accumulating;
422 }
423 ConnectionState::Accumulating => {
424 client_data_lock.queued_events.push(xmpp_event);
426 }
427 }
428 }
429 }
430 };
431
432 ended.cancel();
437 app_state.remove_client_handle(client_id).await;
438 match session_tx.take() {
439 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 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 let end_unless_online = ended.clone().drop_guard();
474
475 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 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 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 Ok("0.0.0.0:0".parse().unwrap())
574 }
575
576 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 fn credentials(startup_params: Option<&StartupParams>) -> Result<(Jid, String)> {
603 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 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 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 XmppEvent::Online { .. } => None,
646 XmppEvent::Disconnected(e) => {
647 warn!("XMPP client {} disconnected: {}", client_id, e);
648 None
649 }
650 XmppEvent::Stanza(stanza) => {
651 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 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 #[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 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 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 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 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 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 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
1026struct StanzaRequest {
1032 stanza: tokio_xmpp::Stanza,
1033 ack: Option<tokio::sync::oneshot::Sender<std::result::Result<(), String>>>,
1034}
1035
1036enum XmppApplied {
1038 Wrote(String),
1040 Queued(String),
1042 NoWire(String),
1044 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}