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)]
20pub enum IrcError {
22 Timeout,
23 Host(String),
24 MaxAttemps,
25 Permission,
26 Aborted,
27 Unknown,
28}
29
30pub 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
48pub enum Command {
50 Pass,
52 Nick,
54 Join,
56 Pong,
58 Ping,
60 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
82pub struct LocoConnection<T>
85where
86 T: Read + Write + Unpin,
87{
88 connection: Option<T>,
89 config: LocoConfig,
90}
91
92
93#[derive(Clone)]
95pub struct LocoConfig {
96 oauth: String,
97 nickname: String,
98 channel_to_join: String,
99}
100
101#[derive(Debug)]
103pub struct Irc {
104 pub irc_type: IrcType,
106 pub nickname: Option<String>,
108 pub keys: Option<HashMap<String, String>>,
110 pub channel: String,
112 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 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 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 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 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}