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}