1use 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
15pub 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
38pub 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
79pub struct IrcClientProtocol;
81
82impl IrcClientProtocol {
83 pub fn new() -> Self {
84 Self
85 }
86}
87
88impl 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 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 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 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 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
394impl 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 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}