1pub mod actions;
4
5pub use actions::IppClientProtocol;
6
7use anyhow::{Context, Result};
8use ipp::prelude::*;
9use std::net::SocketAddr;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tracing::{error, info};
13
14use crate::client::ipp::actions::IPP_CLIENT_CONNECTED_EVENT;
15use crate::client::llm_budget::call_llm_for_client;
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::logging::emit::Log;
20use crate::protocol::Event;
21use crate::state::app_state::AppState;
22use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
23use crate::state::{AccessLogOwner, ClientId, ClientStatus};
24
25pub struct IppExchange {
31 pub operation: &'static str,
32 pub success: bool,
33 pub summary: String,
34 pub response_data: serde_json::Value,
35}
36
37enum Applied {
39 Executed(String),
41 Disconnect,
43}
44
45#[derive(Clone, Copy)]
47enum Dispatch {
48 Spawn,
51 Await,
55}
56
57pub struct IppClient;
59
60impl IppClient {
61 pub async fn connect_with_llm_actions(
63 remote_addr: String,
64 printer_path: Option<String>,
65 llm_client: OllamaClient,
66 app_state: Arc<AppState>,
67 status_tx: mpsc::UnboundedSender<String>,
68 client_id: ClientId,
69 ) -> Result<SocketAddr> {
70 info!("IPP client {} initialized for {}", client_id, remote_addr);
71
72 let base = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://") {
75 remote_addr.clone()
76 } else if remote_addr.starts_with("ipp://") {
77 remote_addr.replace("ipp://", "http://")
79 } else {
80 format!("http://{}", remote_addr)
82 };
83
84 let uri_str = match printer_path {
92 Some(path) if !path_already_present(&base) => {
93 format!(
94 "{}/{}",
95 base.trim_end_matches('/'),
96 path.trim_start_matches('/')
97 )
98 }
99 _ => base,
100 };
101
102 app_state
104 .with_client_mut(client_id, |client| {
105 client.set_protocol_field("ipp_uri".to_string(), serde_json::json!(uri_str));
106 client
107 .set_protocol_field("ipp_client".to_string(), serde_json::json!("initialized"));
108 })
109 .await;
110
111 app_state
113 .update_client_status(client_id, ClientStatus::Connected)
114 .await;
115 let _ = status_tx.send(format!(
116 "[CLIENT] IPP client {} ready for {}",
117 client_id, uri_str
118 ));
119 let _ = status_tx.send("__UPDATE_UI__".to_string());
120
121 let command_rx =
126 crate::client::command_support::register_command_channel(&app_state, client_id).await;
127
128 let command_task = tokio::spawn(Self::command_loop(
134 command_rx,
135 client_id,
136 app_state.clone(),
137 llm_client.clone(),
138 status_tx.clone(),
139 ));
140 app_state
141 .register_client_task(client_id, command_task)
142 .await;
143
144 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
146 let event = Event::new(
147 &IPP_CLIENT_CONNECTED_EVENT,
148 serde_json::json!({
149 "ipp_uri": uri_str.clone(),
150 }),
151 );
152
153 let protocol = Arc::new(crate::client::ipp::actions::IppClientProtocol::new());
154
155 match call_llm_for_client(
156 &llm_client,
157 &app_state,
158 client_id.to_string(),
159 &instruction,
160 &String::new(),
161 Some(&event),
162 protocol.as_ref(),
163 &status_tx,
164 )
165 .await
166 {
167 Ok(result) => {
168 for action in result.actions {
170 match protocol.execute_action(action) {
171 Ok(ClientActionResult::Disconnect) => {
172 info!("LLM requested disconnect after connect");
173 app_state.remove_client_handle(client_id).await;
174 app_state
175 .update_client_status(client_id, ClientStatus::Disconnected)
176 .await;
177 return Ok("0.0.0.0:0".parse().unwrap());
178 }
179 Ok(applied) => {
180 match Self::apply_action(
184 client_id,
185 applied,
186 Dispatch::Spawn,
187 &app_state,
188 &llm_client,
189 &status_tx,
190 )
191 .await
192 {
193 Ok(Applied::Executed(detail)) => {
194 info!("IPP client {} after connect: {}", client_id, detail)
195 }
196 Ok(Applied::Disconnect) => {
197 info!("IPP client {} disconnecting", client_id)
198 }
199 Err(e) => error!("IPP operation failed: {}", e),
200 }
201 }
202 Err(e) => {
203 error!("IPP client {} rejected action: {}", client_id, e);
204 }
205 }
206 }
207 }
208 Err(e) => {
209 error!("LLM error on ipp_connected event: {}", e);
210 }
211 }
212 }
213
214 Ok("0.0.0.0:0".parse().unwrap())
216 }
217
218 pub async fn get_printer_attributes(
220 client_id: ClientId,
221 app_state: Arc<AppState>,
222 llm_client: OllamaClient,
223 status_tx: mpsc::UnboundedSender<String>,
224 ) -> Result<()> {
225 let exchange =
226 Self::perform_get_printer_attributes(client_id, &app_state, &status_tx).await?;
227 Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
228 Ok(())
229 }
230
231 pub async fn perform_get_printer_attributes(
234 client_id: ClientId,
235 app_state: &AppState,
236 status_tx: &mpsc::UnboundedSender<String>,
237 ) -> Result<IppExchange> {
238 let uri_str = Self::get_uri(app_state, client_id).await?;
239 let uri: Uri = uri_str.parse().context("Invalid IPP URI")?;
240
241 info!(
242 "IPP client {} sending Get-Printer-Attributes to {}",
243 client_id, uri
244 );
245
246 let operation = IppOperationBuilder::get_printer_attributes(uri.clone()).build();
247 let client = AsyncIppClient::new(uri);
248
249 match client.send(operation).await {
250 Ok(response) => {
251 let status_code = response.header().status_code();
252 info!(
253 "IPP client {} received response: status={:?}",
254 client_id, status_code
255 );
256
257 let mut attributes = serde_json::Map::new();
259 if status_code.is_success() {
260 if let Some(printer_attrs) = response
261 .attributes()
262 .groups_of(DelimiterTag::PrinterAttributes)
263 .next()
264 {
265 for (_, attr) in printer_attrs.attributes() {
266 attributes.insert(
267 attr.name().to_string(),
268 serde_json::json!(attr.value().to_string()),
269 );
270 }
271 }
272 }
273
274 Ok(IppExchange {
275 operation: "get_printer_attributes",
276 success: status_code.is_success(),
277 summary: format!("{:?}, {} attribute(s)", status_code, attributes.len()),
278 response_data: serde_json::json!({
279 "status_code": format!("{:?}", status_code),
280 "attributes": attributes,
281 }),
282 })
283 }
284 Err(e) => {
285 Log::new(Some(status_tx)).error(format!(
286 "IPP client {} Get-Printer-Attributes failed: {}",
287 client_id, e
288 ));
289 Err(e.into())
290 }
291 }
292 }
293
294 pub async fn print_job(
296 client_id: ClientId,
297 job_name: String,
298 document_format: Option<String>,
299 document_data: Vec<u8>,
300 app_state: Arc<AppState>,
301 llm_client: OllamaClient,
302 status_tx: mpsc::UnboundedSender<String>,
303 ) -> Result<()> {
304 let exchange = Self::perform_print_job(
305 client_id,
306 job_name,
307 document_format,
308 document_data,
309 &app_state,
310 &status_tx,
311 )
312 .await?;
313 Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
314 Ok(())
315 }
316
317 pub async fn perform_print_job(
319 client_id: ClientId,
320 job_name: String,
321 document_format: Option<String>,
322 document_data: Vec<u8>,
323 app_state: &AppState,
324 status_tx: &mpsc::UnboundedSender<String>,
325 ) -> Result<IppExchange> {
326 let uri_str = Self::get_uri(app_state, client_id).await?;
327 let uri: Uri = uri_str.parse().context("Invalid IPP URI")?;
328
329 info!(
330 "IPP client {} sending Print-Job to {}: job={}, format={:?}, size={} bytes",
331 client_id,
332 uri,
333 job_name,
334 document_format,
335 document_data.len()
336 );
337
338 let document_len = document_data.len();
341 let cursor = std::io::Cursor::new(document_data);
342 let payload = IppPayload::new(cursor);
343
344 let operation = IppOperationBuilder::print_job(uri.clone(), payload)
345 .job_title(&job_name)
346 .build();
347
348 let client = AsyncIppClient::new(uri);
349
350 match client.send(operation).await {
351 Ok(response) => {
352 let status_code = response.header().status_code();
353 info!(
354 "IPP client {} Print-Job response: status={:?}",
355 client_id, status_code
356 );
357
358 let mut job_attrs = serde_json::Map::new();
360 if status_code.is_success() {
361 if let Some(attrs) = response
362 .attributes()
363 .groups_of(DelimiterTag::JobAttributes)
364 .next()
365 {
366 for (_, attr) in attrs.attributes() {
367 job_attrs.insert(
368 attr.name().to_string(),
369 serde_json::json!(attr.value().to_string()),
370 );
371 }
372 }
373 }
374
375 Ok(IppExchange {
376 operation: "print_job",
377 success: status_code.is_success(),
378 summary: format!(
379 "{:?} for a {}-byte document, {} job attribute(s)",
380 status_code,
381 document_len,
382 job_attrs.len()
383 ),
384 response_data: serde_json::json!({
385 "status_code": format!("{:?}", status_code),
386 "job_name": job_name,
387 "job_attributes": job_attrs,
388 }),
389 })
390 }
391 Err(e) => {
392 Log::new(Some(status_tx))
393 .error(format!("IPP client {} Print-Job failed: {}", client_id, e));
394 Err(e.into())
395 }
396 }
397 }
398
399 pub async fn get_job_attributes(
401 client_id: ClientId,
402 job_id: i32,
403 app_state: Arc<AppState>,
404 llm_client: OllamaClient,
405 status_tx: mpsc::UnboundedSender<String>,
406 ) -> Result<()> {
407 let exchange =
408 Self::perform_get_job_attributes(client_id, job_id, &app_state, &status_tx).await?;
409 Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
410 Ok(())
411 }
412
413 pub async fn perform_get_job_attributes(
415 client_id: ClientId,
416 job_id: i32,
417 app_state: &AppState,
418 status_tx: &mpsc::UnboundedSender<String>,
419 ) -> Result<IppExchange> {
420 let uri_str = Self::get_uri(app_state, client_id).await?;
421 let uri: Uri = uri_str.parse().context("Invalid IPP URI")?;
422
423 info!(
424 "IPP client {} sending Get-Job-Attributes to {}: job_id={}",
425 client_id, uri, job_id
426 );
427
428 let operation = IppOperationBuilder::get_job_attributes(uri.clone(), job_id).build();
429 let client = AsyncIppClient::new(uri);
430
431 match client.send(operation).await {
432 Ok(response) => {
433 let status_code = response.header().status_code();
434 info!(
435 "IPP client {} Get-Job-Attributes response: status={:?}",
436 client_id, status_code
437 );
438
439 let mut attributes = serde_json::Map::new();
441 if status_code.is_success() {
442 if let Some(job_attrs) = response
443 .attributes()
444 .groups_of(DelimiterTag::JobAttributes)
445 .next()
446 {
447 for (_, attr) in job_attrs.attributes() {
448 attributes.insert(
449 attr.name().to_string(),
450 serde_json::json!(attr.value().to_string()),
451 );
452 }
453 }
454 }
455
456 Ok(IppExchange {
457 operation: "get_job_attributes",
458 success: status_code.is_success(),
459 summary: format!(
460 "job {}: {:?}, {} attribute(s)",
461 job_id,
462 status_code,
463 attributes.len()
464 ),
465 response_data: serde_json::json!({
466 "status_code": format!("{:?}", status_code),
467 "job_id": job_id,
468 "attributes": attributes,
469 }),
470 })
471 }
472 Err(e) => {
473 Log::new(Some(status_tx)).error(format!(
474 "IPP client {} Get-Job-Attributes failed: {}",
475 client_id, e
476 ));
477 Err(e.into())
478 }
479 }
480 }
481
482 async fn get_uri(app_state: &AppState, client_id: ClientId) -> Result<String> {
484 app_state
485 .with_client_mut(client_id, |client| {
486 client
487 .get_protocol_field("ipp_uri")
488 .and_then(|v| v.as_str())
489 .map(|s| s.to_string())
490 })
491 .await
492 .flatten()
493 .context("No IPP URI found in client state")
494 }
495
496 async fn run_follow_ups(
508 client_id: ClientId,
509 actions: Vec<serde_json::Value>,
510 app_state: &AppState,
511 status_tx: &mpsc::UnboundedSender<String>,
512 ) {
513 use crate::llm::actions::client_trait::{Client, ClientActionResult};
514 let protocol = crate::client::ipp::actions::IppClientProtocol::new();
515 for action in actions {
516 let Ok(ClientActionResult::Custom { name, data }) =
517 protocol.execute_action(action.clone())
518 else {
519 continue;
520 };
521 let outcome: Result<()> = match name.as_str() {
522 "ipp_get_printer_attributes" => {
523 Self::perform_get_printer_attributes(client_id, app_state, status_tx)
524 .await
525 .map(|_| ())
526 }
527 "ipp_print_job" => Self::perform_print_job(
528 client_id,
529 data["job_name"].as_str().unwrap_or("netget").to_string(),
530 data["document_format"].as_str().map(|s| s.to_string()),
531 data["document_data"]
532 .as_str()
533 .map(|s| s.as_bytes().to_vec())
534 .unwrap_or_default(),
535 app_state,
536 status_tx,
537 )
538 .await
539 .map(|_| ()),
540 "ipp_get_job_attributes" => Self::perform_get_job_attributes(
541 client_id,
542 data["job_id"].as_i64().unwrap_or(0) as i32,
543 app_state,
544 status_tx,
545 )
546 .await
547 .map(|_| ()),
548 other => {
549 info!(
550 "IPP client {} follow-up '{}' has no non-notifying path; skipped",
551 client_id, other
552 );
553 Ok(())
554 }
555 };
556 if let Err(e) = outcome {
557 error!("IPP client {} follow-up action failed: {}", client_id, e);
558 }
559 }
560 }
561
562 async fn notify_response(
563 client_id: ClientId,
564 exchange: IppExchange,
565 app_state: Arc<AppState>,
566 llm_client: OllamaClient,
567 status_tx: mpsc::UnboundedSender<String>,
568 ) {
569 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
570 return;
571 };
572
573 let protocol = Arc::new(crate::client::ipp::actions::IppClientProtocol::new());
574 let event = Event::new(
575 &crate::client::ipp::actions::IPP_CLIENT_RESPONSE_RECEIVED_EVENT,
576 serde_json::json!({
577 "operation": exchange.operation,
578 "success": exchange.success,
579 "response": exchange.response_data,
580 }),
581 );
582
583 let memory = app_state
584 .get_memory_for_client(client_id)
585 .await
586 .unwrap_or_default();
587
588 match call_llm_for_client(
589 &llm_client,
590 &app_state,
591 client_id.to_string(),
592 &instruction,
593 &memory,
594 Some(&event),
595 protocol.as_ref(),
596 &status_tx,
597 )
598 .await
599 {
600 Ok(ClientLlmResult {
601 actions,
602 memory_updates,
603 }) => {
604 if let Some(mem) = memory_updates {
606 app_state.set_memory_for_client(client_id, mem).await;
607 }
608 Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
609 }
610 Err(e) => {
611 error!("LLM error for IPP client {}: {}", client_id, e);
612 }
613 }
614 }
615
616 async fn apply_action(
626 client_id: ClientId,
627 result: ClientActionResult,
628 dispatch: Dispatch,
629 app_state: &Arc<AppState>,
630 llm_client: &OllamaClient,
631 status_tx: &mpsc::UnboundedSender<String>,
632 ) -> Result<Applied> {
633 let (name, data) = match result {
634 ClientActionResult::Custom { name, data } => (name, data),
635 ClientActionResult::Disconnect => return Ok(Applied::Disconnect),
636 ClientActionResult::NoAction => return Ok(Applied::Executed("no_action".to_string())),
637 ClientActionResult::WaitForMore => {
638 return Ok(Applied::Executed("wait_for_more".to_string()))
639 }
640 ClientActionResult::SendData(_) => {
641 return Ok(Applied::Executed(
642 "IPP owns no socket; raw send_data cannot be put on the wire".to_string(),
643 ))
644 }
645 ClientActionResult::Multiple(_) => {
646 return Ok(Applied::Executed(
647 "IPP produces no Multiple results; nothing executed".to_string(),
648 ))
649 }
650 };
651
652 match name.as_str() {
653 "ipp_get_printer_attributes" => match dispatch {
654 Dispatch::Spawn => {
655 Self::spawn_operation(
656 client_id,
657 app_state,
658 Self::get_printer_attributes(
659 client_id,
660 app_state.clone(),
661 llm_client.clone(),
662 status_tx.clone(),
663 ),
664 )
665 .await;
666 Ok(Applied::Executed(
667 "Get-Printer-Attributes dispatched".to_string(),
668 ))
669 }
670 Dispatch::Await => {
671 let exchange =
672 Self::perform_get_printer_attributes(client_id, app_state, status_tx)
673 .await?;
674 let detail =
675 format!("Get-Printer-Attributes completed -> {}", exchange.summary);
676 Self::spawn_notify(client_id, app_state, exchange, llm_client, status_tx).await;
677 Ok(Applied::Executed(detail))
678 }
679 },
680 "ipp_print_job" => {
681 let job_name = data
682 .get("job_name")
683 .and_then(|v| v.as_str())
684 .unwrap_or("Untitled")
685 .to_string();
686 let document_format = data
687 .get("document_format")
688 .and_then(|v| v.as_str())
689 .map(|s| s.to_string());
690 let document_data = match data.get("document_data") {
691 None => Vec::new(),
692 Some(value) => crate::client::wire_values::bytes(
693 value
694 .as_array()
695 .context("document_data must be a byte array")?,
696 )?,
697 };
698
699 match dispatch {
700 Dispatch::Spawn => {
701 let log_name = job_name.clone();
702 Self::spawn_operation(
703 client_id,
704 app_state,
705 Self::print_job(
706 client_id,
707 job_name,
708 document_format,
709 document_data,
710 app_state.clone(),
711 llm_client.clone(),
712 status_tx.clone(),
713 ),
714 )
715 .await;
716 Ok(Applied::Executed(format!(
717 "Print-Job {log_name:?} dispatched"
718 )))
719 }
720 Dispatch::Await => {
721 let log_name = job_name.clone();
722 let exchange = Self::perform_print_job(
723 client_id,
724 job_name,
725 document_format,
726 document_data,
727 app_state,
728 status_tx,
729 )
730 .await?;
731 let detail =
732 format!("Print-Job {log_name:?} completed -> {}", exchange.summary);
733 Self::spawn_notify(client_id, app_state, exchange, llm_client, status_tx)
734 .await;
735 Ok(Applied::Executed(detail))
736 }
737 }
738 }
739 "ipp_get_job_attributes" => {
740 let job_id = data.get("job_id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
741 match dispatch {
742 Dispatch::Spawn => {
743 Self::spawn_operation(
744 client_id,
745 app_state,
746 Self::get_job_attributes(
747 client_id,
748 job_id,
749 app_state.clone(),
750 llm_client.clone(),
751 status_tx.clone(),
752 ),
753 )
754 .await;
755 Ok(Applied::Executed(format!(
756 "Get-Job-Attributes for job {job_id} dispatched"
757 )))
758 }
759 Dispatch::Await => {
760 let exchange = Self::perform_get_job_attributes(
761 client_id, job_id, app_state, status_tx,
762 )
763 .await?;
764 let detail =
765 format!("Get-Job-Attributes completed -> {}", exchange.summary);
766 Self::spawn_notify(client_id, app_state, exchange, llm_client, status_tx)
767 .await;
768 Ok(Applied::Executed(detail))
769 }
770 }
771 }
772 other => Ok(Applied::Executed(format!(
773 "unknown IPP custom result '{other}' was not executed"
774 ))),
775 }
776 }
777
778 async fn spawn_operation(
780 client_id: ClientId,
781 app_state: &Arc<AppState>,
782 operation: impl std::future::Future<Output = Result<()>> + Send + 'static,
783 ) {
784 let handle = tokio::spawn(async move {
787 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
789 if let Err(e) = operation.await {
790 error!("IPP client {} operation failed: {}", client_id, e);
791 }
792 });
793 app_state.register_client_task(client_id, handle).await;
794 }
795
796 async fn spawn_notify(
799 client_id: ClientId,
800 app_state: &Arc<AppState>,
801 exchange: IppExchange,
802 llm_client: &OllamaClient,
803 status_tx: &mpsc::UnboundedSender<String>,
804 ) {
805 let state = app_state.clone();
806 let llm = llm_client.clone();
807 let tx = status_tx.clone();
808 let handle = tokio::spawn(async move {
809 Self::notify_response(client_id, exchange, state, llm, tx).await;
810 });
811 app_state.register_client_task(client_id, handle).await;
812 }
813
814 async fn command_loop(
816 mut command_rx: mpsc::Receiver<ClientCommand>,
817 client_id: ClientId,
818 app_state: Arc<AppState>,
819 llm_client: OllamaClient,
820 status_tx: mpsc::UnboundedSender<String>,
821 ) {
822 use crate::llm::actions::protocol_trait::Protocol;
823 let protocol = crate::client::ipp::actions::IppClientProtocol::new();
824
825 while let Some(command) = command_rx.recv().await {
826 let action = command.action.clone();
827 let outcome = match protocol.execute_action(action.clone()) {
828 Err(e) => Ok(ClientSendOutcome::Rejected {
829 error: e.to_string(),
830 }),
831 Ok(result) => match Self::apply_action(
834 client_id,
835 result,
836 Dispatch::Await,
837 &app_state,
838 &llm_client,
839 &status_tx,
840 )
841 .await
842 {
843 Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
844 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
845 Err(e) => Err(e),
846 },
847 };
848
849 let outcome_json = match &outcome {
850 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
851 Err(e) => serde_json::json!({"error": e.to_string()}),
852 };
853 app_state
854 .record_access_log(
855 AccessLogOwner::Client(client_id.as_u32()),
856 protocol.protocol_name(),
857 None,
858 "injected_action",
859 action,
860 vec![outcome_json],
861 )
862 .await;
863
864 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
865 if let Err(e) = &outcome {
866 error!("IPP client {} injected action failed: {}", client_id, e);
867 let _ = status_tx.send(format!(
868 "[WARN] Client {} injected action failed: {}",
869 client_id, e
870 ));
871 }
872 let _ = status_tx.send("__UPDATE_UI__".to_string());
873 crate::client::command_support::reply(command, outcome);
874
875 if disconnect {
876 app_state
877 .update_client_status(client_id, ClientStatus::Disconnected)
878 .await;
879 break;
880 }
881 }
882
883 info!("IPP client {} command loop finished", client_id);
884 app_state.remove_client_handle(client_id).await;
885 let _ = status_tx.send("__UPDATE_UI__".to_string());
886 }
887}
888
889fn path_already_present(url: &str) -> bool {
895 let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
897 match after_scheme.split_once('/') {
898 Some((_, path)) => !path.is_empty(),
899 None => false,
900 }
901}