use crate::message::{Message, MessageType};
use crate::server::{ChatListener, MessageHandler};
use anyhow::Result;
use async_trait::async_trait;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
const CTCP_DELIM: char = '\x01';
pub struct IrcListener {
address: String,
server_name: String,
shutdown_tx: Option<tokio::sync::watch::Sender<bool>>,
}
impl IrcListener {
pub fn new(address: impl Into<String>) -> Self {
Self {
address: address.into(),
server_name: "localhost".to_string(),
shutdown_tx: None,
}
}
pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
self.server_name = name.into();
self
}
}
async fn send_welcome(
writer: &mut (impl tokio::io::AsyncWrite + Unpin),
server_name: &str,
nick: &str,
) -> Result<()> {
let lines = [
format!(":{server_name} 001 {nick} :Welcome to the Internet Relay Network {nick}\r\n"),
format!(":{server_name} 002 {nick} :Your host is {server_name}, running chat-system\r\n"),
format!(":{server_name} 003 {nick} :This server was created with chat-system\r\n"),
format!(":{server_name} 004 {nick} {server_name} chat-system o o\r\n"),
];
for line in &lines {
writer.write_all(line.as_bytes()).await?;
}
Ok(())
}
#[allow(dead_code)] pub(super) async fn handle_connection(
stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
handler: MessageHandler,
) -> Result<()> {
handle_connection_with_name(stream, handler, "localhost").await
}
pub(super) async fn handle_connection_with_name(
stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
handler: MessageHandler,
server_name: &str,
) -> Result<()> {
let (reader, mut writer) = tokio::io::split(stream);
let mut lines = BufReader::new(reader).lines();
let mut nick = String::new();
let mut user_seen = false;
let mut registered = false;
while let Some(line) = lines.next_line().await? {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("NICK ") {
nick = rest.trim().to_string();
} else if line.starts_with("USER ") {
user_seen = true;
} else if line.starts_with("PING ") {
let token = line.trim_start_matches("PING ");
writer
.write_all(format!("PONG {}\r\n", token).as_bytes())
.await?;
} else if let Some(rest) = line.strip_prefix("PRIVMSG ") {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() == 2 {
let target = parts[0];
let content = parts[1].trim_start_matches(':');
let (msg_content, msg_type) =
if content.starts_with(CTCP_DELIM) && content.ends_with(CTCP_DELIM) {
let inner = content
.trim_start_matches(CTCP_DELIM)
.trim_end_matches(CTCP_DELIM);
if let Some(action_text) = inner.strip_prefix("ACTION ") {
(action_text.to_string(), MessageType::Action)
} else {
continue;
}
} else {
(content.to_string(), MessageType::Text)
};
let msg = Message {
id: format!("irc-{}", chrono::Utc::now().timestamp_millis()),
sender: nick.clone(),
content: msg_content,
timestamp: chrono::Utc::now().timestamp(),
channel: Some(target.to_string()),
reply_to: None,
thread_id: None,
media: None,
is_direct: !target.starts_with('#'),
message_type: msg_type,
edited_timestamp: None,
reactions: None,
};
if let Ok(Some(reply)) = handler(msg).await {
let response = format!(
":{server_name}!{server_name}@{server_name} PRIVMSG {} :{}\r\n",
target, reply
);
writer.write_all(response.as_bytes()).await?;
}
}
} else if let Some(rest) = line.strip_prefix("NOTICE ") {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() == 2 {
let target = parts[0];
let content = parts[1].trim_start_matches(':');
let msg = Message {
id: format!("irc-{}", chrono::Utc::now().timestamp_millis()),
sender: nick.clone(),
content: content.to_string(),
timestamp: chrono::Utc::now().timestamp(),
channel: Some(target.to_string()),
reply_to: None,
thread_id: None,
media: None,
is_direct: !target.starts_with('#'),
message_type: MessageType::System,
edited_timestamp: None,
reactions: None,
};
let _ = handler(msg).await;
}
} else if let Some(rest) = line.strip_prefix("JOIN ") {
let channel = rest.trim().trim_start_matches(':');
writer
.write_all(format!(":{nick}!{nick}@{server_name} JOIN {channel}\r\n").as_bytes())
.await?;
} else if line.starts_with("PART ") || line.starts_with("TOPIC ") {
} else if line == "QUIT" || line.starts_with("QUIT ") {
break;
}
if !registered && !nick.is_empty() && user_seen {
send_welcome(&mut writer, server_name, &nick).await?;
registered = true;
}
}
Ok(())
}
#[async_trait]
impl ChatListener for IrcListener {
fn address(&self) -> &str {
&self.address
}
fn protocol(&self) -> &str {
"irc"
}
async fn start(
&mut self,
handler: MessageHandler,
alive: tokio::sync::mpsc::Sender<()>,
) -> Result<()> {
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
let listener = TcpListener::bind(&self.address).await?;
self.address = listener.local_addr()?.to_string();
tracing::info!(address = %self.address, "IRC listener bound");
self.shutdown_tx = Some(shutdown_tx);
let server_name = self.server_name.clone();
tokio::spawn(async move {
let _alive = alive;
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, peer)) => {
tracing::debug!(%peer, "IRC listener: new connection");
let h = Arc::clone(&handler);
let sn = server_name.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_with_name(stream, h, &sn).await {
tracing::warn!("IRC connection error: {e}");
}
});
}
Err(e) => {
tracing::warn!("IRC listener accept error: {e}");
break;
}
}
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
}
}
});
Ok(())
}
async fn shutdown(&mut self) -> Result<()> {
if let Some(tx) = &self.shutdown_tx {
let _ = tx.send(true);
}
Ok(())
}
}