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        EnvError(#[from] envy::Error),
203        /// error loading .env file
204        #[error("error loading .env file: {0}")]
205        DotEnvError(#[from] dotenvy::Error),
206        /// error determining current user home directory
207        #[error("error determining current user home directory")]
208        HomeDirError,
209        /// error opening chat log file
210        #[error("error opening chat log file {0}: {1}")]
211        OpenChatLogFileError(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        ChatLogLineReadError(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::HomeDirError);
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::OpenChatLogFileError(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::ChatLogLineReadError)?;
244                if line.starts_with(" ") || line == "" {
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                    match chat_log_line_parser().parse(ll.clone()) {
252                        Err(e) => {
253                            tracing::error!("failed to parse line\n{}", ll);
254                            for err in e {
255                                tracing::error!("{}", err);
256                            }
257                            panic!("Failed to parse a line");
258                        }
259                        Ok(parsed_line) => {
260                            if let ChatLogLine {
261                                timestamp: _,
262                                event:
263                                    ChatLogEvent::SystemMessage {
264                                        message:
265                                            system_messages::SystemMessage::OtherSystemMessage {
266                                                ref message,
267                                            },
268                                    },
269                            } = parsed_line
270                            {
271                                tracing::info!("parsed line\n{}\n{:?}", ll, parsed_line);
272                                if message.starts_with("The message sent to") {
273                                    if let Err(e) =
274                                        system_messages::chat_message_still_being_processed_message_parser()
275                                            .parse(message.to_string())
276                                    {
277                                        for e in e {
278                                            tracing::debug!("{}", utils::ChumskyError {
279                                                description: "group chat message still being processed".to_string(),
280                                                source: message.to_owned(),
281                                                errors: vec![e.to_owned()],
282                                            });
283                                        }
284                                    }
285                                }
286                                if message.contains("owned by") && message.contains("gave you") {
287                                    if let Err(e) =
288                                        system_messages::object_gave_object_message_parser()
289                                            .parse(message.to_string())
290                                    {
291                                        for e in e {
292                                            tracing::debug!(
293                                                "{}",
294                                                utils::ChumskyError {
295                                                    description: "owned by gave you".to_string(),
296                                                    source: message.to_owned(),
297                                                    errors: vec![e.to_owned()],
298                                                }
299                                            );
300                                        }
301                                    }
302                                }
303                                if message.contains("An object named")
304                                    && message.contains("gave you this folder")
305                                {
306                                    if let Err(e) =
307                                        system_messages::object_gave_folder_message_parser()
308                                            .parse(message.to_string())
309                                    {
310                                        for e in e {
311                                            tracing::debug!(
312                                                "{}",
313                                                utils::ChumskyError {
314                                                    description:
315                                                        "An object named ... gave you this folder"
316                                                            .to_string(),
317                                                    source: message.to_owned(),
318                                                    errors: vec![e.to_owned()],
319                                                }
320                                            );
321                                        }
322                                    }
323                                }
324                                if message.starts_with("Can't rez object")
325                                    && message.contains(
326                                        "because the owner of this land does not allow it",
327                                    )
328                                {
329                                    if let Err(e) =
330                                        system_messages::permission_to_rez_object_denied_message_parser()
331                                            .parse(message.to_string())
332                                    {
333                                        for e in e {
334                                            tracing::debug!("{}", utils::ChumskyError {
335                                                description: "permission to rez object denied".to_string(),
336                                                source: message.to_owned(),
337                                                errors: vec![e.to_owned()],
338                                            });
339                                        }
340                                    }
341                                }
342                                if message.starts_with("Teleport completed from") {
343                                    if let Err(e) =
344                                        system_messages::teleport_completed_message_parser()
345                                            .parse(message.to_string())
346                                    {
347                                        for e in e {
348                                            tracing::debug!(
349                                                "{}",
350                                                utils::ChumskyError {
351                                                    description: "teleported completed".to_string(),
352                                                    source: message.to_owned(),
353                                                    errors: vec![e.to_owned()],
354                                                }
355                                            );
356                                        }
357                                    }
358                                }
359                                if message.starts_with("[")
360                                    && message.contains("status.secondlifegrid.net")
361                                {
362                                    if let Err(e) =
363                                        system_messages::grid_status_event_message_parser()
364                                            .parse(message.to_string())
365                                    {
366                                        for e in e {
367                                            tracing::debug!(
368                                                "{}",
369                                                utils::ChumskyError {
370                                                    description: "grid status event".to_string(),
371                                                    source: message.to_owned(),
372                                                    errors: vec![e.to_owned()],
373                                                }
374                                            );
375                                        }
376                                    }
377                                }
378                                if message.starts_with("Object ID:") {
379                                    if let Err(e) =
380                                        system_messages::extended_script_info_message_parser()
381                                            .parse(message.to_string())
382                                    {
383                                        for e in e {
384                                            tracing::debug!(
385                                                "{}",
386                                                utils::ChumskyError {
387                                                    description: "extended script info".to_string(),
388                                                    source: message.to_owned(),
389                                                    errors: vec![e.to_owned()],
390                                                }
391                                            );
392                                        }
393                                    }
394                                }
395                                if message.starts_with("Bridge") {
396                                    if let Err(e) = system_messages::bridge_message_parser()
397                                        .parse(message.to_string())
398                                    {
399                                        for e in e {
400                                            tracing::debug!(
401                                                "{}",
402                                                utils::ChumskyError {
403                                                    description: "bridge message".to_string(),
404                                                    source: message.to_owned(),
405                                                    errors: vec![e.to_owned()],
406                                                }
407                                            );
408                                        }
409                                    }
410                                }
411
412                                if message.starts_with("You paid") {
413                                    if let Err(e) = system_messages::sent_payment_message_parser()
414                                        .parse(message.to_string())
415                                    {
416                                        for e in e {
417                                            tracing::debug!(
418                                                "{}",
419                                                utils::ChumskyError {
420                                                    description: "sent payment".to_string(),
421                                                    source: message.to_owned(),
422                                                    errors: vec![e.to_owned()],
423                                                }
424                                            );
425                                        }
426                                    }
427                                }
428                                if message.contains("Take Linden dollars") {
429                                    if let Err(e) = system_messages::object_granted_permission_to_take_money_parser()
430                                        .parse(message.to_string())
431                                    {
432                                        for e in e {
433                                            tracing::debug!(
434                                                "{}",
435                                                utils::ChumskyError {
436                                                    description: "object granted permission to take money".to_string(),
437                                                    source: message.to_owned(),
438                                                    errors: vec![e.to_owned()],
439                                                }
440                                            );
441                                        }
442                                    }
443                                }
444                                if message.starts_with("You have offered a calling card") {
445                                    if let Err(e) =
446                                        system_messages::offered_calling_card_message_parser()
447                                            .parse(message.to_string())
448                                    {
449                                        for e in e {
450                                            tracing::debug!(
451                                                "{}",
452                                                utils::ChumskyError {
453                                                    description: "offered calling card".to_string(),
454                                                    source: message.to_owned(),
455                                                    errors: vec![e.to_owned()],
456                                                }
457                                            );
458                                        }
459                                    }
460                                }
461                                if message.starts_with("Draw Distance set") {
462                                    if let Err(e) =
463                                        system_messages::draw_distance_set_message_parser()
464                                            .parse(message.to_string())
465                                    {
466                                        for e in e {
467                                            tracing::debug!(
468                                                "{}",
469                                                utils::ChumskyError {
470                                                    description: "draw distance set".to_string(),
471                                                    source: message.to_owned(),
472                                                    errors: vec![e.to_owned()],
473                                                }
474                                            );
475                                        }
476                                    }
477                                }
478                                if message.starts_with("Your object") {
479                                    if let Err(e) =
480                                        system_messages::your_object_has_been_returned_message_parser()
481                                            .parse(message.to_string())
482                                    {
483                                        for e in e {
484                                            tracing::debug!(
485                                                "{}",
486                                                utils::ChumskyError {
487                                                    description: "your object has been returned".to_string(),
488                                                    source: message.to_owned(),
489                                                    errors: vec![e.to_owned()],
490                                                }
491                                            );
492                                        }
493                                    }
494                                }
495                            }
496                        }
497                    }
498                }
499                last_line = Some(line);
500            }
501        }
502        // enable to see output during development, both to identity unhandled messages and to see parse errors above
503        //panic!();
504        Ok(())
505    }
506}