1use std::collections::HashMap;
2
3#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
20pub enum NotificationType {
21 Death,
22 Collection,
23 Level,
24 XpMilestone,
25 Loot,
26 Slayer,
27 Quest,
28 Clue,
29 KillCount,
30 CombatAchievement,
31 AchievementDiary,
32 Pet,
33 Speedrun,
34 BarbarianAssaultGamble,
35 PlayerKill,
36 GroupStorage,
37 GrandExchange,
38 Trade,
39 LeaguesArea,
40 LeaguesMastery,
41 LeaguesRelic,
42 LeaguesTask,
43 Chat,
44 ExternalPlugin,
45 Login,
46 Logout,
47 ToaUnique,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct Notification {
52 pub content: String,
53 pub extra: HashMap<String, String>,
54 pub notification_type: NotificationType,
55 pub player_name: String,
56 pub account_type: String,
57 pub seasonal_world: String,
58 pub dink_account_hash: String,
59 }
61
62impl Notification {
63 pub fn message(&self) -> String {
65 match &self.notification_type {
66 NotificationType::Death => {
67 let value_lost = self
68 .extra
69 .get("valueLost")
70 .map(|s| s.as_str())
71 .unwrap_or("0");
72 let is_pvp = self
73 .extra
74 .get("isPvp")
75 .map(|s| s.as_str())
76 .unwrap_or("false");
77 if is_pvp == "true" {
78 format!("{} died in PvP, lost {} gp", self.player_name, value_lost)
79 } else {
80 format!("{} died, lost {} gp", self.player_name, value_lost)
81 }
82 }
83 NotificationType::Collection => {
84 let item_name = self
85 .extra
86 .get("itemName")
87 .map(|s| s.as_str())
88 .unwrap_or("Unknown item");
89 let price = self.extra.get("price").map(|s| s.as_str()).unwrap_or("0");
90 format!(
91 "{} added {} to collection ({} gp)",
92 self.player_name, item_name, price
93 )
94 }
95 NotificationType::Level => {
96 let skills: Vec<String> = self
97 .extra
98 .get("levelledSkills")
99 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
100 .unwrap_or_default();
101 if skills.is_empty() {
102 format!("{} leveled up", self.player_name)
103 } else {
104 format!("{} leveled up: {}", self.player_name, skills.join(", "))
105 }
106 }
107 NotificationType::XpMilestone => {
108 let milestone = self
109 .extra
110 .get("milestoneAchieved")
111 .map(|s| {
112 s.split(',')
113 .map(|s| s.trim().to_string())
114 .collect::<Vec<String>>()
115 .join(", ")
116 })
117 .unwrap_or_else(|| "skills".to_string());
118 format!("{} hit XP milestone in {}", self.player_name, milestone)
119 }
120 NotificationType::Loot => {
121 let source = self
122 .extra
123 .get("source")
124 .map(|s| s.as_str())
125 .unwrap_or("Unknown source");
126 let items_count = self
127 .extra
128 .get("items")
129 .map(|s| s.split(',').count())
130 .unwrap_or(1);
131 format!(
132 "{} looted {} items from {}",
133 self.player_name, items_count, source
134 )
135 }
136 NotificationType::Slayer => {
137 let task = self
138 .extra
139 .get("slayerTask")
140 .map(|s| s.as_str())
141 .unwrap_or("Unknown task");
142 let points = self
143 .extra
144 .get("slayerPoints")
145 .map(|s| s.as_str())
146 .unwrap_or("0");
147 format!(
148 "{} completed {} slayer task (+{} points)",
149 self.player_name, task, points
150 )
151 }
152 NotificationType::Quest => {
153 let quest = self
154 .extra
155 .get("questName")
156 .map(|s| s.as_str())
157 .unwrap_or("Unknown quest");
158 format!("{} completed quest: {}", self.player_name, quest)
159 }
160 NotificationType::Clue => {
161 let clue_type = self
162 .extra
163 .get("clueType")
164 .map(|s| s.as_str())
165 .unwrap_or("Unknown");
166 let count = self
167 .extra
168 .get("numberCompleted")
169 .map(|s| s.as_str())
170 .unwrap_or("0");
171 format!(
172 "{} completed {} clue (#{})",
173 self.player_name, clue_type, count
174 )
175 }
176 NotificationType::KillCount => {
177 let boss = self
178 .extra
179 .get("boss")
180 .map(|s| s.as_str())
181 .unwrap_or("Unknown boss");
182 let count = self.extra.get("count").map(|s| s.as_str()).unwrap_or("0");
183 format!("{} defeated {} (count: {})", self.player_name, boss, count)
184 }
185 NotificationType::CombatAchievement => {
186 let task = self
187 .extra
188 .get("task")
189 .map(|s| s.as_str())
190 .unwrap_or("Unknown task");
191 let tier = self
192 .extra
193 .get("tier")
194 .map(|s| s.as_str())
195 .unwrap_or("Unknown tier");
196 format!(
197 "{} completed {} combat task: {}",
198 self.player_name, tier, task
199 )
200 }
201 NotificationType::AchievementDiary => {
202 let area = self
203 .extra
204 .get("area")
205 .map(|s| s.as_str())
206 .unwrap_or("Unknown area");
207 let difficulty = self
208 .extra
209 .get("difficulty")
210 .map(|s| s.as_str())
211 .unwrap_or("Unknown");
212 format!(
213 "{} completed {} {} diary",
214 self.player_name, difficulty, area
215 )
216 }
217 NotificationType::Pet => {
218 let pet_name = self
219 .extra
220 .get("petName")
221 .map(|s| s.as_str())
222 .unwrap_or("Unknown pet");
223 format!("{} got pet: {}", self.player_name, pet_name)
224 }
225 NotificationType::Speedrun => {
226 let quest = self
227 .extra
228 .get("questName")
229 .map(|s| s.as_str())
230 .unwrap_or("Unknown quest");
231 let time = self
232 .extra
233 .get("currentTime")
234 .map(|s| s.as_str())
235 .unwrap_or("Unknown time");
236 format!("{} speedran {} in {}", self.player_name, quest, time)
237 }
238 NotificationType::BarbarianAssaultGamble => {
239 let count = self
240 .extra
241 .get("gambleCount")
242 .map(|s| s.as_str())
243 .unwrap_or("0");
244 format!("{} reached {} high gambles", self.player_name, count)
245 }
246 NotificationType::PlayerKill => {
247 let victim = self
248 .extra
249 .get("victimName")
250 .map(|s| s.as_str())
251 .unwrap_or("Unknown");
252 format!("{} PK'd {}", self.player_name, victim)
253 }
254 NotificationType::GroupStorage => {
255 let _group = self
256 .extra
257 .get("groupName")
258 .map(|s| s.as_str())
259 .unwrap_or("Unknown group");
260 let net_value = self
261 .extra
262 .get("netValue")
263 .map(|s| s.as_str())
264 .unwrap_or("0");
265 format!(
266 "{} group storage activity ({} gp net)",
267 self.player_name, net_value
268 )
269 }
270 NotificationType::GrandExchange => {
271 let status = self
272 .extra
273 .get("status")
274 .map(|s| s.as_str())
275 .unwrap_or("Unknown");
276 let item_name = self
277 .extra
278 .get("item")
279 .and_then(|s| s.split(',').next())
280 .unwrap_or("Unknown item");
281 format!("{} {} on GE: {}", self.player_name, status, item_name)
282 }
283 NotificationType::Trade => {
284 let counterparty = self
285 .extra
286 .get("counterparty")
287 .map(|s| s.as_str())
288 .unwrap_or("Unknown");
289 format!("{} traded with {}", self.player_name, counterparty)
290 }
291 NotificationType::LeaguesArea => {
292 let area = self
293 .extra
294 .get("area")
295 .map(|s| s.as_str())
296 .unwrap_or("Unknown area");
297 format!("{} selected region: {}", self.player_name, area)
298 }
299 NotificationType::LeaguesMastery => {
300 let mastery = self
301 .extra
302 .get("masteryType")
303 .map(|s| s.as_str())
304 .unwrap_or("Unknown");
305 format!("{} unlocked {} mastery", self.player_name, mastery)
306 }
307 NotificationType::LeaguesRelic => {
308 let relic = self
309 .extra
310 .get("relic")
311 .map(|s| s.as_str())
312 .unwrap_or("Unknown relic");
313 let tier = self
314 .extra
315 .get("tier")
316 .map(|s| s.as_str())
317 .unwrap_or("Unknown");
318 format!(
319 "{} unlocked Tier {} relic: {}",
320 self.player_name, tier, relic
321 )
322 }
323 NotificationType::LeaguesTask => {
324 let task = self
325 .extra
326 .get("taskName")
327 .map(|s| s.as_str())
328 .unwrap_or("Unknown task");
329 let difficulty = self
330 .extra
331 .get("difficulty")
332 .map(|s| s.as_str())
333 .unwrap_or("Unknown");
334 format!(
335 "{} completed {} task: {}",
336 self.player_name, difficulty, task
337 )
338 }
339 NotificationType::Chat => {
340 let message_type = self
341 .extra
342 .get("type")
343 .map(|s| s.as_str())
344 .unwrap_or("Unknown");
345 format!("{} received {} message", self.player_name, message_type)
346 }
347 NotificationType::ExternalPlugin => {
348 let plugin = self
349 .extra
350 .get("sourcePlugin")
351 .map(|s| s.as_str())
352 .unwrap_or("Unknown plugin");
353 format!(
354 "{} external plugin notification from {}",
355 self.player_name, plugin
356 )
357 }
358 NotificationType::Login => {
359 let world = self
360 .extra
361 .get("world")
362 .map(|s| s.as_str())
363 .unwrap_or("Unknown");
364 format!("{} logged into World {}", self.player_name, world)
365 }
366 NotificationType::Logout => {
367 format!("{} logged out", self.player_name)
368 }
369 NotificationType::ToaUnique => {
370 let raid_level = self
371 .extra
372 .get("raidLevels")
373 .map(|s| s.as_str())
374 .unwrap_or("Unknown");
375 format!(
376 "{} rolled purple from TOA (level {})",
377 self.player_name, raid_level
378 )
379 }
380 }
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_death_notification_message() {
390 let mut extra = HashMap::new();
391 extra.insert("valueLost".to_string(), "500".to_string());
392 extra.insert("isPvp".to_string(), "false".to_string());
393 extra.insert("killerName".to_string(), "Giant Spider".to_string());
394
395 let notification = Notification {
396 content: "PlayerName has died...".to_string(),
397 extra,
398 notification_type: NotificationType::Death,
399 player_name: "PlayerName".to_string(),
400 account_type: "NORMAL".to_string(),
401 seasonal_world: "false".to_string(),
402 dink_account_hash: "test_hash".to_string(),
403 };
404
405 let message = notification.message();
406 assert_eq!(message, "PlayerName died, lost 500 gp");
407 }
408
409 #[test]
410 fn test_death_pvp_notification_message() {
411 let mut extra = HashMap::new();
412 extra.insert("valueLost".to_string(), "1000".to_string());
413 extra.insert("isPvp".to_string(), "true".to_string());
414 extra.insert("killerName".to_string(), "PKerName".to_string());
415
416 let notification = Notification {
417 content: "PlayerName has just been PKed by PKerName for 1000 gp...".to_string(),
418 extra,
419 notification_type: NotificationType::Death,
420 player_name: "PlayerName".to_string(),
421 account_type: "NORMAL".to_string(),
422 seasonal_world: "false".to_string(),
423 dink_account_hash: "test_hash".to_string(),
424 };
425
426 let message = notification.message();
427 assert_eq!(message, "PlayerName died in PvP, lost 1000 gp");
428 }
429
430 #[test]
431 fn test_collection_notification_message() {
432 let mut extra = HashMap::new();
433 extra.insert("itemName".to_string(), "Zamorak chaps".to_string());
434 extra.insert("itemId".to_string(), "10372".to_string());
435 extra.insert("price".to_string(), "500812".to_string());
436 extra.insert("completedEntries".to_string(), "420".to_string());
437 extra.insert("totalEntries".to_string(), "1443".to_string());
438 extra.insert("currentRank".to_string(), "IRON".to_string());
439
440 let notification = Notification {
441 content: "PlayerName has added Zamorak chaps to their collection".to_string(),
442 extra,
443 notification_type: NotificationType::Collection,
444 player_name: "PlayerName".to_string(),
445 account_type: "NORMAL".to_string(),
446 seasonal_world: "false".to_string(),
447 dink_account_hash: "test_hash".to_string(),
448 };
449
450 let message = notification.message();
451 assert_eq!(
452 message,
453 "PlayerName added Zamorak chaps to collection (500812 gp)"
454 );
455 }
456
457 #[test]
458 fn test_loot_valuable_drop_notification_message() {
459 let mut extra = HashMap::new();
460 extra.insert("source".to_string(), "Tombs of Amascut".to_string());
461 extra.insert(
462 "items".to_string(),
463 "Tumeken's shadow, Osmumten's fang, Lightbearer".to_string(),
464 );
465 extra.insert("category".to_string(), "EVENT".to_string());
466 extra.insert("killCount".to_string(), "60".to_string());
467 extra.insert("rarestProbability".to_string(), "0.001".to_string());
468
469 let notification = Notification {
470 content: "PlayerName has looted: Tumeken's shadow, Osmumten's fang, Lightbearer\nFrom: Tombs of Amascut".to_string(),
471 extra,
472 notification_type: NotificationType::Loot,
473 player_name: "PlayerName".to_string(),
474 account_type: "NORMAL".to_string(),
475 seasonal_world: "false".to_string(),
476 dink_account_hash: "test_hash".to_string(),
477 };
478
479 let message = notification.message();
480 assert_eq!(message, "PlayerName looted 3 items from Tombs of Amascut");
481 }
482
483 #[test]
484 fn test_loot_single_item_notification_message() {
485 let mut extra = HashMap::new();
486 extra.insert("source".to_string(), "Giant Mole".to_string());
487 extra.insert("items".to_string(), "Mole skin".to_string());
488 extra.insert("category".to_string(), "NPC".to_string());
489 extra.insert("npcId".to_string(), "3340".to_string());
490
491 let notification = Notification {
492 content: "PlayerName has looted: Mole skin\nFrom: Giant Mole".to_string(),
493 extra,
494 notification_type: NotificationType::Loot,
495 player_name: "PlayerName".to_string(),
496 account_type: "NORMAL".to_string(),
497 seasonal_world: "false".to_string(),
498 dink_account_hash: "test_hash".to_string(),
499 };
500
501 let message = notification.message();
502 assert_eq!(message, "PlayerName looted 1 items from Giant Mole");
503 }
504
505 #[test]
506 fn test_notification_with_missing_extra_data() {
507 let extra = HashMap::new();
508
509 let notification = Notification {
510 content: "PlayerName has died...".to_string(),
511 extra,
512 notification_type: NotificationType::Death,
513 player_name: "PlayerName".to_string(),
514 account_type: "NORMAL".to_string(),
515 seasonal_world: "false".to_string(),
516 dink_account_hash: "test_hash".to_string(),
517 };
518
519 let message = notification.message();
520 assert_eq!(message, "PlayerName died, lost 0 gp");
521 }
522}