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