1pub mod actions;
3
4pub use actions::IrcClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::io::{AsyncWriteExt, BufReader};
10use tokio::net::TcpStream;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{debug, error, info, trace, warn};
13
14use crate::client::irc::actions::{IRC_CLIENT_CONNECTED_EVENT, IRC_CLIENT_MESSAGE_RECEIVED_EVENT};
15use crate::client::llm_budget::call_llm_for_client;
16use crate::server::irc::wire::{
17 cap_line, read_irc_line, reject_line_breaks, reject_not_a_word, IrcLine, MAX_IRC_READ_LINE,
18};
19
20use crate::llm::ollama_client::OllamaClient;
21use crate::llm::ClientLlmResult;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::{ClientId, ClientStatus};
25
26struct ClientData {
36 memory: String,
37 nickname: String,
38}
39
40pub struct IrcClient;
42
43impl IrcClient {
44 pub async fn connect_with_llm_actions(
46 remote_addr: String,
47 llm_client: OllamaClient,
48 app_state: Arc<AppState>,
49 status_tx: mpsc::UnboundedSender<String>,
50 client_id: ClientId,
51 startup_params: Option<crate::protocol::StartupParams>,
52 ) -> Result<SocketAddr> {
53 let nickname = startup_params
55 .as_ref()
56 .map(|p| p.get_optional_string("nickname"))
57 .transpose()?
58 .flatten()
59 .unwrap_or_else(|| "netget_user".to_string());
60 let username = startup_params
61 .as_ref()
62 .map(|p| p.get_optional_string("username"))
63 .transpose()?
64 .flatten()
65 .unwrap_or_else(|| "netget".to_string());
66 let realname = startup_params
67 .as_ref()
68 .map(|p| p.get_optional_string("realname"))
69 .transpose()?
70 .flatten()
71 .unwrap_or_else(|| "NetGet IRC Client".to_string());
72
73 reject_not_a_word("nickname", &nickname)?;
79 reject_not_a_word("username", &username)?;
80 reject_line_breaks("realname", &realname)?;
81
82 let stream = TcpStream::connect(&remote_addr)
84 .await
85 .context(format!("Failed to connect to {}", remote_addr))?;
86
87 let local_addr = stream.local_addr()?;
88 let remote_sock_addr = stream.peer_addr()?;
89
90 info!(
91 "IRC client {} connecting to {} (local: {})",
92 client_id, remote_sock_addr, local_addr
93 );
94
95 app_state
97 .update_client_status(client_id, ClientStatus::Connected)
98 .await;
99 let _ = status_tx.send(format!(
100 "[CLIENT] IRC client {} connected to {}",
101 client_id, remote_sock_addr
102 ));
103 let _ = status_tx.send("__UPDATE_UI__".to_string());
104
105 let (read_half, write_half) = tokio::io::split(stream);
107 let write_half_arc = Arc::new(Mutex::new(write_half));
108
109 let mut writer = write_half_arc.lock().await;
111 writer
112 .write_all(format!("NICK {}\r\n", nickname).as_bytes())
113 .await?;
114 writer
115 .write_all(format!("USER {} 0 * :{}\r\n", username, realname).as_bytes())
116 .await?;
117 drop(writer);
118
119 debug!(
120 "IRC client {} sent registration (nick: {})",
121 client_id, nickname
122 );
123
124 let client_data = Arc::new(Mutex::new(ClientData {
126 memory: String::new(),
127 nickname: nickname.clone(),
128 }));
129
130 let command_rx =
135 crate::client::command_support::register_command_channel(&app_state, client_id).await;
136
137 let cmd_state = app_state.clone();
140 let cmd_tx = status_tx.clone();
141 let cmd_write = write_half_arc.clone();
142 let cmd_data = client_data.clone();
143 let cmd_task = tokio::spawn(async move {
144 Self::command_loop(
145 command_rx, cmd_write, cmd_data, client_id, cmd_state, cmd_tx,
146 )
147 .await;
148 });
149 app_state.register_client_task(client_id, cmd_task).await;
150
151 let write_half_clone = write_half_arc.clone();
153 let client_data_clone = client_data.clone();
154 let nickname_clone = nickname.clone();
155
156 let task_registrar = app_state.clone();
160 let task_handle = tokio::spawn(async move {
161 let mut reader = BufReader::new(read_half);
162 let mut registered = false;
163
164 loop {
165 let line = match read_irc_line(&mut reader, MAX_IRC_READ_LINE).await {
169 Ok((IrcLine::Line(line), _)) => line,
170 Ok((IrcLine::Eof, _)) => {
171 info!("IRC client {} disconnected", client_id);
172 app_state
173 .update_client_status(client_id, ClientStatus::Disconnected)
174 .await;
175 let _ = status_tx
176 .send(format!("[CLIENT] IRC client {} disconnected", client_id));
177 let _ = status_tx.send("__UPDATE_UI__".to_string());
178 break;
179 }
180 Ok((IrcLine::TooLong, n)) => {
181 let msg = format!(
182 "IRC server sent {n} bytes with no newline (limit \
183 {MAX_IRC_READ_LINE})"
184 );
185 error!("IRC client {}: {}", client_id, msg);
186 app_state
187 .update_client_status(client_id, ClientStatus::Error(msg))
188 .await;
189 let _ = status_tx.send("__UPDATE_UI__".to_string());
190 break;
191 }
192 Err(e) => {
193 error!("IRC client {} read error: {}", client_id, e);
194 app_state
195 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
196 .await;
197 let _ = status_tx.send("__UPDATE_UI__".to_string());
198 break;
199 }
200 };
201
202 {
203 {
204 let line = line.trim_end().to_string();
205 trace!("IRC client {} received: {}", client_id, line);
206
207 if let Some(rest) = line.strip_prefix("PING ") {
215 let pong = format!("PONG {rest}\r\n");
216 if write_half_clone
217 .lock()
218 .await
219 .write_all(pong.as_bytes())
220 .await
221 .is_ok()
222 {
223 trace!("IRC client {} sent PONG", client_id);
224 }
225 continue;
226 }
227
228 if !registered && line.contains(" 001 ") {
230 registered = true;
231 info!("IRC client {} registration complete", client_id);
232
233 if let Some(instruction) =
235 app_state.get_instruction_for_client(client_id).await
236 {
237 let protocol = Arc::new(IrcClientProtocol::new());
238 let event = Event::new(
239 &IRC_CLIENT_CONNECTED_EVENT,
240 serde_json::json!({
241 "remote_addr": remote_sock_addr.to_string(),
242 "nickname": nickname_clone,
243 }),
244 );
245
246 if let Err(e) = Self::handle_llm_call(
247 &llm_client,
248 &app_state,
249 client_id,
250 &instruction,
251 &client_data_clone,
252 Some(&event),
253 protocol,
254 &write_half_clone,
255 &status_tx,
256 )
257 .await
258 {
259 error!("IRC client {} LLM error on connect: {}", client_id, e);
260 }
261 }
262 continue;
263 }
264
265 if !registered {
267 continue;
268 }
269
270 let parsed = Self::parse_irc_message(&line);
272
273 if let Some(instruction) =
277 app_state.get_instruction_for_client(client_id).await
278 {
279 let protocol = Arc::new(IrcClientProtocol::new());
280 let event = Event::new(
281 &IRC_CLIENT_MESSAGE_RECEIVED_EVENT,
282 serde_json::json!({
283 "source": parsed.source,
284 "command": parsed.command,
285 "target": parsed.target,
286 "message": parsed.message,
287 "raw_message": line,
288 }),
289 );
290
291 if let Err(e) = Self::handle_llm_call(
292 &llm_client,
293 &app_state,
294 client_id,
295 &instruction,
296 &client_data_clone,
297 Some(&event),
298 protocol,
299 &write_half_clone,
300 &status_tx,
301 )
302 .await
303 {
304 error!("IRC client {} LLM error: {}", client_id, e);
305 }
306 }
307 }
308 }
309 }
310
311 app_state.remove_client_handle(client_id).await;
315 });
316 task_registrar
317 .register_client_task(client_id, task_handle)
318 .await;
319
320 Ok(local_addr)
321 }
322
323 async fn command_loop(
332 mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
333 write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
334 client_data: Arc<Mutex<ClientData>>,
335 client_id: ClientId,
336 app_state: Arc<AppState>,
337 status_tx: mpsc::UnboundedSender<String>,
338 ) {
339 use crate::llm::actions::client_trait::Client;
340 use crate::llm::actions::protocol_trait::Protocol;
341 use crate::state::client_handles::ClientSendOutcome;
342 use crate::state::AccessLogOwner;
343
344 let protocol = IrcClientProtocol::new();
345
346 while let Some(command) = command_rx.recv().await {
347 let action = command.action.clone();
348 let outcome = match protocol.execute_action(action.clone()) {
349 Err(e) => Ok(ClientSendOutcome::Rejected {
350 error: e.to_string(),
351 }),
352 Ok(result) => Self::apply_action(result, &write_half, &client_data)
353 .await
354 .map(|applied| match applied {
355 Applied::Disconnect => ClientSendOutcome::Disconnected,
356 Applied::Sent(0) => ClientSendOutcome::Executed {
357 detail: "executed (nothing to write)".to_string(),
358 },
359 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
360 }),
361 };
362
363 let outcome_json = match &outcome {
364 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
365 Err(e) => serde_json::json!({"error": e.to_string()}),
366 };
367 app_state
368 .record_access_log(
369 AccessLogOwner::Client(client_id.as_u32()),
370 protocol.protocol_name(),
371 None,
372 "injected_action",
373 action,
374 vec![outcome_json],
375 )
376 .await;
377
378 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
379 if let Err(e) = &outcome {
380 error!("IRC client {} injected action failed: {}", client_id, e);
381 let _ = status_tx.send(format!(
382 "[WARN] Client {} injected action failed: {}",
383 client_id, e
384 ));
385 }
386 let _ = status_tx.send("__UPDATE_UI__".to_string());
387 crate::client::command_support::reply(command, outcome);
388
389 if disconnect {
390 let _ = write_half.lock().await.shutdown().await;
393 break;
394 }
395 }
396 }
397
398 fn parse_irc_message(line: &str) -> ParsedMessage {
400 let mut parts = line.split_whitespace();
401
402 let source = if line.starts_with(':') {
403 parts.next().map(|s| s.trim_start_matches(':').to_string())
404 } else {
405 None
406 };
407
408 let command = parts.next().map(|s| s.to_uppercase()).unwrap_or_default();
409
410 let remaining: Vec<&str> = parts.collect();
411 let (target, message) = if command == "PRIVMSG" || command == "NOTICE" {
412 let target = remaining.first().map(|s| s.to_string());
413 let message = if let Some(idx) = remaining.iter().position(|s| s.starts_with(':')) {
414 let msg_parts: Vec<&str> = remaining[idx..].iter().map(|s| *s).collect();
415 Some(msg_parts.join(" ").trim_start_matches(':').to_string())
416 } else {
417 None
418 };
419 (target, message)
420 } else {
421 (None, None)
422 };
423
424 ParsedMessage {
425 source,
426 command,
427 target,
428 message,
429 }
430 }
431
432 async fn handle_llm_call(
434 llm_client: &OllamaClient,
435 app_state: &Arc<AppState>,
436 client_id: ClientId,
437 instruction: &str,
438 client_data: &Arc<Mutex<ClientData>>,
439 event: Option<&Event>,
440 protocol: Arc<IrcClientProtocol>,
441 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
442 status_tx: &mpsc::UnboundedSender<String>,
443 ) -> Result<()> {
444 let memory = client_data.lock().await.memory.clone();
445
446 match call_llm_for_client(
447 llm_client,
448 app_state,
449 client_id.to_string(),
450 instruction,
451 &memory,
452 event,
453 protocol.as_ref(),
454 status_tx,
455 )
456 .await
457 {
458 Ok(ClientLlmResult {
459 actions,
460 memory_updates,
461 }) => {
462 if let Some(mem) = memory_updates {
464 client_data.lock().await.memory = mem;
465 }
466
467 for action in actions {
469 use crate::llm::actions::client_trait::Client;
470 match protocol.as_ref().execute_action(action) {
471 Ok(result) => {
472 if let Applied::Disconnect =
473 Self::apply_action(result, write_half, client_data).await?
474 {
475 info!("IRC client {} disconnecting", client_id);
476 return Err(anyhow::anyhow!("Disconnect requested"));
477 }
478 }
479 Err(e) => {
480 warn!("IRC client {} action execution error: {}", client_id, e);
481 }
482 }
483 }
484 }
485 Err(e) => {
486 return Err(e);
487 }
488 }
489
490 Ok(())
491 }
492
493 async fn apply_action(
496 result: crate::llm::actions::client_trait::ClientActionResult,
497 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
498 client_data: &Arc<Mutex<ClientData>>,
499 ) -> Result<Applied> {
500 use crate::llm::actions::client_trait::ClientActionResult;
501
502 match result {
503 ClientActionResult::Custom { name, data } => {
504 Self::execute_irc_action(&name, data, write_half, client_data).await
505 }
506 ClientActionResult::Disconnect => {
507 Self::execute_irc_action(
508 "disconnect",
509 serde_json::Value::Null,
510 write_half,
511 client_data,
512 )
513 .await
514 }
515 _ => Ok(Applied::Sent(0)),
517 }
518 }
519
520 async fn execute_irc_action(
527 name: &str,
528 data: serde_json::Value,
529 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
530 client_data: &Arc<Mutex<ClientData>>,
531 ) -> Result<Applied> {
532 let (wire, disconnect) = match name {
535 "join_channel" => {
536 let channel = data["channel"].as_str().context("Missing channel")?;
537 debug!("IRC: JOIN {}", channel);
538 (format!("JOIN {}\r\n", channel), false)
539 }
540 "part_channel" => {
541 let channel = data["channel"].as_str().context("Missing channel")?;
542 debug!("IRC: PART {}", channel);
543 match data["message"].as_str() {
544 Some(msg) => (format!("PART {} :{}\r\n", channel, msg), false),
545 None => (format!("PART {}\r\n", channel), false),
546 }
547 }
548 "change_nick" => {
549 let new_nick = data["new_nick"].as_str().context("Missing new_nick")?;
550 client_data.lock().await.nickname = new_nick.to_string();
551 debug!("IRC: NICK {}", new_nick);
552 (format!("NICK {}\r\n", new_nick), false)
553 }
554 "send_privmsg" => {
555 let target = data["target"].as_str().context("Missing target")?;
556 let message = data["message"].as_str().context("Missing message")?;
557 debug!("IRC: PRIVMSG {} :{}", target, message);
558 (format!("PRIVMSG {} :{}\r\n", target, message), false)
559 }
560 "send_notice" => {
561 let target = data["target"].as_str().context("Missing target")?;
562 let message = data["message"].as_str().context("Missing message")?;
563 debug!("IRC: NOTICE {} :{}", target, message);
564 (format!("NOTICE {} :{}\r\n", target, message), false)
565 }
566 "send_raw" => {
567 let command = data["command"].as_str().context("Missing command")?;
568 debug!("IRC: RAW {}", command);
569 (format!("{}\r\n", command), false)
570 }
571 "disconnect" => {
572 let quit_message = data["quit_message"].as_str().unwrap_or("Leaving");
573 debug!("IRC: QUIT");
574 (format!("QUIT :{}\r\n", quit_message), true)
575 }
576 _ => {
577 warn!("Unknown IRC action: {}", name);
578 return Ok(Applied::Sent(0));
579 }
580 };
581
582 let wire = cap_line(name, wire);
586
587 let mut writer = write_half.lock().await;
588 writer.write_all(&wire).await?;
589 writer.flush().await?;
590
591 Ok(if disconnect {
592 Applied::Disconnect
593 } else {
594 Applied::Sent(wire.len())
595 })
596 }
597}
598
599enum Applied {
601 Sent(usize),
603 Disconnect,
605}
606
607#[derive(Debug)]
609struct ParsedMessage {
610 source: Option<String>,
611 command: String,
612 target: Option<String>,
613 message: Option<String>,
614}