twitch_irc/message/commands/
privmsg.rs

1use crate::message::commands::IRCMessageParseExt;
2use crate::message::twitch::{Badge, Emote, RGBColor, TwitchUserBasics};
3use crate::message::{IRCMessage, ReplyToMessage, ServerMessageParseError};
4use chrono::{DateTime, Utc};
5use std::convert::TryFrom;
6
7#[cfg(feature = "with-serde")]
8use {serde::Deserialize, serde::Serialize};
9
10/// A regular Twitch chat message.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "with-serde", derive(Serialize, Deserialize))]
13pub struct PrivmsgMessage {
14    /// Login name of the channel that the message was sent to.
15    pub channel_login: String,
16    /// ID of the channel that the message was sent to.
17    pub channel_id: String,
18    /// The message text that was sent.
19    pub message_text: String,
20    /// Whether this message was made using the `/me` command.
21    ///
22    /// These type of messages are typically fully colored with `name_color` and
23    /// have no `:` separating the sending user and the message.
24    ///
25    /// The `message_text` does not contain the `/me` command or the control sequence
26    /// (`\x01ACTION <msg>\x01`) that is used for these action messages.
27    pub is_action: bool,
28    /// The user that sent this message.
29    pub sender: TwitchUserBasics,
30    /// Metadata related to the chat badges in the `badges` tag.
31    ///
32    /// Currently this is used only for `subscriber`, to indicate the exact number of months
33    /// the user has been a subscriber. This number is finer grained than the version number in
34    /// badges. For example, a user who has been a subscriber for 45 months would have a
35    /// `badge_info` value of 45 but might have a `badges` `version` number for only 3 years.
36    pub badge_info: Vec<Badge>,
37    /// List of badges that should be displayed alongside the message.
38    pub badges: Vec<Badge>,
39    /// If present, specifies how many bits were cheered with this message.
40    pub bits: Option<u64>,
41    /// If present, specifies the color that the user's name should be displayed in. A value
42    /// of `None` here signifies that the user has not picked any particular color.
43    /// Implementations differ on how they handle this, on the Twitch website users are assigned
44    /// a pseudorandom but consistent-per-user color if they have no color specified.
45    pub name_color: Option<RGBColor>,
46    /// A list of emotes in this message. Each emote replaces a part of the `message_text`.
47    /// These emotes are sorted in the order that they appear in the message.
48    pub emotes: Vec<Emote>,
49    /// A string uniquely identifying this message. Can be used with the Twitch API to
50    /// delete single messages. See also the `CLEARMSG` message type.
51    pub message_id: String,
52    /// Timestamp of when this message was sent.
53    pub server_timestamp: DateTime<Utc>,
54
55    /// The message that this `PrivmsgMessage` was parsed from.
56    pub source: IRCMessage,
57}
58
59impl TryFrom<IRCMessage> for PrivmsgMessage {
60    type Error = ServerMessageParseError;
61
62    fn try_from(source: IRCMessage) -> Result<PrivmsgMessage, ServerMessageParseError> {
63        if source.command != "PRIVMSG" {
64            return Err(ServerMessageParseError::MismatchedCommand(source));
65        }
66
67        let (message_text, is_action) = source.try_get_message_text()?;
68
69        Ok(PrivmsgMessage {
70            channel_login: source.try_get_channel_login()?.to_owned(),
71            channel_id: source.try_get_nonempty_tag_value("room-id")?.to_owned(),
72            sender: TwitchUserBasics {
73                id: source.try_get_nonempty_tag_value("user-id")?.to_owned(),
74                login: source.try_get_prefix_nickname()?.to_owned(),
75                name: source
76                    .try_get_nonempty_tag_value("display-name")?
77                    .to_owned(),
78            },
79            badge_info: source.try_get_badges("badge-info")?,
80            badges: source.try_get_badges("badges")?,
81            bits: source.try_get_optional_number("bits")?,
82            name_color: source.try_get_color("color")?,
83            emotes: source.try_get_emotes("emotes", message_text)?,
84            server_timestamp: source.try_get_timestamp("tmi-sent-ts")?,
85            message_id: source.try_get_nonempty_tag_value("id")?.to_owned(),
86            message_text: message_text.to_owned(),
87            is_action,
88            source,
89        })
90    }
91}
92
93impl From<PrivmsgMessage> for IRCMessage {
94    fn from(msg: PrivmsgMessage) -> IRCMessage {
95        msg.source
96    }
97}
98
99impl ReplyToMessage for PrivmsgMessage {
100    fn channel_login(&self) -> &str {
101        &self.channel_login
102    }
103
104    fn message_id(&self) -> &str {
105        &self.message_id
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use crate::message::twitch::{Badge, Emote, RGBColor, TwitchUserBasics};
112    use crate::message::{IRCMessage, PrivmsgMessage};
113    use chrono::offset::TimeZone;
114    use chrono::Utc;
115    use std::convert::TryFrom;
116    use std::ops::Range;
117
118    #[test]
119    fn test_basic_example() {
120        let src = "@badge-info=;badges=;color=#0000FF;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada :dank cam";
121        let irc_message = IRCMessage::parse(src).unwrap();
122        let msg = PrivmsgMessage::try_from(irc_message.clone()).unwrap();
123
124        assert_eq!(
125            msg,
126            PrivmsgMessage {
127                channel_login: "pajlada".to_owned(),
128                channel_id: "11148817".to_owned(),
129                message_text: "dank cam".to_owned(),
130                is_action: false,
131                sender: TwitchUserBasics {
132                    id: "29803735".to_owned(),
133                    login: "jun1orrrr".to_owned(),
134                    name: "JuN1oRRRR".to_owned()
135                },
136                badge_info: vec![],
137                badges: vec![],
138                bits: None,
139                name_color: Some(RGBColor {
140                    r: 0x00,
141                    g: 0x00,
142                    b: 0xFF
143                }),
144                emotes: vec![],
145                server_timestamp: Utc.timestamp_millis_opt(1594545155039).unwrap(),
146                message_id: "e9d998c3-36f1-430f-89ec-6b887c28af36".to_owned(),
147
148                source: irc_message
149            }
150        );
151    }
152
153    #[test]
154    fn test_action_and_badges() {
155        let src = "@badge-info=subscriber/22;badges=moderator/1,subscriber/12;color=#19E6E6;display-name=randers;emotes=;flags=;id=d831d848-b7c7-4559-ae3a-2cb88f4dbfed;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1594555275886;turbo=0;user-id=40286300;user-type=mod :randers!randers@randers.tmi.twitch.tv PRIVMSG #pajlada :ACTION -tags";
156        let irc_message = IRCMessage::parse(src).unwrap();
157        let msg = PrivmsgMessage::try_from(irc_message.clone()).unwrap();
158
159        assert_eq!(
160            msg,
161            PrivmsgMessage {
162                channel_login: "pajlada".to_owned(),
163                channel_id: "11148817".to_owned(),
164                message_text: "-tags".to_owned(),
165                is_action: true,
166                sender: TwitchUserBasics {
167                    id: "40286300".to_owned(),
168                    login: "randers".to_owned(),
169                    name: "randers".to_owned()
170                },
171                badge_info: vec![Badge {
172                    name: "subscriber".to_owned(),
173                    version: "22".to_owned()
174                }],
175                badges: vec![
176                    Badge {
177                        name: "moderator".to_owned(),
178                        version: "1".to_owned()
179                    },
180                    Badge {
181                        name: "subscriber".to_owned(),
182                        version: "12".to_owned()
183                    }
184                ],
185                bits: None,
186                name_color: Some(RGBColor {
187                    r: 0x19,
188                    g: 0xE6,
189                    b: 0xE6
190                }),
191                emotes: vec![],
192                server_timestamp: Utc.timestamp_millis_opt(1594555275886).unwrap(),
193                message_id: "d831d848-b7c7-4559-ae3a-2cb88f4dbfed".to_owned(),
194
195                source: irc_message
196            }
197        );
198    }
199
200    #[test]
201    fn test_greyname_no_color() {
202        let src = "@rm-received-ts=1594554085918;historical=1;badge-info=;badges=;client-nonce=815810609edecdf4537bd9586994182b;color=;display-name=CarvedTaleare;emotes=;flags=;id=c9b941d9-a0ab-4534-9903-971768fcdf10;mod=0;room-id=22484632;subscriber=0;tmi-sent-ts=1594554085753;turbo=0;user-id=467684514;user-type= :carvedtaleare!carvedtaleare@carvedtaleare.tmi.twitch.tv PRIVMSG #forsen :NaM";
203        let irc_message = IRCMessage::parse(src).unwrap();
204        let msg = PrivmsgMessage::try_from(irc_message.clone()).unwrap();
205
206        assert_eq!(
207            msg,
208            PrivmsgMessage {
209                channel_login: "forsen".to_owned(),
210                channel_id: "22484632".to_owned(),
211                message_text: "NaM".to_owned(),
212                is_action: false,
213                sender: TwitchUserBasics {
214                    id: "467684514".to_owned(),
215                    login: "carvedtaleare".to_owned(),
216                    name: "CarvedTaleare".to_owned()
217                },
218                badge_info: vec![],
219                badges: vec![],
220                bits: None,
221                name_color: None,
222                emotes: vec![],
223                server_timestamp: Utc.timestamp_millis_opt(1594554085753).unwrap(),
224                message_id: "c9b941d9-a0ab-4534-9903-971768fcdf10".to_owned(),
225
226                source: irc_message
227            }
228        );
229    }
230
231    #[test]
232    fn test_display_name_with_trailing_space() {
233        let src = "@rm-received-ts=1594554085918;historical=1;badge-info=;badges=;client-nonce=815810609edecdf4537bd9586994182b;color=;display-name=CarvedTaleare\\s;emotes=;flags=;id=c9b941d9-a0ab-4534-9903-971768fcdf10;mod=0;room-id=22484632;subscriber=0;tmi-sent-ts=1594554085753;turbo=0;user-id=467684514;user-type= :carvedtaleare!carvedtaleare@carvedtaleare.tmi.twitch.tv PRIVMSG #forsen :NaM";
234        let irc_message = IRCMessage::parse(src).unwrap();
235        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
236        assert_eq!(msg.sender.name, "CarvedTaleare ");
237    }
238
239    #[test]
240    fn test_korean_display_name() {
241        let src = "@badge-info=subscriber/35;badges=moderator/1,subscriber/3024;color=#FF0000;display-name=테스트계정420;emotes=;flags=;id=bdfa278e-11c4-484f-9491-0a61b16fab60;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1593953876927;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :@asd";
242        let irc_message = IRCMessage::parse(src).unwrap();
243        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
244        assert_eq!(msg.sender.name, "테스트계정420");
245    }
246
247    #[test]
248    fn test_display_name_with_middle_space() {
249        let src = "@badge-info=;badges=;color=;display-name=Riot\\sGames;emotes=;flags=;id=bdfa278e-11c4-484f-9491-0a61b16fab60;mod=1;room-id=36029255;subscriber=0;tmi-sent-ts=1593953876927;turbo=0;user-id=36029255;user-type= :riotgames!riotgames@riotgames.tmi.twitch.tv PRIVMSG #riotgames :test fake message";
250        let irc_message = IRCMessage::parse(src).unwrap();
251        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
252        assert_eq!(msg.sender.name, "Riot Games");
253        assert_eq!(msg.sender.login, "riotgames");
254    }
255
256    #[test]
257    fn test_emotes_1() {
258        let src = "@badge-info=subscriber/22;badges=moderator/1,subscriber/12;color=#19E6E6;display-name=randers;emotes=1902:6-10,29-33,35-39/499:45-46,48-49/490:51-52/25:0-4,12-16,18-22;flags=;id=f9c5774b-faa7-4378-b1af-c4e08b532dc2;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1594556065407;turbo=0;user-id=40286300;user-type=mod :randers!randers@randers.tmi.twitch.tv PRIVMSG #pajlada :Kappa Keepo Kappa Kappa test Keepo Keepo 123 :) :) :P";
259        let irc_message = IRCMessage::parse(src).unwrap();
260        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
261        assert_eq!(
262            msg.emotes,
263            vec![
264                Emote {
265                    id: "25".to_owned(),
266                    char_range: Range { start: 0, end: 5 },
267                    code: "Kappa".to_owned()
268                },
269                Emote {
270                    id: "1902".to_owned(),
271                    char_range: Range { start: 6, end: 11 },
272                    code: "Keepo".to_owned()
273                },
274                Emote {
275                    id: "25".to_owned(),
276                    char_range: Range { start: 12, end: 17 },
277                    code: "Kappa".to_owned()
278                },
279                Emote {
280                    id: "25".to_owned(),
281                    char_range: Range { start: 18, end: 23 },
282                    code: "Kappa".to_owned()
283                },
284                Emote {
285                    id: "1902".to_owned(),
286                    char_range: Range { start: 29, end: 34 },
287                    code: "Keepo".to_owned()
288                },
289                Emote {
290                    id: "1902".to_owned(),
291                    char_range: Range { start: 35, end: 40 },
292                    code: "Keepo".to_owned()
293                },
294                Emote {
295                    id: "499".to_owned(),
296                    char_range: Range { start: 45, end: 47 },
297                    code: ":)".to_owned()
298                },
299                Emote {
300                    id: "499".to_owned(),
301                    char_range: Range { start: 48, end: 50 },
302                    code: ":)".to_owned()
303                },
304                Emote {
305                    id: "490".to_owned(),
306                    char_range: Range { start: 51, end: 53 },
307                    code: ":P".to_owned()
308                },
309            ]
310        );
311    }
312
313    #[test]
314    fn test_emote_non_numeric_id() {
315        // emote tag specifies an index that's out of bounds.
316        let src = "@badge-info=;badges=;client-nonce=245b864d508a69a685e25104204bd31b;color=#FF144A;display-name=AvianArtworks;emote-only=1;emotes=300196486_TK:0-7;flags=;id=21194e0d-f0fa-4a8f-a14f-3cbe89366ad9;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594552113129;turbo=0;user-id=39565465;user-type= :avianartworks!avianartworks@avianartworks.tmi.twitch.tv PRIVMSG #pajlada :pajaM_TK";
317        let irc_message = IRCMessage::parse(src).unwrap();
318        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
319        assert_eq!(
320            msg.emotes,
321            vec![Emote {
322                id: "300196486_TK".to_owned(),
323                char_range: Range { start: 0, end: 8 },
324                code: "pajaM_TK".to_owned()
325            },]
326        );
327    }
328
329    #[test]
330    fn test_emote_after_emoji() {
331        // emojis are wider than one byte, tests that indices correctly refer
332        // to unicode scalar values, and not bytes in the utf-8 string
333        let src = "@badge-info=subscriber/22;badges=moderator/1,subscriber/12;color=#19E6E6;display-name=randers;emotes=483:2-3,7-8,12-13;flags=;id=3695cb46-f70a-4d6f-a71b-159d434c45b5;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1594557379272;turbo=0;user-id=40286300;user-type=mod :randers!randers@randers.tmi.twitch.tv PRIVMSG #pajlada :👉 <3 👉 <3 👉 <3";
334        let irc_message = IRCMessage::parse(src).unwrap();
335        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
336        assert_eq!(
337            msg.emotes,
338            vec![
339                Emote {
340                    id: "483".to_owned(),
341                    char_range: Range { start: 2, end: 4 },
342                    code: "<3".to_owned()
343                },
344                Emote {
345                    id: "483".to_owned(),
346                    char_range: Range { start: 7, end: 9 },
347                    code: "<3".to_owned()
348                },
349                Emote {
350                    id: "483".to_owned(),
351                    char_range: Range { start: 12, end: 14 },
352                    code: "<3".to_owned()
353                },
354            ]
355        );
356    }
357
358    #[test]
359    fn test_message_with_bits() {
360        let src = "@badge-info=;badges=bits/100;bits=1;color=#004B49;display-name=TETYYS;emotes=;flags=;id=d7f03a35-f339-41ca-b4d4-7c0721438570;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594571566672;turbo=0;user-id=36175310;user-type= :tetyys!tetyys@tetyys.tmi.twitch.tv PRIVMSG #pajlada :trihard1";
361        let irc_message = IRCMessage::parse(src).unwrap();
362        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
363        assert_eq!(msg.bits, Some(1));
364    }
365
366    #[test]
367    fn test_incorrect_emote_index() {
368        // emote index off by one.
369        let src = r"@badge-info=;badges=;color=;display-name=some_1_happy;emotes=425618:49-51;flags=24-28:A.3;id=9eb37414-0952-44cc-b177-ad8007088034;mod=0;room-id=35768443;subscriber=0;tmi-sent-ts=1597921035256;turbo=0;user-id=473035780;user-type= :some_1_happy!some_1_happy@some_1_happy.tmi.twitch.tv PRIVMSG #mocbka34 :Я не такой красивый. Не урод, но до тебя далеко LUL";
370        let irc_message = IRCMessage::parse(src).unwrap();
371        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
372
373        assert_eq!(
374            msg.emotes,
375            vec![Emote {
376                id: "425618".to_owned(),
377                char_range: 49..52,
378                code: "UL".to_owned(),
379            }]
380        );
381        assert_eq!(
382            msg.message_text,
383            "Я не такой красивый. Не урод, но до тебя далеко LUL"
384        );
385    }
386
387    #[test]
388    fn test_extremely_incorrect_emote_index() {
389        // emote index off by more than 1
390        let src = r"@badge-info=subscriber/3;badges=subscriber/3;color=#0000FF;display-name=Linkoping;emotes=25:41-45;flags=17-26:S.6;id=744f9c58-b180-4f46-bd9e-b515b5ef75c1;mod=0;room-id=188442366;subscriber=1;tmi-sent-ts=1566335866017;turbo=0;user-id=91673457;user-type= :linkoping!linkoping@linkoping.tmi.twitch.tv PRIVMSG #queenqarro :Då kan du begära skadestånd och förtal Kappa";
391        let irc_message = IRCMessage::parse(src).unwrap();
392        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
393
394        assert_eq!(
395            msg.emotes,
396            vec![Emote {
397                id: "25".to_owned(),
398                char_range: 41..46,
399                code: "ppa".to_owned(),
400            }]
401        );
402        assert_eq!(
403            msg.message_text,
404            "Då kan du begära skadestånd och förtal Kappa"
405        );
406    }
407
408    #[test]
409    fn test_emote_index_complete_out_of_range() {
410        // no overlap between string and specified range
411        let src = r"@badge-info=subscriber/3;badges=subscriber/3;color=#0000FF;display-name=Linkoping;emotes=25:44-48;flags=17-26:S.6;id=744f9c58-b180-4f46-bd9e-b515b5ef75c1;mod=0;room-id=188442366;subscriber=1;tmi-sent-ts=1566335866017;turbo=0;user-id=91673457;user-type= :linkoping!linkoping@linkoping.tmi.twitch.tv PRIVMSG #queenqarro :Då kan du begära skadestånd och förtal Kappa";
412        let irc_message = IRCMessage::parse(src).unwrap();
413        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
414
415        assert_eq!(
416            msg.emotes,
417            vec![Emote {
418                id: "25".to_owned(),
419                char_range: 44..49,
420                code: "".to_owned(),
421            }]
422        );
423    }
424
425    #[test]
426    fn test_emote_index_beyond_out_of_range() {
427        // no overlap between string and specified range
428        let src = r"@badge-info=subscriber/3;badges=subscriber/3;color=#0000FF;display-name=Linkoping;emotes=25:45-49;flags=17-26:S.6;id=744f9c58-b180-4f46-bd9e-b515b5ef75c1;mod=0;room-id=188442366;subscriber=1;tmi-sent-ts=1566335866017;turbo=0;user-id=91673457;user-type= :linkoping!linkoping@linkoping.tmi.twitch.tv PRIVMSG #queenqarro :Då kan du begära skadestånd och förtal Kappa";
429        let irc_message = IRCMessage::parse(src).unwrap();
430        let msg = PrivmsgMessage::try_from(irc_message).unwrap();
431
432        assert_eq!(
433            msg.emotes,
434            vec![Emote {
435                id: "25".to_owned(),
436                char_range: 45..50,
437                code: "".to_owned(),
438            }]
439        );
440    }
441}