sl_chat_log_parser/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use chumsky::error::Simple;
4use chumsky::prelude::{any, just, none_of, one_of, take_until};
5use chumsky::text::whitespace;
6use chumsky::Parser;
7
8pub mod avatar_messages;
9pub mod system_messages;
10pub mod utils;
11
12/// represents an event commemorated in the Second Life chat log
13///
14/// large variant warning for clippy is overridden since the Box get in the way
15/// of properly pattern matching
16#[allow(clippy::large_enum_variant)]
17#[derive(Debug, Clone, PartialEq)]
18pub enum ChatLogEvent {
19    /// line about an avatar (or an object doing things indistinguishable from an avatar in the chat log)
20    AvatarLine {
21        /// name of the avatar or object
22        name: String,
23        /// message
24        message: crate::avatar_messages::AvatarMessage,
25    },
26    /// a message by the Second Life viewer or server itself
27    SystemMessage {
28        /// the system message
29        message: crate::system_messages::SystemMessage,
30    },
31    /// a message without a colon, most likely an unnamed object like a translator, spanker, etc.
32    OtherMessage {
33        /// the message
34        message: String,
35    },
36}
37
38/// parse a second life avatar name as it appears in the chat log before a message
39///
40/// # Errors
41///
42/// returns an error if the parser fails
43#[must_use]
44pub fn avatar_name_parser() -> impl Parser<char, String, Error = Simple<char>> {
45    none_of(":")
46        .repeated()
47        .collect::<String>()
48        .try_map(|s, _span: std::ops::Range<usize>| Ok(s))
49}
50
51/// parse a Second Life chat log event
52///
53/// # Errors
54///
55/// returns an error if the parser fails
56#[must_use]
57fn chat_log_event_parser() -> impl Parser<char, ChatLogEvent, Error = Simple<char>> {
58    just("Second Life: ")
59        .ignore_then(
60            take_until(
61                crate::avatar_messages::avatar_came_online_message_parser().or(
62                    crate::avatar_messages::avatar_went_offline_message_parser()
63                        .or(crate::avatar_messages::avatar_entered_area_message_parser())
64                        .or(crate::avatar_messages::avatar_left_area_message_parser()),
65                ),
66            )
67            .map(|(vc, msg)| (vc.into_iter().collect::<String>(), msg))
68            .map(|(name, message)| ChatLogEvent::AvatarLine {
69                name: name.strip_suffix(" ").unwrap_or(&name).to_owned(),
70                message,
71            }),
72        )
73        .or(just("Second Life: ").ignore_then(
74            crate::system_messages::system_message_parser()
75                .map(|message| ChatLogEvent::SystemMessage { message }),
76        ))
77        .or(avatar_name_parser()
78            .then_ignore(just(":").then(whitespace()))
79            .then(crate::avatar_messages::avatar_message_parser())
80            .map(|(name, message)| ChatLogEvent::AvatarLine { name, message }))
81        .or(any()
82            .repeated()
83            .collect::<String>()
84            .map(|s| ChatLogEvent::OtherMessage { message: s }))
85}
86
87/// represents a Second Life chat log line
88#[derive(Debug, Clone, PartialEq)]
89pub struct ChatLogLine {
90    /// timestamp of the chat log line, some log lines do not have one because of bugs at the time they were written (e.g. some just have the time formatting string)
91    pub timestamp: Option<time::PrimitiveDateTime>,
92    /// event that happened at that time
93    pub event: ChatLogEvent,
94}
95
96/// parse a Second Life chat log line
97///
98/// # Errors
99///
100/// returns an error if the parser fails
101#[must_use]
102pub fn chat_log_line_parser() -> impl Parser<char, ChatLogLine, Error = Simple<char>> {
103    just("[")
104        .ignore_then(
105            one_of("0123456789")
106                .repeated()
107                .exactly(4)
108                .collect::<String>(),
109        )
110        .then(
111            just("/").ignore_then(
112                one_of("0123456789")
113                    .repeated()
114                    .exactly(2)
115                    .collect::<String>(),
116            ),
117        )
118        .then(
119            just("/").ignore_then(
120                one_of("0123456789")
121                    .repeated()
122                    .exactly(2)
123                    .collect::<String>(),
124            ),
125        )
126        .then(
127            just(" ").ignore_then(
128                one_of("0123456789")
129                    .repeated()
130                    .exactly(2)
131                    .collect::<String>(),
132            ),
133        )
134        .then(
135            just(":").ignore_then(
136                one_of("0123456789")
137                    .repeated()
138                    .exactly(2)
139                    .collect::<String>(),
140            ),
141        )
142        .then(
143            just(":")
144                .ignore_then(
145                    one_of("0123456789")
146                        .repeated()
147                        .exactly(2)
148                        .collect::<String>(),
149                )
150                .or_not(),
151        )
152        .then_ignore(just("]"))
153        .try_map(
154            |(((((year, month), day), hour), minute), second),
155             span: std::ops::Range<usize>| {
156                let second = second.unwrap_or("00".to_string());
157                let format = time::macros::format_description!(
158                    "[year]/[month]/[day] [hour]:[minute]:[second]"
159                );
160                Ok(Some(
161                    time::PrimitiveDateTime::parse(
162                        &format!("{}/{}/{} {}:{}:{}", year, month, day, hour, minute, second),
163                        format,
164                    ).map_err(|e| Simple::custom(span, format!("{:?}", e)))?
165                ))
166             }
167        )
168        .or(just("[[year,datetime,slt]/[mthnum,datetime,slt]/[day,datetime,slt] [hour,datetime,slt]:[min,datetime,slt]]").map(|_| None))
169        .then_ignore(whitespace())
170        .then(chat_log_event_parser())
171        .try_map(
172            |(timestamp, event),
173             _span: std::ops::Range<usize>| {
174                Ok(ChatLogLine {
175                    timestamp,
176                    event,
177                })
178            },
179        )
180}
181
182#[cfg(test)]
183mod test {
184    use std::io::{BufRead, BufReader};
185
186    use super::*;
187
188    /// used to deserialize the required options from the environment
189    #[derive(Debug, serde::Deserialize)]
190    struct EnvOptions {
191        #[serde(
192            deserialize_with = "serde_aux::field_attributes::deserialize_vec_from_string_or_vec"
193        )]
194        test_avatar_names: Vec<String>,
195    }
196
197    /// Error enum for the application
198    #[derive(thiserror::Error, Debug)]
199    pub enum TestError {
200        /// error loading environment
201        #[error("error loading environment: {0}")]
202        Env(#[from] envy::Error),
203        /// error loading .env file
204        #[error("error loading .env file: {0}")]
205        DotEnv(#[from] dotenvy::Error),
206        /// error determining current user home directory
207        #[error("error determining current user home directory")]
208        HomeDir,
209        /// error opening chat log file
210        #[error("error opening chat log file {0}: {1}")]
211        OpenChatLogFile(std::path::PathBuf, std::io::Error),
212        /// error reading chat log line from file
213        #[error("error reading chat log line from file: {0}")]
214        ChatLogLineRead(std::io::Error),
215    }
216
217    /// determine avatar log dir from avatar name
218    pub fn avatar_log_dir(avatar_name: &str) -> Result<std::path::PathBuf, TestError> {
219        let avatar_dir_name = avatar_name.replace(' ', "_").to_lowercase();
220        tracing::debug!("Avatar dir name: {}", avatar_dir_name);
221
222        let Some(home_dir) = dirs2::home_dir() else {
223            tracing::error!("Could not determine current user home directory");
224            return Err(TestError::HomeDir);
225        };
226
227        Ok(home_dir.join(".firestorm/").join(avatar_dir_name))
228    }
229
230    #[tracing_test::traced_test]
231    #[tokio::test]
232    async fn test_log_line_parser() -> Result<(), TestError> {
233        dotenvy::dotenv()?;
234        let env_options = envy::from_env::<EnvOptions>()?;
235        for avatar_name in env_options.test_avatar_names {
236            let avatar_dir = avatar_log_dir(&avatar_name)?;
237            let local_chat_log_file = avatar_dir.join("chat.txt");
238            let file = std::fs::File::open(&local_chat_log_file)
239                .map_err(|e| TestError::OpenChatLogFile(local_chat_log_file.clone(), e))?;
240            let file = BufReader::new(file);
241            let mut last_line: Option<String> = None;
242            for line in file.lines() {
243                let line = line.map_err(TestError::ChatLogLineRead)?;
244                if line.starts_with(" ") || line.is_empty() {
245                    if let Some(ll) = last_line {
246                        last_line = Some(format!("{}\n{}", ll, line));
247                        continue;
248                    }
249                }
250                if let Some(ref ll) = last_line {
251                    #[allow(clippy::panic)]
252                    match chat_log_line_parser().parse(ll.clone()) {
253                        Err(e) => {
254                            tracing::error!("failed to parse line\n{}", ll);
255                            for err in e {
256                                tracing::error!("{}", err);
257                            }
258                            panic!("Failed to parse a line");
259                        }
260                        Ok(parsed_line) => {
261                            if let ChatLogLine {
262                                timestamp: _,
263                                event:
264                                    ChatLogEvent::SystemMessage {
265                                        message:
266                                            system_messages::SystemMessage::OtherSystemMessage {
267                                                ref message,
268                                            },
269                                    },
270                            } = parsed_line
271                            {
272                                tracing::info!("parsed line\n{}\n{:?}", ll, parsed_line);
273                                if message.starts_with("The message sent to") {
274                                    if let Err(e) =
275                                        system_messages::chat_message_still_being_processed_message_parser()
276                                            .parse(message.to_string())
277                                    {
278                                        for e in e {
279                                            tracing::debug!("{}", utils::ChumskyError {
280                                                description: "group chat message still being processed".to_string(),
281                                                source: message.to_owned(),
282                                                errors: vec![e.to_owned()],
283                                            });
284                                        }
285                                    }
286                                }
287                                if message.contains("owned by") && message.contains("gave you") {
288                                    if let Err(e) =
289                                        system_messages::object_gave_object_message_parser()
290                                            .parse(message.to_string())
291                                    {
292                                        for e in e {
293                                            tracing::debug!(
294                                                "{}",
295                                                utils::ChumskyError {
296                                                    description: "owned by gave you".to_string(),
297                                                    source: message.to_owned(),
298                                                    errors: vec![e.to_owned()],
299                                                }
300                                            );
301                                        }
302                                    }
303                                }
304                                if message.contains("An object named")
305                                    && message.contains("gave you this folder")
306                                {
307                                    if let Err(e) =
308                                        system_messages::object_gave_folder_message_parser()
309                                            .parse(message.to_string())
310                                    {
311                                        for e in e {
312                                            tracing::debug!(
313                                                "{}",
314                                                utils::ChumskyError {
315                                                    description:
316                                                        "An object named ... gave you this folder"
317                                                            .to_string(),
318                                                    source: message.to_owned(),
319                                                    errors: vec![e.to_owned()],
320                                                }
321                                            );
322                                        }
323                                    }
324                                }
325                                if message.starts_with("Can't rez object")
326                                    && message.contains(
327                                        "because the owner of this land does not allow it",
328                                    )
329                                {
330                                    if let Err(e) =
331                                        system_messages::permission_to_rez_object_denied_message_parser()
332                                            .parse(message.to_string())
333                                    {
334                                        for e in e {
335                                            tracing::debug!("{}", utils::ChumskyError {
336                                                description: "permission to rez object denied".to_string(),
337                                                source: message.to_owned(),
338                                                errors: vec![e.to_owned()],
339                                            });
340                                        }
341                                    }
342                                }
343                                if message.starts_with("Teleport completed from") {
344                                    if let Err(e) =
345                                        system_messages::teleport_completed_message_parser()
346                                            .parse(message.to_string())
347                                    {
348                                        for e in e {
349                                            tracing::debug!(
350                                                "{}",
351                                                utils::ChumskyError {
352                                                    description: "teleported completed".to_string(),
353                                                    source: message.to_owned(),
354                                                    errors: vec![e.to_owned()],
355                                                }
356                                            );
357                                        }
358                                    }
359                                }
360                                if message.starts_with("[")
361                                    && message.contains("status.secondlifegrid.net")
362                                {
363                                    if let Err(e) =
364                                        system_messages::grid_status_event_message_parser()
365                                            .parse(message.to_string())
366                                    {
367                                        for e in e {
368                                            tracing::debug!(
369                                                "{}",
370                                                utils::ChumskyError {
371                                                    description: "grid status event".to_string(),
372                                                    source: message.to_owned(),
373                                                    errors: vec![e.to_owned()],
374                                                }
375                                            );
376                                        }
377                                    }
378                                }
379                                if message.starts_with("Object ID:") {
380                                    if let Err(e) =
381                                        system_messages::extended_script_info_message_parser()
382                                            .parse(message.to_string())
383                                    {
384                                        for e in e {
385                                            tracing::debug!(
386                                                "{}",
387                                                utils::ChumskyError {
388                                                    description: "extended script info".to_string(),
389                                                    source: message.to_owned(),
390                                                    errors: vec![e.to_owned()],
391                                                }
392                                            );
393                                        }
394                                    }
395                                }
396                                if message.starts_with("Bridge") {
397                                    if let Err(e) = system_messages::bridge_message_parser()
398                                        .parse(message.to_string())
399                                    {
400                                        for e in e {
401                                            tracing::debug!(
402                                                "{}",
403                                                utils::ChumskyError {
404                                                    description: "bridge message".to_string(),
405                                                    source: message.to_owned(),
406                                                    errors: vec![e.to_owned()],
407                                                }
408                                            );
409                                        }
410                                    }
411                                }
412
413                                if message.starts_with("You paid") {
414                                    if let Err(e) = system_messages::sent_payment_message_parser()
415                                        .parse(message.to_string())
416                                    {
417                                        for e in e {
418                                            tracing::debug!(
419                                                "{}",
420                                                utils::ChumskyError {
421                                                    description: "sent payment".to_string(),
422                                                    source: message.to_owned(),
423                                                    errors: vec![e.to_owned()],
424                                                }
425                                            );
426                                        }
427                                    }
428                                }
429                                if message.contains("Take Linden dollars") {
430                                    if let Err(e) = system_messages::object_granted_permission_to_take_money_parser()
431                                        .parse(message.to_string())
432                                    {
433                                        for e in e {
434                                            tracing::debug!(
435                                                "{}",
436                                                utils::ChumskyError {
437                                                    description: "object granted permission to take money".to_string(),
438                                                    source: message.to_owned(),
439                                                    errors: vec![e.to_owned()],
440                                                }
441                                            );
442                                        }
443                                    }
444                                }
445                                if message.starts_with("You have offered a calling card") {
446                                    if let Err(e) =
447                                        system_messages::offered_calling_card_message_parser()
448                                            .parse(message.to_string())
449                                    {
450                                        for e in e {
451                                            tracing::debug!(
452                                                "{}",
453                                                utils::ChumskyError {
454                                                    description: "offered calling card".to_string(),
455                                                    source: message.to_owned(),
456                                                    errors: vec![e.to_owned()],
457                                                }
458                                            );
459                                        }
460                                    }
461                                }
462                                if message.starts_with("Draw Distance set") {
463                                    if let Err(e) =
464                                        system_messages::draw_distance_set_message_parser()
465                                            .parse(message.to_string())
466                                    {
467                                        for e in e {
468                                            tracing::debug!(
469                                                "{}",
470                                                utils::ChumskyError {
471                                                    description: "draw distance set".to_string(),
472                                                    source: message.to_owned(),
473                                                    errors: vec![e.to_owned()],
474                                                }
475                                            );
476                                        }
477                                    }
478                                }
479                                if message.starts_with("Your object") {
480                                    if let Err(e) =
481                                        system_messages::your_object_has_been_returned_message_parser()
482                                            .parse(message.to_string())
483                                    {
484                                        for e in e {
485                                            tracing::debug!(
486                                                "{}",
487                                                utils::ChumskyError {
488                                                    description: "your object has been returned".to_string(),
489                                                    source: message.to_owned(),
490                                                    errors: vec![e.to_owned()],
491                                                }
492                                            );
493                                        }
494                                    }
495                                }
496                            }
497                        }
498                    }
499                }
500                last_line = Some(line);
501            }
502        }
503        // enable to see output during development, both to identity unhandled messages and to see parse errors above
504        //panic!();
505        Ok(())
506    }
507}