Skip to main content

netget/client/irc/
actions.rs

1//! IRC 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::server::irc::wire::{reject_line_breaks, reject_not_a_word};
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15/// IRC client connected event
16pub static IRC_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "irc_connected",
19        "IRC client successfully connected to server",
20        json!({"type": "send_privmsg", "target": "#channel", "message": "Hello!"}),
21    )
22    .with_parameters(vec![
23        Parameter {
24            name: "remote_addr".to_string(),
25            type_hint: "string".to_string(),
26            description: "Remote IRC server address".to_string(),
27            required: true,
28        },
29        Parameter {
30            name: "nickname".to_string(),
31            type_hint: "string".to_string(),
32            description: "Nickname registered with the server".to_string(),
33            required: true,
34        },
35    ])
36});
37
38/// IRC message received event
39pub static IRC_CLIENT_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
40    EventType::new(
41        "irc_message_received",
42        "IRC message received from server or channel",
43        json!({"type": "send_privmsg", "target": "#channel", "message": "Response message"}),
44    )
45    .with_parameters(vec![
46        Parameter {
47            name: "source".to_string(),
48            type_hint: "string".to_string(),
49            description: "Source of the message (nick!user@host or server)".to_string(),
50            required: false,
51        },
52        Parameter {
53            name: "command".to_string(),
54            type_hint: "string".to_string(),
55            description: "IRC command (e.g., PRIVMSG, JOIN, NOTICE, 001, 433)".to_string(),
56            required: true,
57        },
58        Parameter {
59            name: "target".to_string(),
60            type_hint: "string".to_string(),
61            description: "Target of the message (channel or user)".to_string(),
62            required: false,
63        },
64        Parameter {
65            name: "message".to_string(),
66            type_hint: "string".to_string(),
67            description: "The message text".to_string(),
68            required: false,
69        },
70        Parameter {
71            name: "raw_message".to_string(),
72            type_hint: "string".to_string(),
73            description: "The raw IRC message line".to_string(),
74            required: true,
75        },
76    ])
77});
78
79/// IRC client protocol action handler
80pub struct IrcClientProtocol;
81
82impl IrcClientProtocol {
83    pub fn new() -> Self {
84        Self
85    }
86}
87
88// Implement Protocol trait (common functionality)
89impl Protocol for IrcClientProtocol {
90    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
91        vec![
92            ActionDefinition {
93                name: "join_channel".to_string(),
94                description: "Join an IRC channel".to_string(),
95                parameters: vec![Parameter {
96                    name: "channel".to_string(),
97                    type_hint: "string".to_string(),
98                    description: "Channel name (e.g., '#rust'). One word - no spaces, no line \
99                                  breaks."
100                        .to_string(),
101                    required: true,
102                }],
103                example: json!({
104                    "type": "join_channel",
105                    "channel": "#rust"
106                }),
107                log_template: None,
108            },
109            ActionDefinition {
110                name: "part_channel".to_string(),
111                description: "Leave an IRC channel".to_string(),
112                parameters: vec![
113                    Parameter {
114                        name: "channel".to_string(),
115                        type_hint: "string".to_string(),
116                        description: "Channel name to leave".to_string(),
117                        required: true,
118                    },
119                    Parameter {
120                        name: "message".to_string(),
121                        type_hint: "string".to_string(),
122                        description: "Optional part message".to_string(),
123                        required: false,
124                    },
125                ],
126                example: json!({
127                    "type": "part_channel",
128                    "channel": "#rust",
129                    "message": "Goodbye!"
130                }),
131                log_template: None,
132            },
133            ActionDefinition {
134                name: "change_nick".to_string(),
135                description: "Change the client's nickname".to_string(),
136                parameters: vec![Parameter {
137                    name: "new_nick".to_string(),
138                    type_hint: "string".to_string(),
139                    description: "New nickname to use".to_string(),
140                    required: true,
141                }],
142                example: json!({
143                    "type": "change_nick",
144                    "new_nick": "newname"
145                }),
146                log_template: None,
147            },
148            ActionDefinition {
149                name: "disconnect".to_string(),
150                description: "Disconnect from the IRC server".to_string(),
151                parameters: vec![Parameter {
152                    name: "quit_message".to_string(),
153                    type_hint: "string".to_string(),
154                    description: "Optional quit message".to_string(),
155                    required: false,
156                }],
157                example: json!({
158                    "type": "disconnect",
159                    "quit_message": "Leaving"
160                }),
161                log_template: None,
162            },
163        ]
164    }
165    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
166        vec![
167            ActionDefinition {
168                name: "send_privmsg".to_string(),
169                description: "Send a PRIVMSG to a channel or user".to_string(),
170                parameters: vec![
171                    Parameter {
172                        name: "target".to_string(),
173                        type_hint: "string".to_string(),
174                        description: "Target channel or user. One word - a space here would \
175                                      shift every later parameter."
176                            .to_string(),
177                        required: true,
178                    },
179                    Parameter {
180                        name: "message".to_string(),
181                        type_hint: "string".to_string(),
182                        description: "Message text to send. Must not contain CR or LF: IRC \
183                                      messages are CRLF-terminated, so a line break would \
184                                      forge a second command from this client rather than \
185                                      continuing the message. Truncated at the RFC 1459 \
186                                      512-byte line limit."
187                            .to_string(),
188                        required: true,
189                    },
190                ],
191                example: json!({
192                    "type": "send_privmsg",
193                    "target": "#rust",
194                    "message": "Hello, channel!"
195                }),
196                log_template: None,
197            },
198            ActionDefinition {
199                name: "send_notice".to_string(),
200                description: "Send a NOTICE to a channel or user".to_string(),
201                parameters: vec![
202                    Parameter {
203                        name: "target".to_string(),
204                        type_hint: "string".to_string(),
205                        description: "Target channel or user. One word - a space here would \
206                                      shift every later parameter."
207                            .to_string(),
208                        required: true,
209                    },
210                    Parameter {
211                        name: "message".to_string(),
212                        type_hint: "string".to_string(),
213                        description: "Notice text to send. Must not contain CR or LF, and is \
214                                      truncated at the RFC 1459 512-byte line limit."
215                            .to_string(),
216                        required: true,
217                    },
218                ],
219                example: json!({
220                    "type": "send_notice",
221                    "target": "#rust",
222                    "message": "Bot notification"
223                }),
224                log_template: None,
225            },
226            ActionDefinition {
227                name: "send_raw".to_string(),
228                description: "Send one raw IRC command, for a verb the other actions do not \
229                              cover. Exactly one command: the CRLF is added for you and the \
230                              text must not contain CR or LF."
231                    .to_string(),
232                parameters: vec![Parameter {
233                    name: "command".to_string(),
234                    type_hint: "string".to_string(),
235                    description: "One raw IRC command without its line ending, e.g. \
236                                  \"MODE #rust +m\". Must not contain CR or LF - use one \
237                                  action per command."
238                        .to_string(),
239                    required: true,
240                }],
241                example: json!({
242                    "type": "send_raw",
243                    "command": "MODE #rust +m"
244                }),
245                log_template: None,
246            },
247            ActionDefinition {
248                name: "wait_for_more".to_string(),
249                description: "Wait for more messages before responding".to_string(),
250                parameters: vec![],
251                example: json!({
252                    "type": "wait_for_more"
253                }),
254                log_template: None,
255            },
256        ]
257    }
258    fn protocol_name(&self) -> &'static str {
259        "IRC"
260    }
261    fn get_event_types(&self) -> Vec<EventType> {
262        // The two statics, not fresh copies. This used to build a second, parameterless pair
263        // with the same ids, so everything reading `get_event_types()` - the model's docs, the
264        // dashboard's routing editor - was told these events carry no fields, while the events
265        // the read loop actually raises carry five. Two declarations of one event drift; there
266        // is only one now.
267        vec![
268            IRC_CLIENT_CONNECTED_EVENT.clone(),
269            IRC_CLIENT_MESSAGE_RECEIVED_EVENT.clone(),
270        ]
271    }
272    fn stack_name(&self) -> &'static str {
273        "ETH>IP>TCP>IRC"
274    }
275    fn keywords(&self) -> Vec<&'static str> {
276        vec!["irc", "irc client", "chat", "connect to irc"]
277    }
278    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
279        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
280
281        ProtocolMetadataV2::builder()
282            .state(DevelopmentState::Experimental)
283            .implementation(
284                "Hand-rolled line-based IRC over plain TCP - no TLS, no SASL, no CTCP. The \
285                 `irc` crate is a declared dependency of this feature and is used by nothing; \
286                 this file used to claim otherwise. Registration is NICK + USER, and the \
287                 connected event fires on numeric 001.",
288            )
289            .llm_control("Join/part channels, send messages and notices, change nick, quit")
290            .e2e_testing(
291                "tests/client/irc/e2e_test.rs drives this client against NetGet's own IRC \
292                 *server* with both sides mocked, so it proves the two halves agree and \
293                 nothing about a real ircd. No test has ever run against ngircd, inspircd or \
294                 any other independent server, which is why this is Experimental rather than \
295                 Beta.",
296            )
297            .build()
298    }
299    fn description(&self) -> &'static str {
300        "IRC client for connecting to IRC servers and chat channels"
301    }
302    fn example_prompt(&self) -> &'static str {
303        "Connect to IRC at irc.libera.chat:6667 with nick testbot, join #test and say hello"
304    }
305    fn group_name(&self) -> &'static str {
306        "Messaging"
307    }
308    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
309        vec![
310            ParameterDefinition {
311                name: "nickname".to_string(),
312                type_hint: "string".to_string(),
313                description: "IRC nickname (default: netget_user)".to_string(),
314                required: false,
315                example: json!("mybot"),
316                default: None,
317            },
318            ParameterDefinition {
319                name: "username".to_string(),
320                type_hint: "string".to_string(),
321                description: "IRC username (default: netget)".to_string(),
322                required: false,
323                example: json!("botuser"),
324                default: None,
325            },
326            ParameterDefinition {
327                name: "realname".to_string(),
328                type_hint: "string".to_string(),
329                description: "IRC real name (default: NetGet IRC Client)".to_string(),
330                required: false,
331                example: json!("My IRC Bot"),
332                default: None,
333            },
334        ]
335    }
336
337    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
338        use crate::llm::actions::StartupExamples;
339        use serde_json::json;
340
341        StartupExamples::new(
342            // LLM mode: LLM controls IRC chat
343            json!({
344                "type": "open_client",
345                "remote_addr": "irc.libera.chat:6667",
346                "base_stack": "irc",
347                "instruction": "Join #rust channel and say hello, respond to any messages mentioning 'help'"
348            }),
349            // Script mode: Code-based deterministic responses
350            json!({
351                "type": "open_client",
352                "remote_addr": "irc.libera.chat:6667",
353                "base_stack": "irc",
354                "event_handlers": [{
355                    "event_pattern": "irc_message_received",
356                    "handler": {
357                        "type": "script",
358                        "language": "python",
359                        "code": "<irc_client_handler>"
360                    }
361                }]
362            }),
363            // Static mode: Fixed IRC join on connect
364            json!({
365                "type": "open_client",
366                "remote_addr": "irc.libera.chat:6667",
367                "base_stack": "irc",
368                "event_handlers": [
369                    {
370                        "event_pattern": "irc_connected",
371                        "handler": {
372                            "type": "static",
373                            "actions": [{
374                                "type": "join_channel",
375                                "channel": "#test"
376                            }]
377                        }
378                    },
379                    {
380                        "event_pattern": "irc_message_received",
381                        "handler": {
382                            "type": "static",
383                            "actions": [{
384                                "type": "wait_for_more"
385                            }]
386                        }
387                    }
388                ]
389            }),
390        )
391    }
392}
393
394// Implement Client trait (client-specific functionality)
395/// Every wire verb validates its fields here rather than at the point of writing, because
396/// this is the one gate both callers pass through: `handle_llm_call` and the injected-command
397/// loop each run `execute_action` before `apply_action`. Validating here means a refusal
398/// arrives as a `Rejected` outcome the dashboard can show, instead of as a channel error that
399/// reads like the plumbing broke - and it means the model's own answer is refused before any
400/// byte of it reaches the socket.
401impl Client for IrcClientProtocol {
402    fn connect(
403        &self,
404        ctx: crate::protocol::ConnectContext,
405    ) -> std::pin::Pin<
406        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
407    > {
408        Box::pin(async move {
409            use crate::client::irc::IrcClient;
410            IrcClient::connect_with_llm_actions(
411                ctx.remote_addr,
412                ctx.llm_client,
413                ctx.state,
414                ctx.status_tx,
415                ctx.client_id,
416                ctx.startup_params,
417            )
418            .await
419        })
420    }
421    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
422        let action_type = action
423            .get("type")
424            .and_then(|v| v.as_str())
425            .context("Missing 'type' field in action")?;
426
427        match action_type {
428            "join_channel" => {
429                let channel = action
430                    .get("channel")
431                    .and_then(|v| v.as_str())
432                    .context("Missing 'channel' field")?;
433                reject_not_a_word("channel", channel)?;
434
435                Ok(ClientActionResult::Custom {
436                    name: "join_channel".to_string(),
437                    data: json!({ "channel": channel }),
438                })
439            }
440            "part_channel" => {
441                let channel = action
442                    .get("channel")
443                    .and_then(|v| v.as_str())
444                    .context("Missing 'channel' field")?;
445                reject_not_a_word("channel", channel)?;
446                let message = action.get("message").and_then(|v| v.as_str());
447                if let Some(message) = message {
448                    reject_line_breaks("message", message)?;
449                }
450
451                Ok(ClientActionResult::Custom {
452                    name: "part_channel".to_string(),
453                    data: json!({ "channel": channel, "message": message }),
454                })
455            }
456            "change_nick" => {
457                let new_nick = action
458                    .get("new_nick")
459                    .and_then(|v| v.as_str())
460                    .context("Missing 'new_nick' field")?;
461                reject_not_a_word("new_nick", new_nick)?;
462
463                Ok(ClientActionResult::Custom {
464                    name: "change_nick".to_string(),
465                    data: json!({ "new_nick": new_nick }),
466                })
467            }
468            "send_privmsg" => {
469                let target = action
470                    .get("target")
471                    .and_then(|v| v.as_str())
472                    .context("Missing 'target' field")?;
473                let message = action
474                    .get("message")
475                    .and_then(|v| v.as_str())
476                    .context("Missing 'message' field")?;
477                reject_not_a_word("target", target)?;
478                reject_line_breaks("message", message)?;
479
480                Ok(ClientActionResult::Custom {
481                    name: "send_privmsg".to_string(),
482                    data: json!({ "target": target, "message": message }),
483                })
484            }
485            "send_notice" => {
486                let target = action
487                    .get("target")
488                    .and_then(|v| v.as_str())
489                    .context("Missing 'target' field")?;
490                let message = action
491                    .get("message")
492                    .and_then(|v| v.as_str())
493                    .context("Missing 'message' field")?;
494                reject_not_a_word("target", target)?;
495                reject_line_breaks("message", message)?;
496
497                Ok(ClientActionResult::Custom {
498                    name: "send_notice".to_string(),
499                    data: json!({ "target": target, "message": message }),
500                })
501            }
502            "send_raw" => {
503                let command = action
504                    .get("command")
505                    .and_then(|v| v.as_str())
506                    .context("Missing 'command' field")?;
507                // Raw means "a verb this vocabulary does not name", not "several commands":
508                // the CRLF is added when the line is written, so one already in the text is an
509                // extra command.
510                let command = command.trim_end_matches('\n').trim_end_matches('\r');
511                reject_line_breaks("command", command)?;
512
513                Ok(ClientActionResult::Custom {
514                    name: "send_raw".to_string(),
515                    data: json!({ "command": command }),
516                })
517            }
518            "disconnect" => {
519                let quit_message = action.get("quit_message").and_then(|v| v.as_str());
520                if let Some(quit_message) = quit_message {
521                    reject_line_breaks("quit_message", quit_message)?;
522                }
523                Ok(ClientActionResult::Custom {
524                    name: "disconnect".to_string(),
525                    data: json!({ "quit_message": quit_message }),
526                })
527            }
528            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
529            _ => Err(anyhow::anyhow!(
530                "Unknown IRC client action: {}",
531                action_type
532            )),
533        }
534    }
535}