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}