Skip to main content

chat_system/servers/
irc.rs

1//! IRC listener implementation.
2//!
3//! Implements a basic IRC server that handles connection registration,
4//! PRIVMSG/NOTICE, PING/PONG, JOIN/PART, TOPIC, and the standard
5//! RPL_WELCOME sequence (001–004).
6
7use crate::message::{Message, MessageType};
8use crate::server::{ChatListener, MessageHandler};
9use anyhow::Result;
10use async_trait::async_trait;
11use std::sync::Arc;
12use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::net::TcpListener;
14
15/// CTCP delimiter character (0x01).
16const CTCP_DELIM: char = '\x01';
17
18// ── IrcListener ───────────────────────────────────────────────────────────────
19
20/// A TCP listener that speaks the IRC protocol.
21///
22/// When started, it binds the configured address, accepts incoming connections,
23/// parses IRC messages, invokes the message handler, and sends replies back in
24/// IRC wire format.  Multiple `IrcListener` instances can be attached to a
25/// single [`Server`](crate::server::Server) so that it is reachable on several
26/// ports simultaneously.
27///
28/// ```rust,no_run
29/// use chat_system::server::Server;
30/// use chat_system::servers::IrcListener;
31///
32/// # #[tokio::main] async fn main() -> anyhow::Result<()> {
33/// let mut server = Server::new("my-irc")
34///     .add_listener(IrcListener::new("0.0.0.0:6667"))
35///     .add_listener(IrcListener::new("0.0.0.0:6697"));
36/// // server.run(handler).await?;
37/// # Ok(()) }
38/// ```
39pub struct IrcListener {
40    address: String,
41    server_name: String,
42    shutdown_tx: Option<tokio::sync::watch::Sender<bool>>,
43}
44
45impl IrcListener {
46    /// Create a new [`IrcListener`] that will bind to `address` (e.g.
47    /// `"127.0.0.1:6667"`).
48    pub fn new(address: impl Into<String>) -> Self {
49        Self {
50            address: address.into(),
51            server_name: "localhost".to_string(),
52            shutdown_tx: None,
53        }
54    }
55
56    /// Set a custom server name used in the RPL_WELCOME sequence.
57    pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
58        self.server_name = name.into();
59        self
60    }
61}
62
63/// Send the standard IRC welcome sequence (RPL 001–004) to a newly registered
64/// client.
65async fn send_welcome(
66    writer: &mut (impl tokio::io::AsyncWrite + Unpin),
67    server_name: &str,
68    nick: &str,
69) -> Result<()> {
70    let lines = [
71        format!(":{server_name} 001 {nick} :Welcome to the Internet Relay Network {nick}\r\n"),
72        format!(":{server_name} 002 {nick} :Your host is {server_name}, running chat-system\r\n"),
73        format!(":{server_name} 003 {nick} :This server was created with chat-system\r\n"),
74        format!(":{server_name} 004 {nick} {server_name} chat-system o o\r\n"),
75    ];
76    for line in &lines {
77        writer.write_all(line.as_bytes()).await?;
78    }
79    Ok(())
80}
81
82/// Handle a single IRC connection: perform the handshake, parse `PRIVMSG`
83/// lines, invoke the handler, and write replies.
84///
85/// Generic over the stream type so it can be used with both plain TCP and TLS
86/// connections.
87#[allow(dead_code)] // used by TlsIrcListener behind `tls` feature gate
88pub(super) async fn handle_connection(
89    stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
90    handler: MessageHandler,
91) -> Result<()> {
92    handle_connection_with_name(stream, handler, "localhost").await
93}
94
95/// Like [`handle_connection`], but allows specifying a custom server name for
96/// the welcome sequence.
97pub(super) async fn handle_connection_with_name(
98    stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
99    handler: MessageHandler,
100    server_name: &str,
101) -> Result<()> {
102    let (reader, mut writer) = tokio::io::split(stream);
103    let mut lines = BufReader::new(reader).lines();
104    let mut nick = String::new();
105    let mut user_seen = false;
106    let mut registered = false;
107
108    while let Some(line) = lines.next_line().await? {
109        let line = line.trim().to_string();
110        if line.is_empty() {
111            continue;
112        }
113
114        if let Some(rest) = line.strip_prefix("NICK ") {
115            nick = rest.trim().to_string();
116        } else if line.starts_with("USER ") {
117            user_seen = true;
118        } else if line.starts_with("PING ") {
119            let token = line.trim_start_matches("PING ");
120            writer
121                .write_all(format!("PONG {}\r\n", token).as_bytes())
122                .await?;
123        } else if let Some(rest) = line.strip_prefix("PRIVMSG ") {
124            let parts: Vec<&str> = rest.splitn(2, ' ').collect();
125            if parts.len() == 2 {
126                let target = parts[0];
127                let content = parts[1].trim_start_matches(':');
128
129                // Detect CTCP ACTION and map to MessageType::Action
130                let (msg_content, msg_type) =
131                    if content.starts_with(CTCP_DELIM) && content.ends_with(CTCP_DELIM) {
132                        let inner = content
133                            .trim_start_matches(CTCP_DELIM)
134                            .trim_end_matches(CTCP_DELIM);
135                        if let Some(action_text) = inner.strip_prefix("ACTION ") {
136                            (action_text.to_string(), MessageType::Action)
137                        } else {
138                            // Other CTCP in server context — skip
139                            continue;
140                        }
141                    } else {
142                        (content.to_string(), MessageType::Text)
143                    };
144
145                let msg = Message {
146                    id: format!("irc-{}", chrono::Utc::now().timestamp_millis()),
147                    sender: nick.clone(),
148                    content: msg_content,
149                    timestamp: chrono::Utc::now().timestamp(),
150                    channel: Some(target.to_string()),
151                    reply_to: None,
152                    thread_id: None,
153                    media: None,
154                    is_direct: !target.starts_with('#'),
155                    message_type: msg_type,
156                    edited_timestamp: None,
157                    reactions: None,
158                };
159                if let Ok(Some(reply)) = handler(msg).await {
160                    let response = format!(
161                        ":{server_name}!{server_name}@{server_name} PRIVMSG {} :{}\r\n",
162                        target, reply
163                    );
164                    writer.write_all(response.as_bytes()).await?;
165                }
166            }
167        } else if let Some(rest) = line.strip_prefix("NOTICE ") {
168            // Parse NOTICE similarly to PRIVMSG but deliver as System
169            let parts: Vec<&str> = rest.splitn(2, ' ').collect();
170            if parts.len() == 2 {
171                let target = parts[0];
172                let content = parts[1].trim_start_matches(':');
173                let msg = Message {
174                    id: format!("irc-{}", chrono::Utc::now().timestamp_millis()),
175                    sender: nick.clone(),
176                    content: content.to_string(),
177                    timestamp: chrono::Utc::now().timestamp(),
178                    channel: Some(target.to_string()),
179                    reply_to: None,
180                    thread_id: None,
181                    media: None,
182                    is_direct: !target.starts_with('#'),
183                    message_type: MessageType::System,
184                    edited_timestamp: None,
185                    reactions: None,
186                };
187                // NOTICEs do not generate automatic replies per IRC spec
188                let _ = handler(msg).await;
189            }
190        } else if let Some(rest) = line.strip_prefix("JOIN ") {
191            let channel = rest.trim().trim_start_matches(':');
192            // Echo JOIN back to the client
193            writer
194                .write_all(format!(":{nick}!{nick}@{server_name} JOIN {channel}\r\n").as_bytes())
195                .await?;
196        } else if line.starts_with("PART ") || line.starts_with("TOPIC ") {
197            // Acknowledge silently for now
198        } else if line == "QUIT" || line.starts_with("QUIT ") {
199            break;
200        }
201
202        if !registered && !nick.is_empty() && user_seen {
203            send_welcome(&mut writer, server_name, &nick).await?;
204            registered = true;
205        }
206    }
207    Ok(())
208}
209
210#[async_trait]
211impl ChatListener for IrcListener {
212    fn address(&self) -> &str {
213        &self.address
214    }
215
216    fn protocol(&self) -> &str {
217        "irc"
218    }
219
220    async fn start(
221        &mut self,
222        handler: MessageHandler,
223        alive: tokio::sync::mpsc::Sender<()>,
224    ) -> Result<()> {
225        let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
226        let listener = TcpListener::bind(&self.address).await?;
227        // Update to the actual bound address (useful when port 0 is requested).
228        self.address = listener.local_addr()?.to_string();
229        tracing::info!(address = %self.address, "IRC listener bound");
230        self.shutdown_tx = Some(shutdown_tx);
231        let server_name = self.server_name.clone();
232
233        tokio::spawn(async move {
234            // Hold `alive` — when this task exits, the sender is dropped,
235            // signalling the server that this listener has stopped.
236            let _alive = alive;
237
238            loop {
239                tokio::select! {
240                    result = listener.accept() => {
241                        match result {
242                            Ok((stream, peer)) => {
243                                tracing::debug!(%peer, "IRC listener: new connection");
244                                let h = Arc::clone(&handler);
245                                let sn = server_name.clone();
246                                tokio::spawn(async move {
247                                    if let Err(e) = handle_connection_with_name(stream, h, &sn).await {
248                                        tracing::warn!("IRC connection error: {e}");
249                                    }
250                                });
251                            }
252                            Err(e) => {
253                                tracing::warn!("IRC listener accept error: {e}");
254                                break;
255                            }
256                        }
257                    }
258                    _ = shutdown_rx.changed() => {
259                        if *shutdown_rx.borrow() {
260                            break;
261                        }
262                    }
263                }
264            }
265        });
266
267        Ok(())
268    }
269
270    async fn shutdown(&mut self) -> Result<()> {
271        if let Some(tx) = &self.shutdown_tx {
272            let _ = tx.send(true);
273        }
274        Ok(())
275    }
276}