Skip to main content

sl_chat_log_parser/
lib.rs

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