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