Skip to main content

twitch_irc/message/commands/
privmsg.rs

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