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#[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 is_action: bool,
28 pub sender: TwitchUserBasics,
30 pub badge_info: Vec<Badge>,
37 pub badges: Vec<Badge>,
39 pub bits: Option<u64>,
41 pub name_color: Option<RGBColor>,
46 pub emotes: Vec<Emote>,
49 pub message_id: String,
52 pub server_timestamp: DateTime<Utc>,
54
55 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 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 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 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 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 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 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}