tmi_parser/
message.rs

1//! IRC-based TMI messages.
2
3use crate::{TagValue, Tags};
4use std::io::{Error, ErrorKind, Result};
5
6/// Possible types of TMI messages.
7/// Unrecognized messages are handled by the associated [`parse`] function.
8///
9/// Tags are always treated as Optional even on messages that require them.
10/// Actually, tags validation should be done by the user code.
11///
12/// Consider changing simple enum structs to enum tuples.
13#[derive(Debug, PartialEq)]
14pub enum Message<'a> {
15    /// Represents a ping request message.
16    /// `PING :<endpoint>`
17    Ping,
18    /// Represents a pong response message.
19    /// `PONG :<endpoint>`
20    Pong,
21    /// Represents a capability request message.
22    /// `CAP REQ :<capability>`
23    CapReq { req: &'a str },
24    /// Represents a capability acknowledgement message.
25    /// `:<endpoint> CAP * ACK :<capability>`
26    CapAck { req: &'a str },
27    /// Represents a password authentication message.
28    /// `PASS <password>`
29    /// `PASS oauth:<token>` (using Twitch OAuth tokens)
30    Pass { pass: &'a str },
31    /// Represents a nickname authentication message.
32    /// `NICK <user>`
33    Nick { nick: &'a str },
34    /// Represents a join command message.
35    /// `JOIN #<channel>`
36    Join { chan: &'a str },
37    /// Represents a part command message.
38    /// `PART #<channel>`
39    Part { chan: &'a str },
40    /// Represents a privmsg command message.
41    /// `[@<tags>] PRIVMSG #<channel> :<message>`
42    Privmsg {
43        tags: Option<Tags<'a>>,
44        chan: &'a str,
45        msg: &'a str,
46    },
47    /// Represents a clearchat command message.
48    /// `[@<tags>] :<endpoint> CLEARCHAT #<channel> [:<user>]`
49    Clearchat {
50        tags: Option<Tags<'a>>,
51        chan: &'a str,
52        usr: Option<&'a str>,
53    },
54    /// Represents a clearmsg command message.
55    /// `[@<tags>] :<endpoint> CLEARMSG #<channel> [:<message>]`
56    Clearmsg {
57        tags: Option<Tags<'a>>,
58        chan: &'a str,
59        msg: &'a str,
60    },
61    /// Represents a hosttarget start message.
62    /// `:<endpoint> HOSTTARGET #<host> :<channel> [<viewers>]`
63    HosttargetStart {
64        host: &'a str,
65        chan: &'a str,
66        view: Option<u32>,
67    },
68    /// Represents a hosttarget end message.
69    /// `:<endpoint> HOSTTARGET #<host> :- [<viewers>]`
70    HosttargetEnd { host: &'a str, view: Option<u32> },
71    /// Represents a notice message.
72    /// `[@<tags>] :<endpoint> NOTICE #<channel> :<message>`
73    Notice {
74        tags: Option<Tags<'a>>,
75        chan: &'a str,
76        msg: &'a str,
77    },
78    /// Represents a reconnect request message.
79    /// `RECONNECT`
80    Reconnect,
81    /// Represents a roomstate message.
82    /// `[@<tags>] :<endpoint> ROOMSTATE #<channel>`
83    Roomstate {
84        tags: Option<Tags<'a>>,
85        chan: &'a str,
86    },
87    /// Represents a usernotice message.
88    /// `[@<tags>] :<endpoint> USERNOTICE #<channel> :<message>`
89    Usernotice {
90        tags: Option<Tags<'a>>,
91        chan: &'a str,
92        msg: &'a str,
93    },
94    /// Represents a userstate message.
95    /// `[@<tags>] :<endpoint> USERSTATE #<channel>`
96    Userstate {
97        tags: Option<Tags<'a>>,
98        chan: &'a str,
99    },
100    /// Represents a global userstate message.
101    /// `[@<tags>] :<endpoint> GLOBALUSERSTATE`
102    GlobalUserstate { tags: Option<Tags<'a>> },
103}
104
105impl<'a> Message<'a> {
106    /// Parses a [`& str`] slice and returns a Message if successful, otherwise an [`io::Error`].
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// let s = ":tmi.twitch.tv CLEARCHAT #dallas :ronni";
112    /// let msg = tmi_parser::Message::parse(s);
113    /// ```
114    pub fn parse(msg: &'a str) -> Result<Message> {
115        if msg.len() < 5 {
116            return Err(Error::new(ErrorKind::Other, "Malformed message."));
117        }
118
119        let buf = msg.trim();
120        let (tags, off) = if let Some(buf) = buf.strip_prefix('@') {
121            Self::parse_tags(buf)?
122        } else {
123            (None, 0)
124        };
125
126        let mut rest = &buf[off..];
127        const ENDPOINT: &str = "tmi.twitch.tv ";
128
129        if let Some(off) = rest.find(ENDPOINT) {
130            rest = &rest[(off + ENDPOINT.len())..];
131        }
132
133        if let Some(off) = rest.find(' ') {
134            let cmd = &rest[..off];
135            let body = &rest[(off + 1)..];
136
137            Self::parse_command(cmd, body, tags)
138        } else {
139            Self::parse_command(rest, "", tags)
140        }
141    }
142
143    /// Helper function for parsing message tags.
144    fn parse_tags(msg: &'a str) -> Result<(Option<Tags<'a>>, usize)> {
145        let mut map = Tags::default();
146
147        if let Some(idx) = msg.find(" :") {
148            let tag = &msg[..idx];
149            let toks = tag.split(';');
150
151            for tok in toks {
152                let items = tok.split('=').collect::<Vec<_>>();
153                let key = items[0];
154                let val = items[1];
155
156                map.insert(key, TagValue::new(val));
157            }
158
159            Ok((Some(map), idx + 3))
160        } else {
161            Err(Error::new(ErrorKind::Other, "Parsing message tags failed."))
162        }
163    }
164
165    /// Helper function for parsing message body base on the command.
166    fn parse_command(cmd: &'a str, body: &'a str, tags: Option<Tags<'a>>) -> Result<Message<'a>> {
167        Ok(match cmd {
168            "PING" => Message::Ping,
169            "PONG" => Message::Pong,
170            "CAP" => {
171                let off = body
172                    .find(" :")
173                    .ok_or_else(|| Error::new(ErrorKind::Other, "Malformed CAP command."))?;
174
175                match &body[..off] {
176                    "REQ" => Message::CapReq {
177                        req: &body[(off + 2)..],
178                    },
179                    "* ACK" => Message::CapAck {
180                        req: &body[(off + 2)..],
181                    },
182                    _ => return Err(Error::new(ErrorKind::Other, "Malformed CAP command.")),
183                }
184            }
185            "PASS" => Message::Pass { pass: body },
186            "NICK" => Message::Nick { nick: body },
187            "JOIN" => Message::Join { chan: &body[1..] },
188            "PART" => Message::Part { chan: &body[1..] },
189            "PRIVMSG" => {
190                let off = body
191                    .find(" :")
192                    .ok_or_else(|| Error::new(ErrorKind::Other, "Malformed PRIVMSG command."))?;
193
194                Message::Privmsg {
195                    tags,
196                    chan: &body[1..off],
197                    msg: &body[(off + 2)..],
198                }
199            }
200            "CLEARCHAT" => {
201                if let Some(off) = body.find(" :") {
202                    Message::Clearchat {
203                        tags,
204                        chan: &body[1..off],
205                        usr: Some(&body[(off + 2)..]),
206                    }
207                } else {
208                    Message::Clearchat {
209                        tags,
210                        chan: &body[1..],
211                        usr: None,
212                    }
213                }
214            }
215            "CLEARMSG" => {
216                let off = body
217                    .find(" :")
218                    .ok_or_else(|| Error::new(ErrorKind::Other, "Malformed CLEARMSG command."))?;
219
220                Message::Clearmsg {
221                    tags,
222                    chan: &body[1..off],
223                    msg: &body[(off + 2)..],
224                }
225            }
226            "HOSTTARGET" => {
227                if let Some(off) = body.find(" :-") {
228                    if body.len() > off + 5 {
229                        if let Ok(view) = body[(off + 4)..].parse::<u32>() {
230                            Message::HosttargetEnd {
231                                host: &body[1..off],
232                                view: Some(view),
233                            }
234                        } else {
235                            return Err(Error::new(
236                                ErrorKind::Other,
237                                "Malformed HOSTTARGET command.",
238                            ));
239                        }
240                    } else {
241                        Message::HosttargetEnd {
242                            host: &body[1..off],
243                            view: None,
244                        }
245                    }
246                } else {
247                    let off = body.find(" :").ok_or_else(|| {
248                        Error::new(ErrorKind::Other, "Malformed HOSTTARGET command.")
249                    })?;
250
251                    if body.len() < off + 3 {
252                        return Err(Error::new(
253                            ErrorKind::Other,
254                            "Malformed HOSTTARGET command.",
255                        ));
256                    }
257
258                    let host = &body[1..off];
259                    let body = &body[(off + 2)..];
260
261                    if let Some(off) = body.find(' ') {
262                        if body.len() > off + 2 {
263                            if let Ok(view) = body[(off + 1)..].parse::<u32>() {
264                                return Ok(Message::HosttargetStart {
265                                    host,
266                                    chan: &body[..off],
267                                    view: Some(view),
268                                });
269                            } else {
270                                return Err(Error::new(
271                                    ErrorKind::Other,
272                                    "Malformed HOSTTARGET command.",
273                                ));
274                            }
275                        } else {
276                            Message::HosttargetStart {
277                                host,
278                                chan: body,
279                                view: None,
280                            }
281                        }
282                    } else {
283                        Message::HosttargetStart {
284                            host,
285                            chan: body,
286                            view: None,
287                        }
288                    }
289                }
290            }
291            "NOTICE" => {
292                let off = body
293                    .find(" :")
294                    .ok_or_else(|| Error::new(ErrorKind::Other, "Malformed NOTICE command."))?;
295
296                Message::Notice {
297                    tags,
298                    chan: &body[1..off],
299                    msg: &body[(off + 2)..],
300                }
301            }
302            "RECONNECT" => Message::Reconnect,
303            "ROOMSTATE" => Message::Roomstate {
304                tags,
305                chan: &body[1..],
306            },
307            "USERNOTICE" => {
308                let off = body
309                    .find(" :")
310                    .ok_or_else(|| Error::new(ErrorKind::Other, "Malformed USERNOTICE command."))?;
311
312                Message::Usernotice {
313                    tags,
314                    chan: &body[1..off],
315                    msg: &body[(off + 2)..],
316                }
317            }
318            "USERSTATE" => Message::Userstate {
319                tags,
320                chan: &body[1..],
321            },
322            "GLOBALUSERSTATE" => Message::GlobalUserstate { tags },
323            _ => {
324                return Err(Error::new(
325                    ErrorKind::Other,
326                    "Parsing message command failed.",
327                ))
328            }
329        })
330    }
331}