Skip to main content

loco_twitch/irc/
mod.rs

1use std::{
2    collections::HashMap,
3    io::{Read, Result as IOResult, Write},
4    net::TcpStream,
5    thread,
6    time::Duration,
7};
8
9use fancy_regex::Regex;
10
11use self::parser::Parser;
12
13mod parser;
14
15const IRC_PORT: u16 = 6667;
16const IRC_URL: &str = "irc.chat.twitch.tv";
17
18
19#[derive(Debug)]
20/// Error types
21pub enum IrcError {
22    Timeout,
23    Host(String),
24    MaxAttemps,
25    Permission,
26    Aborted,
27    Unknown,
28}
29
30/// Return a Irc Object or an IrcError
31pub type IrcResult = Result<Irc, IrcError>;
32
33impl From<std::io::Error> for IrcError {
34    fn from(err: std::io::Error) -> Self {
35        use std::io::ErrorKind;
36        match err.kind() {
37            ErrorKind::ConnectionReset => Self::Host("connection reset by peer".into()),
38            ErrorKind::ConnectionRefused => Self::Host("connection refused by host".into()),
39            ErrorKind::NotFound => Self::Host("unknown host".into()),
40            ErrorKind::PermissionDenied => Self::Permission,
41            ErrorKind::ConnectionAborted => Self::Aborted,
42            ErrorKind::BrokenPipe => Self::Host("broken pipe".into()),
43            _ => Self::Unknown,
44        }
45    }
46}
47
48/// IRC Commands
49pub enum Command {
50    /// Account OAuth Pass
51    Pass, 
52    /// Account nickname
53    Nick, 
54    /// Join a Channel
55    Join, 
56    /// Pong a ping
57    Pong, 
58    /// Ping IRC Twitch Chat
59    Ping, 
60    /// Send chat message
61    Privmsg 
62}
63
64impl Command {
65    pub fn build<T>(&self, arg: String, connection: &LocoConnection<T>) -> String
66    where
67        T: Read + Write + Unpin,
68    {
69        let prefix = match self {
70            Self::Pass => "PASS oauth:".into(),
71            Self::Nick => "NICK ".into(),
72            Self::Join => "JOIN #".into(),
73            Self::Pong => "PONG :tmi.twitch.tv".into(),
74            Self::Ping => "PING".into(),
75            Self::Privmsg => format!("PRIVMSG #{} :", connection.config.channel_to_join.clone()),
76        };
77        format!("{}{}\r\n", prefix, &arg)
78    }
79}
80
81
82/// Connection T is only for a mock in tests, 
83/// Use new method instead
84pub struct LocoConnection<T>
85where
86    T: Read + Write + Unpin,
87{
88    connection: Option<T>,
89    config: LocoConfig,
90}
91
92
93/// Configuration of authentication in IRC Twitch Chat
94#[derive(Clone)]
95pub struct LocoConfig {
96    oauth: String,
97    nickname: String,
98    channel_to_join: String,
99}
100
101/// IRC event
102#[derive(Debug)]
103pub struct Irc {
104    /// Type of IRC Event
105    pub irc_type: IrcType,
106    /// Only have nickname in event
107    pub nickname: Option<String>,
108    /// Message if as PRIVMSG event
109    pub keys: Option<HashMap<String, String>>,
110    /// Channel of event
111    pub channel: String,
112    /// Message if as PRIVMSG event
113    pub message: Option<String>,
114}
115
116impl Irc {
117    pub fn new(
118        irc_type: IrcType,
119        nickname: Option<String>,
120        keys: Option<HashMap<String, String>>,
121        channel: String,
122        message: Option<String>,
123    ) -> Self {
124        Self {
125            irc_type,
126            nickname,
127            keys,
128            channel,
129            message,
130        }
131    }
132}
133
134#[derive(Debug)]
135pub enum IrcType {
136    Message,
137    Join,
138    Part,
139    Usernotice,
140    CleanChat,
141    Pong,
142    Ping,
143    UserState,
144    Notice,
145    Unknown,
146}
147
148impl IrcType {
149    #[doc(hidden)]
150    fn display_name(&self) -> Regex {
151        let expr = match self {
152            Self::Message => r"(?<=:)(\w+)(?=!)",
153            Self::Join => r"(?<=:)(\w+)(?=!)",
154            Self::Part => r"(?<=:)(\w+)(?=!)",
155            Self::Usernotice => r"(?<=display-name=)([\w]+)",
156            Self::CleanChat => r"(?<=:)([\w]+)(?!.)",
157            Self::Pong => r"TODOU",
158            Self::Ping => r"TODOU",
159            Self::UserState => r"(?<=display-name=)([\w]+)",
160            Self::Notice => r"TODOU",
161            _ => r"TODOU",
162        };
163        Regex::new(expr).unwrap()
164    }
165}
166
167#[doc(hidden)]
168impl From<String> for IrcType {
169    fn from(value: String) -> Self {
170        match &value[..] {
171            "PRIVMSG" => Self::Message,
172            "JOIN" => Self::Join,
173            "PART" => Self::Part,
174            "USERNOTICE" => Self::Usernotice,
175            "CLEARCHAT" => Self::CleanChat,
176            "PING" => Self::Ping,
177            "PONG" => Self::Pong,
178            "NOTICE" => Self::Notice,
179            _ => Self::Unknown,
180        }
181    }
182}
183
184impl LocoConfig {
185    /// Returns a Config Object
186    pub fn new(oauth: String, nickname: String, channel_to_join: String) -> Self {
187        Self {
188            oauth,
189            nickname,
190            channel_to_join,
191        }
192    }
193}
194
195impl LocoConnection<TcpStream> {
196    /// Initialize a Tcp Connection
197    pub fn new(loco_config: LocoConfig) -> Result<LocoConnection<TcpStream>, IrcError> {
198        let con: LocoConnection<TcpStream> = LocoConnection::try_connect(loco_config)?;
199        Ok(con)
200    }
201
202    fn try_connect(loco_config: LocoConfig) -> Result<LocoConnection<TcpStream>, IrcError> {
203        const MAX_ATTEMPS: usize = 3;
204        for attempt in 0..MAX_ATTEMPS {
205            println!("connection attempt {att}", att = attempt + 1);
206            match TcpStream::connect(&format!("{}:{}", IRC_URL, IRC_PORT)) {
207                Ok(connection) => {
208                    let mut loco_connection = LocoConnection {
209                        connection: Some(connection),
210                        config: loco_config.clone(),
211                    };
212                    loco_connection.batch_command(&[
213                        Command::Pass.build(loco_config.oauth.clone(), &loco_connection),
214                        Command::Nick.build(loco_config.nickname.clone(), &loco_connection),
215                        Command::Join.build(loco_config.channel_to_join, &loco_connection),
216                        "CAP REQ :twitch.tv/commands\r\n".into(),
217                        "CAP REQ :twitch.tv/membership\r\n".into(),
218                        "CAP REQ :twitch.tv/tags\r\n".into(),
219                    ])?;
220                    return Ok(loco_connection);
221                }
222                _ => {
223                    if attempt == MAX_ATTEMPS {
224                        return Err(IrcError::MaxAttemps);
225                    }
226                    thread::sleep(Duration::from_secs((2_u64).pow(attempt as u32)))
227                }
228            }
229        }
230        Err(IrcError::Unknown)
231    }
232
233    fn batch_command(&mut self, vec: &[String]) -> IOResult<()> {
234        let map = vec.iter().flat_map(|val| val.bytes()).collect::<Vec<u8>>();
235        if let Some(connection) = &mut self.connection {
236            connection.write_all(&map)?;
237        }
238        Ok(())
239    }
240
241    /// Send a command to IRC
242    pub fn send_command(&mut self, command: Command, arg: &str) -> IOResult<()> {
243        let command = command.build(arg.into(), self);
244        self.batch_command(&[command])?;
245        Ok(())
246    }
247
248    /// Another way to handle messages, but cannot send commands with the same connection
249    //TODO: greceful shutdown
250    pub fn read(&mut self, exec: impl Fn(Irc)) {
251        for irc in self {
252            exec(irc)
253        }
254    }
255}
256
257impl<T> Iterator for LocoConnection<T>
258where
259    T: Read + Write + Unpin,
260{
261    type Item = Irc;
262
263    fn next(&mut self) -> Option<Self::Item> {
264        let mut irc: Option<Self::Item> = None;
265        if let Some(connection) = &mut self.connection {
266            let mut buf = [0; 1024];
267            if connection.read(&mut buf).is_ok() {
268                if let Ok(msg) = String::from_utf8(Vec::from(buf)) {
269                    if let Ok(value) = Parser.parse(msg) {
270                        irc = Some(value);
271                    }
272                }
273            }
274        }
275        irc
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn build_commands() {
285        let fake_conn: LocoConnection<TcpStream> = LocoConnection {
286            connection: None,
287            config: LocoConfig {
288                oauth: "test".into(),
289                nickname: "test".into(),
290                channel_to_join: "test".into(),
291            },
292        };
293        let inputs = [
294            (Command::Join, "test", "JOIN #test\r\n"),
295            (Command::Nick, "test", "NICK test\r\n"),
296            (Command::Privmsg, "test", "PRIVMSG #test :test\r\n"),
297            (Command::Pass, "test", "PASS oauth:test\r\n"),
298        ];
299
300        for (command, param, expected) in inputs {
301            assert_eq!(expected, command.build(param.into(), &fake_conn))
302        }
303    }
304}