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#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "with-serde", derive(Serialize, Deserialize))]
13pub struct PrivmsgMessage {
14 pub channel_login: String,
16 pub channel_id: String,
18 pub message_text: String,
20 pub reply_parent: Option<ReplyParent>,
22 pub is_action: bool,
30 pub sender: TwitchUserBasics,
32 pub badge_info: Vec<Badge>,
39 pub badges: Vec<Badge>,
41 pub bits: Option<u64>,
43 pub name_color: Option<RGBColor>,
48 pub emotes: Vec<Emote>,
51 pub message_id: String,
54 pub server_timestamp: DateTime<Utc>,
56
57 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 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 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 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 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 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 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}