Skip to main content

netget/client/ipp/
actions.rs

1//! IPP client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// IPP client connected event
15pub static IPP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "ipp_connected",
18        "IPP client initialized and ready to send print operations",
19        json!({"type": "get_printer_attributes"}),
20    )
21    .with_parameters(vec![Parameter {
22        name: "printer_uri".to_string(),
23        type_hint: "string".to_string(),
24        description: "IPP printer URI".to_string(),
25        required: true,
26    }])
27});
28
29/// IPP client response received event
30pub static IPP_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
31    EventType::new(
32        "ipp_response_received",
33        "IPP operation response received from printer",
34        json!({"type": "get_job_attributes", "job_id": 123}),
35    )
36    .with_parameters(vec![
37        Parameter {
38            name: "operation".to_string(),
39            type_hint: "string".to_string(),
40            description:
41                "IPP operation name (get_printer_attributes, print_job, get_job_attributes)"
42                    .to_string(),
43            required: true,
44        },
45        Parameter {
46            name: "success".to_string(),
47            type_hint: "boolean".to_string(),
48            description: "Whether the operation succeeded".to_string(),
49            required: true,
50        },
51        Parameter {
52            name: "response".to_string(),
53            type_hint: "object".to_string(),
54            description: "Response data from the printer".to_string(),
55            required: true,
56        },
57    ])
58});
59
60/// IPP client protocol action handler
61pub struct IppClientProtocol;
62
63impl IppClientProtocol {
64    pub fn new() -> Self {
65        Self
66    }
67}
68
69// Implement Protocol trait (common functionality)
70impl Protocol for IppClientProtocol {
71    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
72        vec![ParameterDefinition {
73            name: "printer_path".to_string(),
74            description: "Path to the printer on the server (e.g., /printers/test-printer)"
75                .to_string(),
76            type_hint: "string".to_string(),
77            required: false,
78            example: json!("/printers/test-printer"),
79            default: None,
80        }]
81    }
82    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
83        vec![
84            ActionDefinition {
85                name: "get_printer_attributes".to_string(),
86                description: "Query printer capabilities and status".to_string(),
87                parameters: vec![],
88                example: json!({
89                    "type": "get_printer_attributes"
90                }),
91                log_template: None,
92            },
93            ActionDefinition {
94                name: "print_job".to_string(),
95                description: "Submit a print job to the printer".to_string(),
96                parameters: vec![
97                    Parameter {
98                        name: "job_name".to_string(),
99                        type_hint: "string".to_string(),
100                        description: "Name/title for the print job".to_string(),
101                        required: true,
102                    },
103                    Parameter {
104                        name: "document_format".to_string(),
105                        type_hint: "string".to_string(),
106                        description:
107                            "MIME type of the document (e.g., application/pdf, text/plain)"
108                                .to_string(),
109                        required: false,
110                    },
111                    Parameter {
112                        name: "document_data".to_string(),
113                        type_hint: "string".to_string(),
114                        description: "Document content. Plain text unless you set \
115                                      encoding=\"base64\", which is how you send a PDF or any \
116                                      other binary document. It is never guessed."
117                            .to_string(),
118                        required: true,
119                    },
120                    Parameter {
121                        name: "encoding".to_string(),
122                        type_hint: "string".to_string(),
123                        description: "How to read document_data: \"utf8\" (default, the text \
124                                      itself) or \"base64\". Say which; the two are not \
125                                      distinguishable from the content."
126                            .to_string(),
127                        required: false,
128                    },
129                ],
130                example: json!({
131                    "type": "print_job",
132                    "job_name": "Test Document",
133                    "document_format": "text/plain",
134                    "document_data": "Hello, Printer!\n"
135                }),
136                log_template: None,
137            },
138            ActionDefinition {
139                name: "get_job_attributes".to_string(),
140                description: "Query status and details of a specific print job".to_string(),
141                parameters: vec![Parameter {
142                    name: "job_id".to_string(),
143                    type_hint: "number".to_string(),
144                    description: "Job ID returned from print_job operation".to_string(),
145                    required: true,
146                }],
147                example: json!({
148                    "type": "get_job_attributes",
149                    "job_id": 123
150                }),
151                log_template: None,
152            },
153            ActionDefinition {
154                name: "disconnect".to_string(),
155                description: "Disconnect from the IPP printer".to_string(),
156                parameters: vec![],
157                example: json!({
158                    "type": "disconnect"
159                }),
160                log_template: None,
161            },
162        ]
163    }
164    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
165        vec![
166            ActionDefinition {
167                name: "wait_for_more".to_string(),
168                description: "Do nothing and wait for the next IPP response. The correct answer \
169                    when what arrived needs no follow-up -- without it the model has to \
170                    invent an action it does not want."
171                    .to_string(),
172                parameters: vec![],
173                example: json!({
174                    "type": "wait_for_more"
175                }),
176                log_template: None,
177            },
178            ActionDefinition {
179                name: "get_printer_attributes".to_string(),
180                description: "Query printer capabilities in response to previous operation"
181                    .to_string(),
182                parameters: vec![],
183                example: json!({
184                    "type": "get_printer_attributes"
185                }),
186                log_template: None,
187            },
188            ActionDefinition {
189                name: "get_job_attributes".to_string(),
190                description: "Query job status after submitting a print job".to_string(),
191                parameters: vec![Parameter {
192                    name: "job_id".to_string(),
193                    type_hint: "number".to_string(),
194                    description: "Job ID to query".to_string(),
195                    required: true,
196                }],
197                example: json!({
198                    "type": "get_job_attributes",
199                    "job_id": 123
200                }),
201                log_template: None,
202            },
203        ]
204    }
205    fn protocol_name(&self) -> &'static str {
206        "IPP"
207    }
208    fn get_event_types(&self) -> Vec<EventType> {
209        vec![
210            EventType::new(
211                "ipp_connected",
212                "Triggered when IPP client is initialized",
213                json!({"type": "get_printer_attributes"}),
214            ),
215            EventType::new(
216                "ipp_response_received",
217                "Triggered when IPP client receives a response from the printer",
218                json!({"type": "get_job_attributes", "job_id": 123}),
219            ),
220        ]
221    }
222    fn stack_name(&self) -> &'static str {
223        "ETH>IP>TCP>HTTP>IPP"
224    }
225    fn keywords(&self) -> Vec<&'static str> {
226        vec![
227            "ipp",
228            "ipp client",
229            "internet printing protocol",
230            "print",
231            "printer",
232        ]
233    }
234    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
235        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
236
237        ProtocolMetadataV2::builder()
238                .state(DevelopmentState::Experimental)
239                .implementation("ipp crate 5.3 with AsyncIppClient")
240                .llm_control("Full control over print operations: get-printer-attributes, print-job, get-job-attributes")
241                .e2e_testing("CUPS test server or local IPP printer")
242                .build()
243    }
244    fn description(&self) -> &'static str {
245        "IPP client for printing and querying print jobs"
246    }
247    fn example_prompt(&self) -> &'static str {
248        "Connect to ipp://localhost:631/printers/test-printer and query its capabilities"
249    }
250    fn group_name(&self) -> &'static str {
251        "File & Print"
252    }
253    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
254        use crate::llm::actions::StartupExamples;
255        use serde_json::json;
256
257        StartupExamples::new(
258            // LLM mode: LLM controls print operations
259            json!({
260                "type": "open_client",
261                "remote_addr": "localhost:631",
262                "base_stack": "ipp",
263                "startup_params": {
264                    "printer_path": "/printers/test-printer"
265                },
266                "instruction": "Query printer capabilities and submit a test print job"
267            }),
268            // Script mode: Code-based print job handling
269            json!({
270                "type": "open_client",
271                "remote_addr": "localhost:631",
272                "base_stack": "ipp",
273                "startup_params": {
274                    "printer_path": "/printers/test-printer"
275                },
276                "event_handlers": [{
277                    "event_pattern": "ipp_response_received",
278                    "handler": {
279                        "type": "script",
280                        "language": "python",
281                        "code": "<ipp_client_handler>"
282                    }
283                }]
284            }),
285            // Static mode: Fixed printer query
286            json!({
287                "type": "open_client",
288                "remote_addr": "localhost:631",
289                "base_stack": "ipp",
290                "startup_params": {
291                    "printer_path": "/printers/test-printer"
292                },
293                "event_handlers": [
294                    {
295                        "event_pattern": "ipp_connected",
296                        "handler": {
297                            "type": "static",
298                            "actions": [{
299                                "type": "get_printer_attributes"
300                            }]
301                        }
302                    },
303                    {
304                        "event_pattern": "ipp_response_received",
305                        "handler": {
306                            "type": "static",
307                            "actions": [{
308                                "type": "disconnect"
309                            }]
310                        }
311                    }
312                ]
313            }),
314        )
315    }
316}
317
318// Implement Client trait (client-specific functionality)
319impl Client for IppClientProtocol {
320    fn connect(
321        &self,
322        ctx: crate::protocol::ConnectContext,
323    ) -> std::pin::Pin<
324        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
325    > {
326        Box::pin(async move {
327            use crate::client::ipp::IppClient;
328
329            // `printer_path` is a declared startup parameter and was read by nothing: the URI
330            // was built from `remote_addr` alone, so every `startup_params:
331            // {"printer_path": "/printers/test-printer"}` in this file's own examples did
332            // nothing, and an operator who set the field in the dashboard got the same request
333            // as one who left it blank.
334            //
335            // It matters more here than a dead knob usually does. IPP addresses a *printer*,
336            // not a host: `http://host:631/printers/foo` and `http://host:631/printers/bar` are
337            // different queues on the same server, and with no path the request goes to the
338            // server root, which a real IPP server answers with 404 or with the wrong queue's
339            // attributes.
340            let printer_path = ctx
341                .startup_params
342                .as_ref()
343                .map(|p| p.get_optional_string("printer_path"))
344                .transpose()?
345                .flatten()
346                .filter(|s| !s.trim().is_empty());
347
348            IppClient::connect_with_llm_actions(
349                ctx.remote_addr,
350                printer_path,
351                ctx.llm_client,
352                ctx.state,
353                ctx.status_tx,
354                ctx.client_id,
355            )
356            .await
357        })
358    }
359    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
360        let action_type = action
361            .get("type")
362            .and_then(|v| v.as_str())
363            .context("Missing 'type' field in action")?;
364
365        match action_type {
366            "get_printer_attributes" => Ok(ClientActionResult::Custom {
367                name: "ipp_get_printer_attributes".to_string(),
368                data: json!({}),
369            }),
370            "print_job" => {
371                let job_name = action
372                    .get("job_name")
373                    .and_then(|v| v.as_str())
374                    .context("Missing 'job_name' field")?
375                    .to_string();
376
377                let document_format = action
378                    .get("document_format")
379                    .and_then(|v| v.as_str())
380                    .map(|s| s.to_string());
381
382                let document_data = action
383                    .get("document_data")
384                    .and_then(|v| v.as_str())
385                    .context("Missing 'document_data' field")?;
386
387                // The encoding is declared, never sniffed.
388                //
389                // This used to guess: "all ASCII alphanumeric (plus +/=) and a length divisible
390                // by four" meant base64, anything else meant text. A four-character document is
391                // both — printing the word `Test` produced three bytes of binary on the wire and
392                // nothing said so. `"48656c6c6f"` is simultaneously valid text and valid hex and
393                // only the sender knows which it means; this is the same defect `send_tcp_data`
394                // had, in base64 rather than hex, and the fix is the same explicit field.
395                //
396                // The old guess also swallowed a genuine base64 error into the raw bytes with
397                // `unwrap_or_else`, so a truncated document was printed as its own base64 text.
398                let data_bytes = match action
399                    .get("encoding")
400                    .and_then(|v| v.as_str())
401                    .map(str::trim)
402                    .unwrap_or("utf8")
403                    .to_ascii_lowercase()
404                    .as_str()
405                {
406                    "utf8" | "utf-8" | "text" | "plain" => document_data.as_bytes().to_vec(),
407                    "base64" | "b64" => {
408                        use base64::{engine::general_purpose, Engine as _};
409                        general_purpose::STANDARD
410                            .decode(document_data)
411                            .map_err(|e| {
412                                anyhow::anyhow!(
413                                "print_job 'document_data' is not valid base64: {e}. Either fix \
414                                 the encoding or omit the 'encoding' field to send the string \
415                                 as text."
416                            )
417                            })?
418                    }
419                    other => {
420                        return Err(anyhow::anyhow!(
421                            "print_job 'encoding' must be \"utf8\" or \"base64\", got {other:?}"
422                        ))
423                    }
424                };
425
426                Ok(ClientActionResult::Custom {
427                    name: "ipp_print_job".to_string(),
428                    data: json!({
429                        "job_name": job_name,
430                        "document_format": document_format,
431                        "document_data": data_bytes,
432                    }),
433                })
434            }
435            "get_job_attributes" => {
436                // Range-check before narrowing. `as i32` wraps, so `4294967297` becomes `1`
437                // and the client asks the printer about a completely different job — one it
438                // may well have, so the answer looks like a real answer. RFC 8011 §5.3.2
439                // types job-id as integer(1:MAX), which is exactly what this can carry.
440                let job_id = action
441                    .get("job_id")
442                    .and_then(|v| v.as_i64())
443                    .context("Missing or invalid 'job_id' field")?;
444                if !(1..=i32::MAX as i64).contains(&job_id) {
445                    return Err(anyhow::anyhow!(
446                        "job_id {job_id} is not an IPP job-id (1-2147483647, RFC 8011 §5.3.2). \
447                         Use the job-id the printer returned from print_job."
448                    ));
449                }
450                let job_id = job_id as i32;
451
452                Ok(ClientActionResult::Custom {
453                    name: "ipp_get_job_attributes".to_string(),
454                    data: json!({
455                        "job_id": job_id,
456                    }),
457                })
458            }
459            "disconnect" => Ok(ClientActionResult::Disconnect),
460            // Declared in get_sync_actions, so it has to be executable here too --
461            // advertising a name the executor rejects shows the model a tool it is
462            // then punished for using.
463            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
464            _ => Err(anyhow::anyhow!(
465                "Unknown IPP client action: {}",
466                action_type
467            )),
468        }
469    }
470}