Skip to main content

netget/server/irc/
actions.rs

1//! IRC protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::server::irc::wire::{cap_line, 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;
14use tracing::debug;
15
16/// IRC protocol action handler
17///
18/// Deliberately stateless. An `IrcClientState` map (nickname, username, realname, channels)
19/// used to live here with insert/update/lookup helpers, but nothing ever called them - no
20/// nickname was ever recorded and no channel was ever joined. It was also the wrong place for
21/// it: protocols do not keep protocol state in Rust, the model tracks nicknames and channel
22/// membership through its instruction and server memory.
23pub struct IrcProtocol;
24
25impl IrcProtocol {
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31// Implement Protocol trait (common functionality)
32impl Protocol for IrcProtocol {
33    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
34        // No `send_first`: it was declared here but `spawn` read it into `_send_first` and threw
35        // it away, so callers were told the server could open the conversation when it never
36        // did. An IRC server does not speak first anyway - the client sends NICK/USER. Not
37        // declaring it makes `server_startup` warn that the flag is unsupported instead of
38        // silently dropping it. Same reasoning as `src/server/ftp/actions.rs:215`.
39        Vec::new()
40    }
41    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
42        // IRC could have async actions like broadcast_message in the future
43        Vec::new()
44    }
45    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
46        vec![
47            send_irc_message_action(),
48            send_irc_welcome_action(),
49            send_irc_pong_action(),
50            send_irc_join_action(),
51            send_irc_part_action(),
52            send_irc_privmsg_action(),
53            send_irc_notice_action(),
54            send_irc_numeric_action(),
55            wait_for_more_action(),
56            close_connection_action(),
57        ]
58    }
59    fn protocol_name(&self) -> &'static str {
60        "IRC"
61    }
62    fn get_event_types(&self) -> Vec<EventType> {
63        get_irc_event_types()
64    }
65    fn stack_name(&self) -> &'static str {
66        "ETH>IP>TCP>IRC"
67    }
68    fn keywords(&self) -> Vec<&'static str> {
69        vec!["irc", "chat"]
70    }
71    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
72        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
73
74        ProtocolMetadataV2::builder()
75            .state(DevelopmentState::Experimental)
76            .well_known_port(6667)
77            .implementation("Manual line-based IRC parsing, plain TCP only")
78            .llm_control("Every inbound line; the model composes every reply")
79            .e2e_testing("Raw TCP client issuing NICK/USER/JOIN/PRIVMSG")
80            .notes(
81                "Single-client view: no channel membership, no nick registry, no broadcast \
82                 between connections, no TLS. The model tracks all of that itself.",
83            )
84            .max_inbound_bytes(crate::server::irc::wire::MAX_IRC_READ_LINE)
85            .build()
86    }
87    fn description(&self) -> &'static str {
88        "IRC chat server"
89    }
90    fn example_prompt(&self) -> &'static str {
91        "Start an IRC server"
92    }
93    fn group_name(&self) -> &'static str {
94        "Application"
95    }
96    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
97        use crate::llm::actions::StartupExamples;
98        StartupExamples::new(
99            // LLM-driven example
100            json!({
101                "type": "open_server",
102                "port": 6667,
103                "base_stack": "irc",
104                "instruction": "IRC chat server, send welcome on NICK/USER, echo messages back"
105            }),
106            // Script-based example
107            json!({
108                "type": "open_server",
109                "port": 6667,
110                "base_stack": "irc",
111                "event_handlers": [{
112                    "event_pattern": "irc_message_received",
113                    "handler": {
114                        "type": "script",
115                        "language": "python",
116                        "code": "# Handle IRC messages\nmsg = event.get('message', '').strip()\nif msg.startswith('NICK'):\n    nick = msg.split()[1] if len(msg.split()) > 1 else 'guest'\n    respond([{'type': 'send_irc_welcome', 'nickname': nick, 'server': 'irc.netget.local', 'message': 'Welcome to NetGet IRC'}])\nelif msg.startswith('PING'):\n    token = msg.split(':')[1] if ':' in msg else ''\n    respond([{'type': 'send_irc_pong', 'token': token}])\nelif msg.startswith('JOIN'):\n    channel = msg.split()[1] if len(msg.split()) > 1 else '#general'\n    respond([{'type': 'send_irc_join', 'nickname': 'guest', 'channel': channel}])\nelse:\n    respond([{'type': 'wait_for_more'}])"
117                    }
118                }]
119            }),
120            // Static handler example
121            json!({
122                "type": "open_server",
123                "port": 6667,
124                "base_stack": "irc",
125                "event_handlers": [{
126                    "event_pattern": "irc_message_received",
127                    "handler": {
128                        "type": "static",
129                        "actions": [{
130                            "type": "send_irc_welcome",
131                            "nickname": "guest",
132                            "server": "irc.netget.local",
133                            "message": "Welcome to NetGet IRC"
134                        }]
135                    }
136                }]
137            }),
138        )
139    }
140}
141
142// Implement Server trait (server-specific functionality)
143impl Server for IrcProtocol {
144    fn spawn(
145        &self,
146        ctx: crate::protocol::SpawnContext,
147    ) -> std::pin::Pin<
148        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
149    > {
150        Box::pin(async move {
151            use crate::server::irc::IrcServer;
152
153            IrcServer::spawn_with_llm_actions(
154                ctx.legacy_listen_addr(),
155                ctx.llm_client,
156                ctx.state,
157                ctx.status_tx,
158                ctx.server_id,
159            )
160            .await
161        })
162    }
163    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
164        let action_type = action
165            .get("type")
166            .and_then(|v| v.as_str())
167            .context("Missing 'type' field in action")?;
168
169        match action_type {
170            "send_irc_message" => self.execute_send_irc_message(action),
171            "send_irc_welcome" => self.execute_send_irc_welcome(action),
172            "send_irc_pong" => self.execute_send_irc_pong(action),
173            "send_irc_join" => self.execute_send_irc_join(action),
174            "send_irc_part" => self.execute_send_irc_part(action),
175            "send_irc_privmsg" => self.execute_send_irc_privmsg(action),
176            "send_irc_notice" => self.execute_send_irc_notice(action),
177            "send_irc_numeric" => self.execute_send_irc_numeric(action),
178            "wait_for_more" => Ok(ActionResult::WaitForMore),
179            "close_connection" => Ok(ActionResult::CloseConnection),
180            _ => Err(anyhow::anyhow!("Unknown IRC action: {}", action_type)),
181        }
182    }
183}
184
185impl IrcProtocol {
186    fn execute_send_irc_message(&self, action: serde_json::Value) -> Result<ActionResult> {
187        let message = action
188            .get("message")
189            .and_then(|v| v.as_str())
190            .context("Missing 'message' parameter")?;
191
192        // One raw line, not a raw byte stream. The escape hatch exists so the model can send
193        // a verb this vocabulary does not name, not so it can send several messages in one
194        // action - which is what an embedded CRLF would do, silently, from a field the model
195        // filled in.
196        let body = message.trim_end_matches('\n').trim_end_matches('\r');
197        reject_line_breaks("message", body)?;
198
199        let formatted = format!("{body}\r\n");
200
201        debug!("IRC sending message: {}", formatted.trim());
202        Ok(ActionResult::Output(cap_line(
203            "send_irc_message",
204            formatted,
205        )))
206    }
207
208    fn execute_send_irc_welcome(&self, action: serde_json::Value) -> Result<ActionResult> {
209        let nickname = action
210            .get("nickname")
211            .and_then(|v| v.as_str())
212            .context("Missing 'nickname' parameter")?;
213
214        let server = action
215            .get("server")
216            .and_then(|v| v.as_str())
217            .unwrap_or("irc.server");
218
219        let message = action
220            .get("message")
221            .and_then(|v| v.as_str())
222            .unwrap_or("Welcome to the IRC Network");
223
224        reject_not_a_word("nickname", nickname)?;
225        reject_not_a_word("server", server)?;
226        reject_line_breaks("message", message)?;
227
228        // IRC numeric 001 (RPL_WELCOME)
229        let response = format!(":{} 001 {} :{}\r\n", server, nickname, message);
230
231        debug!("IRC sending welcome: {}", response.trim());
232        Ok(ActionResult::Output(cap_line("send_irc_welcome", response)))
233    }
234
235    fn execute_send_irc_pong(&self, action: serde_json::Value) -> Result<ActionResult> {
236        let token = action
237            .get("token")
238            .and_then(|v| v.as_str())
239            .context("Missing 'token' parameter")?;
240
241        reject_line_breaks("token", token)?;
242
243        let response = format!("PONG :{}\r\n", token);
244
245        debug!("IRC sending PONG: {}", token);
246        Ok(ActionResult::Output(cap_line("send_irc_pong", response)))
247    }
248
249    fn execute_send_irc_join(&self, action: serde_json::Value) -> Result<ActionResult> {
250        let nickname = action
251            .get("nickname")
252            .and_then(|v| v.as_str())
253            .context("Missing 'nickname' parameter")?;
254
255        let channel = action
256            .get("channel")
257            .and_then(|v| v.as_str())
258            .context("Missing 'channel' parameter")?;
259
260        let user = action
261            .get("user")
262            .and_then(|v| v.as_str())
263            .unwrap_or("user");
264
265        let host = action
266            .get("host")
267            .and_then(|v| v.as_str())
268            .unwrap_or("localhost");
269
270        for (field, value) in [
271            ("nickname", nickname),
272            ("channel", channel),
273            ("user", user),
274            ("host", host),
275        ] {
276            reject_not_a_word(field, value)?;
277        }
278
279        // IRC JOIN message
280        let response = format!(":{nickname}!{user}@{host} JOIN {channel}\r\n");
281
282        debug!("IRC sending JOIN: {} to {}", nickname, channel);
283        Ok(ActionResult::Output(cap_line("send_irc_join", response)))
284    }
285
286    fn execute_send_irc_part(&self, action: serde_json::Value) -> Result<ActionResult> {
287        let nickname = action
288            .get("nickname")
289            .and_then(|v| v.as_str())
290            .context("Missing 'nickname' parameter")?;
291
292        let channel = action
293            .get("channel")
294            .and_then(|v| v.as_str())
295            .context("Missing 'channel' parameter")?;
296
297        let user = action
298            .get("user")
299            .and_then(|v| v.as_str())
300            .unwrap_or("user");
301
302        let host = action
303            .get("host")
304            .and_then(|v| v.as_str())
305            .unwrap_or("localhost");
306
307        let reason = action.get("reason").and_then(|v| v.as_str());
308
309        for (field, value) in [
310            ("nickname", nickname),
311            ("channel", channel),
312            ("user", user),
313            ("host", host),
314        ] {
315            reject_not_a_word(field, value)?;
316        }
317        if let Some(reason) = reason {
318            reject_line_breaks("reason", reason)?;
319        }
320
321        // IRC PART message
322        let response = if let Some(reason) = reason {
323            format!(":{nickname}!{user}@{host} PART {channel} :{reason}\r\n")
324        } else {
325            format!(":{nickname}!{user}@{host} PART {channel}\r\n")
326        };
327
328        debug!("IRC sending PART: {} from {}", nickname, channel);
329        Ok(ActionResult::Output(cap_line("send_irc_part", response)))
330    }
331
332    fn execute_send_irc_privmsg(&self, action: serde_json::Value) -> Result<ActionResult> {
333        let source = action
334            .get("source")
335            .and_then(|v| v.as_str())
336            .context("Missing 'source' parameter")?;
337
338        let target = action
339            .get("target")
340            .and_then(|v| v.as_str())
341            .context("Missing 'target' parameter")?;
342
343        let message = action
344            .get("message")
345            .and_then(|v| v.as_str())
346            .context("Missing 'message' parameter")?;
347
348        reject_not_a_word("source", source)?;
349        reject_not_a_word("target", target)?;
350        reject_line_breaks("message", message)?;
351
352        // IRC PRIVMSG
353        let response = format!(":{} PRIVMSG {} :{}\r\n", source, target, message);
354
355        debug!("IRC sending PRIVMSG from {} to {}", source, target);
356        Ok(ActionResult::Output(cap_line("send_irc_privmsg", response)))
357    }
358
359    fn execute_send_irc_notice(&self, action: serde_json::Value) -> Result<ActionResult> {
360        let source = action
361            .get("source")
362            .and_then(|v| v.as_str())
363            .context("Missing 'source' parameter")?;
364
365        let target = action
366            .get("target")
367            .and_then(|v| v.as_str())
368            .context("Missing 'target' parameter")?;
369
370        let message = action
371            .get("message")
372            .and_then(|v| v.as_str())
373            .context("Missing 'message' parameter")?;
374
375        reject_not_a_word("source", source)?;
376        reject_not_a_word("target", target)?;
377        reject_line_breaks("message", message)?;
378
379        // IRC NOTICE
380        let response = format!(":{} NOTICE {} :{}\r\n", source, target, message);
381
382        debug!("IRC sending NOTICE from {} to {}", source, target);
383        Ok(ActionResult::Output(cap_line("send_irc_notice", response)))
384    }
385
386    fn execute_send_irc_numeric(&self, action: serde_json::Value) -> Result<ActionResult> {
387        let server = action
388            .get("server")
389            .and_then(|v| v.as_str())
390            .unwrap_or("irc.server");
391
392        let code = action
393            .get("code")
394            .and_then(|v| v.as_u64())
395            .context("Missing 'code' parameter")?;
396
397        let target = action
398            .get("target")
399            .and_then(|v| v.as_str())
400            .context("Missing 'target' parameter")?;
401
402        let message = action
403            .get("message")
404            .and_then(|v| v.as_str())
405            .context("Missing 'message' parameter")?;
406
407        // A numeric is three digits wide by definition; `{:03}` pads a smaller number but
408        // happily widens a larger one, and a four-digit "numeric" is not a numeric at all -
409        // the client reads the extra digit as the start of the next parameter.
410        if !(1..=999).contains(&code) {
411            anyhow::bail!(
412                "Invalid 'code' {code}: an IRC numeric is a three-digit reply code between \
413                 001 and 999 (e.g. 001 RPL_WELCOME, 332 RPL_TOPIC, 353 RPL_NAMREPLY)."
414            );
415        }
416        reject_not_a_word("server", server)?;
417        reject_not_a_word("target", target)?;
418        reject_line_breaks("message", message)?;
419
420        // IRC numeric response
421        let response = format!(":{} {:03} {} :{}\r\n", server, code, target, message);
422
423        debug!("IRC sending numeric {}: {}", code, message);
424        Ok(ActionResult::Output(cap_line("send_irc_numeric", response)))
425    }
426}
427
428// Action definitions
429
430fn send_irc_message_action() -> ActionDefinition {
431    ActionDefinition {
432        name: "send_irc_message".to_string(),
433        description: "Send one raw IRC line, for a verb the other actions do not cover. \
434                      Exactly one message: the CRLF is added for you and the text must not \
435                      contain CR, LF or NUL. RFC 1459 caps a line at 512 bytes including that \
436                      CRLF, and a longer one is truncated."
437            .to_string(),
438        parameters: vec![Parameter {
439            name: "message".to_string(),
440            type_hint: "string".to_string(),
441            description: "One IRC protocol line without its line ending, e.g. \
442                          \":server NOTICE * :Looking up your hostname\". Must not contain CR, \
443                          LF or NUL - use one action per message."
444                .to_string(),
445            required: true,
446        }],
447        example: json!({
448            "type": "send_irc_message",
449            "message": ":server NOTICE * :Looking up your hostname"
450        }),
451        log_template: Some(
452            LogTemplate::new()
453                .with_info("-> IRC: {preview(message,60)}")
454                .with_debug("IRC send_irc_message: {message}"),
455        ),
456    }
457}
458
459fn send_irc_welcome_action() -> ActionDefinition {
460    ActionDefinition {
461        name: "send_irc_welcome".to_string(),
462        description: "Send IRC welcome message (numeric 001 - RPL_WELCOME)".to_string(),
463        parameters: vec![
464            Parameter {
465                name: "nickname".to_string(),
466                type_hint: "string".to_string(),
467                description: "Client nickname".to_string(),
468                required: true,
469            },
470            Parameter {
471                name: "server".to_string(),
472                type_hint: "string".to_string(),
473                description: "Server name (default: irc.server)".to_string(),
474                required: false,
475            },
476            Parameter {
477                name: "message".to_string(),
478                type_hint: "string".to_string(),
479                description: "Welcome message (default: 'Welcome to the IRC Network')".to_string(),
480                required: false,
481            },
482        ],
483        example: json!({
484            "type": "send_irc_welcome",
485            "nickname": "alice",
486            "server": "irc.example.com",
487            "message": "Welcome to the IRC Network, alice!"
488        }),
489        log_template: Some(
490            LogTemplate::new()
491                .with_info("-> IRC 001 {nickname}: {message}")
492                .with_debug("IRC send_irc_welcome: nickname={nickname}, server={server}"),
493        ),
494    }
495}
496
497fn send_irc_pong_action() -> ActionDefinition {
498    ActionDefinition {
499        name: "send_irc_pong".to_string(),
500        description: "Send IRC PONG response to PING".to_string(),
501        parameters: vec![Parameter {
502            name: "token".to_string(),
503            type_hint: "string".to_string(),
504            description: "Token from PING command".to_string(),
505            required: true,
506        }],
507        example: json!({
508            "type": "send_irc_pong",
509            "token": "1234567890"
510        }),
511        log_template: Some(
512            LogTemplate::new()
513                .with_info("-> IRC PONG :{token}")
514                .with_debug("IRC send_irc_pong: token={token}"),
515        ),
516    }
517}
518
519fn send_irc_join_action() -> ActionDefinition {
520    ActionDefinition {
521        name: "send_irc_join".to_string(),
522        description: "Send IRC JOIN confirmation".to_string(),
523        parameters: vec![
524            Parameter {
525                name: "nickname".to_string(),
526                type_hint: "string".to_string(),
527                description: "Client nickname".to_string(),
528                required: true,
529            },
530            Parameter {
531                name: "channel".to_string(),
532                type_hint: "string".to_string(),
533                description: "Channel name (e.g., #general)".to_string(),
534                required: true,
535            },
536            Parameter {
537                name: "user".to_string(),
538                type_hint: "string".to_string(),
539                description: "Username (default: user)".to_string(),
540                required: false,
541            },
542            Parameter {
543                name: "host".to_string(),
544                type_hint: "string".to_string(),
545                description: "Hostname (default: localhost)".to_string(),
546                required: false,
547            },
548        ],
549        example: json!({
550            "type": "send_irc_join",
551            "nickname": "alice",
552            "channel": "#general"
553        }),
554        log_template: Some(
555            LogTemplate::new()
556                .with_info("-> IRC {nickname} JOIN {channel}")
557                .with_debug("IRC send_irc_join: nickname={nickname}, channel={channel}"),
558        ),
559    }
560}
561
562fn send_irc_part_action() -> ActionDefinition {
563    ActionDefinition {
564        name: "send_irc_part".to_string(),
565        description: "Send IRC PART confirmation (leaving channel)".to_string(),
566        parameters: vec![
567            Parameter {
568                name: "nickname".to_string(),
569                type_hint: "string".to_string(),
570                description: "Client nickname".to_string(),
571                required: true,
572            },
573            Parameter {
574                name: "channel".to_string(),
575                type_hint: "string".to_string(),
576                description: "Channel name (e.g., #general)".to_string(),
577                required: true,
578            },
579            Parameter {
580                name: "user".to_string(),
581                type_hint: "string".to_string(),
582                description: "Username (default: user)".to_string(),
583                required: false,
584            },
585            Parameter {
586                name: "host".to_string(),
587                type_hint: "string".to_string(),
588                description: "Hostname (default: localhost)".to_string(),
589                required: false,
590            },
591            Parameter {
592                name: "reason".to_string(),
593                type_hint: "string".to_string(),
594                description: "Part reason (optional)".to_string(),
595                required: false,
596            },
597        ],
598        example: json!({
599            "type": "send_irc_part",
600            "nickname": "alice",
601            "channel": "#general",
602            "reason": "Goodbye!"
603        }),
604        log_template: Some(
605            LogTemplate::new()
606                .with_info("-> IRC {nickname} PART {channel}")
607                .with_debug(
608                    "IRC send_irc_part: nickname={nickname}, channel={channel}, reason={reason}",
609                ),
610        ),
611    }
612}
613
614fn send_irc_privmsg_action() -> ActionDefinition {
615    ActionDefinition {
616        name: "send_irc_privmsg".to_string(),
617        description: "Send IRC PRIVMSG (chat message)".to_string(),
618        parameters: vec![
619            Parameter {
620                name: "source".to_string(),
621                type_hint: "string".to_string(),
622                description: "Source (nickname or server). One word, no leading ':' - the colon \
623                              is added for you."
624                    .to_string(),
625                required: true,
626            },
627            Parameter {
628                name: "target".to_string(),
629                type_hint: "string".to_string(),
630                description: "Target (nickname or channel). One word - a space here would shift \
631                              every later parameter."
632                    .to_string(),
633                required: true,
634            },
635            Parameter {
636                name: "message".to_string(),
637                type_hint: "string".to_string(),
638                description: "Message text. Must not contain CR, LF or NUL: an embedded line \
639                              break would forge a second IRC command from this source rather \
640                              than continuing the message. Truncated at the RFC 1459 512-byte \
641                              line limit."
642                    .to_string(),
643                required: true,
644            },
645        ],
646        example: json!({
647            "type": "send_irc_privmsg",
648            "source": "bot",
649            "target": "alice",
650            "message": "Hello, alice!"
651        }),
652        log_template: Some(
653            LogTemplate::new()
654                .with_info("-> IRC {source} -> {target}: {preview(message,40)}")
655                .with_debug("IRC send_irc_privmsg: source={source}, target={target}"),
656        ),
657    }
658}
659
660fn send_irc_notice_action() -> ActionDefinition {
661    ActionDefinition {
662        name: "send_irc_notice".to_string(),
663        description: "Send IRC NOTICE (notification message)".to_string(),
664        parameters: vec![
665            Parameter {
666                name: "source".to_string(),
667                type_hint: "string".to_string(),
668                description: "Source (nickname or server). One word, no leading ':' - the colon \
669                              is added for you."
670                    .to_string(),
671                required: true,
672            },
673            Parameter {
674                name: "target".to_string(),
675                type_hint: "string".to_string(),
676                description: "Target (nickname or channel). One word - a space here would shift \
677                              every later parameter."
678                    .to_string(),
679                required: true,
680            },
681            Parameter {
682                name: "message".to_string(),
683                type_hint: "string".to_string(),
684                description: "Notice text. Must not contain CR, LF or NUL, and is truncated at \
685                              the RFC 1459 512-byte line limit."
686                    .to_string(),
687                required: true,
688            },
689        ],
690        example: json!({
691            "type": "send_irc_notice",
692            "source": "server",
693            "target": "alice",
694            "message": "Server maintenance in 5 minutes"
695        }),
696        log_template: Some(
697            LogTemplate::new()
698                .with_info("-> IRC NOTICE {source} -> {target}")
699                .with_debug(
700                    "IRC send_irc_notice: source={source}, target={target}, message={message}",
701                ),
702        ),
703    }
704}
705
706fn send_irc_numeric_action() -> ActionDefinition {
707    ActionDefinition {
708        name: "send_irc_numeric".to_string(),
709        description: "Send IRC numeric response (e.g., 332 for topic, 353 for names)".to_string(),
710        parameters: vec![
711            Parameter {
712                name: "code".to_string(),
713                type_hint: "number".to_string(),
714                description: "Three-digit reply code between 001 and 999 (e.g. 332 RPL_TOPIC, \
715                              353 RPL_NAMREPLY, 366 RPL_ENDOFNAMES)."
716                    .to_string(),
717                required: true,
718            },
719            Parameter {
720                name: "target".to_string(),
721                type_hint: "string".to_string(),
722                description: "Target nickname".to_string(),
723                required: true,
724            },
725            Parameter {
726                name: "message".to_string(),
727                type_hint: "string".to_string(),
728                description: "Message text".to_string(),
729                required: true,
730            },
731            Parameter {
732                name: "server".to_string(),
733                type_hint: "string".to_string(),
734                description: "Server name (default: irc.server)".to_string(),
735                required: false,
736            },
737        ],
738        example: json!({
739            "type": "send_irc_numeric",
740            "code": 332,
741            "target": "alice",
742            "message": "#general Welcome to our channel!"
743        }),
744        log_template: Some(
745            LogTemplate::new()
746                .with_info("-> IRC {code} {target}: {preview(message,40)}")
747                .with_debug("IRC send_irc_numeric: code={code}, target={target}"),
748        ),
749    }
750}
751
752fn wait_for_more_action() -> ActionDefinition {
753    ActionDefinition {
754        name: "wait_for_more".to_string(),
755        description: "Wait for more data before responding".to_string(),
756        parameters: vec![],
757        example: json!({
758            "type": "wait_for_more"
759        }),
760        log_template: Some(
761            LogTemplate::new()
762                .with_info("IRC waiting for more data")
763                .with_debug("IRC wait_for_more"),
764        ),
765    }
766}
767
768fn close_connection_action() -> ActionDefinition {
769    ActionDefinition {
770        name: "close_connection".to_string(),
771        description: "Close the IRC connection".to_string(),
772        parameters: vec![],
773        example: json!({
774            "type": "close_connection"
775        }),
776        log_template: Some(
777            LogTemplate::new()
778                .with_info("IRC connection closed")
779                .with_debug("IRC close_connection"),
780        ),
781    }
782}
783
784// ============================================================================
785// IRC Event Type Constants
786// ============================================================================
787
788pub static IRC_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
789    EventType::new(
790        "irc_message_received",
791        "One IRC protocol line received from a client (NICK, USER, JOIN, PRIVMSG, PING, QUIT, ...). \
792         The server does not speak first, so registration begins with the client's NICK/USER.",
793        json!({"type": "send_irc_welcome", "nickname": "alice", "server": "irc.example.com", "message": "Welcome to the IRC Network"}),
794    )
795    .with_parameters(vec![Parameter {
796        name: "message".to_string(),
797        type_hint: "string".to_string(),
798        description: "The IRC message line received, with the trailing CRLF stripped \
799                      (e.g. 'NICK alice', 'PRIVMSG #general :hello')"
800            .to_string(),
801        required: true,
802    }])
803    .with_actions(vec![
804        send_irc_message_action(),
805        send_irc_welcome_action(),
806        send_irc_pong_action(),
807        send_irc_join_action(),
808        send_irc_part_action(),
809        send_irc_privmsg_action(),
810        send_irc_notice_action(),
811        send_irc_numeric_action(),
812        wait_for_more_action(),
813        close_connection_action(),
814    ])
815    .with_log_template(
816        LogTemplate::new()
817            .with_info("IRC {client_ip}: {preview(message,80)}")
818            .with_debug("IRC message from {client_ip}:{client_port}")
819            .with_trace("IRC: {json_pretty(.)}"),
820    )
821});
822
823pub fn get_irc_event_types() -> Vec<EventType> {
824    vec![IRC_MESSAGE_RECEIVED_EVENT.clone()]
825}