1use std::collections::{HashMap, HashSet};
15
16const MIN_SKELETON_LEN: usize = 8;
19
20pub fn skeleton(text: &str) -> String {
29 skeleton_with(text, &HashSet::new())
30}
31
32pub fn skeleton_with(text: &str, known: &HashSet<String>) -> String {
36 strip_shortcodes(text, known)
37 .chars()
38 .filter(|c| c.is_alphanumeric() && !c.is_numeric())
39 .flat_map(|c| c.to_lowercase())
40 .collect()
41}
42
43fn strip_shortcodes(text: &str, known: &HashSet<String>) -> String {
48 let mut out = String::with_capacity(text.len());
49 let mut rest = text;
50 while let Some(open) = rest.find(':') {
51 out.push_str(&rest[..open]);
52 let after = &rest[open + 1..];
53 let close = after.find(':').filter(|end| {
54 *end > 0 && after[..*end].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+')
55 });
56 match close {
57 Some(end) => {
58 let name = &after[..end];
59 if !known.contains(name) && name.chars().count() >= MIN_SKELETON_LEN {
60 out.push_str(name);
61 }
62 rest = &after[end + 1..];
63 }
64 None => {
65 out.push(':');
66 rest = after;
67 }
68 }
69 }
70 out.push_str(rest);
71 out
72}
73
74#[derive(Debug, Clone)]
77pub struct RaidParams {
78 pub fresh_join_secs: u64,
80 pub trusted_tenure_secs: u64,
82 pub trusted_messages: u64,
84 pub trusted_distinct: u64,
86 pub quiet_messages: u64,
88 pub min_cohort: usize,
90 pub short_text_factor: usize,
92 pub burst_gap_secs: u64,
94 pub min_burst: usize,
96 pub known_shortcodes: HashSet<String>,
98}
99
100impl Default for RaidParams {
101 fn default() -> Self {
102 Self {
103 fresh_join_secs: 24 * 60 * 60,
104 trusted_tenure_secs: 7 * 24 * 60 * 60,
105 trusted_messages: 5,
106 trusted_distinct: 3,
107 quiet_messages: 2,
108 min_cohort: 3,
109 short_text_factor: 3,
110 burst_gap_secs: 10 * 60,
111 min_burst: 5,
112 known_shortcodes: HashSet::new(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Default)]
119pub struct MemberSignals {
120 pub npub: String,
121 pub joined_at_ms: u64,
123 pub invite_label: Option<String>,
125 pub messages: u64,
126 pub first_secs: u64,
128 pub last_secs: u64,
129 pub texts: Vec<String>,
131 pub is_owner: bool,
132 pub is_admin: bool,
133 pub is_me: bool,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Verdict {
140 Protected,
142 Trusted,
144 Neutral,
146 Suspect,
148}
149
150#[derive(Debug, Clone, serde::Serialize)]
152pub struct MemberAssessment {
153 pub npub: String,
154 pub verdict: Verdict,
155 pub score: u32,
157 pub reasons: Vec<String>,
158 pub joined_at_ms: u64,
159 pub invite_label: Option<String>,
160 pub messages: u64,
161 pub distinct: u64,
163 pub cohort: u64,
165 pub tenure_secs: u64,
167 pub last_secs: u64,
168 pub is_owner: bool,
169 pub is_admin: bool,
170 pub is_me: bool,
171}
172
173const COHORT_SAMPLE_CAP: usize = 24;
177
178#[derive(Debug, Clone, serde::Serialize)]
180pub struct Cohort {
181 pub sample: String,
183 pub size: usize,
185 pub members: Vec<String>,
186}
187
188#[derive(Debug, Clone, serde::Serialize)]
190pub struct RaidReport {
191 pub members: Vec<MemberAssessment>,
192 pub suspects: usize,
193 pub trusted: usize,
194 pub protected: usize,
195 pub cohorts: Vec<Cohort>,
196 pub burst_size: usize,
198 pub burst_from_ms: u64,
200 pub burst_to_ms: u64,
201 pub raid_detected: bool,
204}
205
206pub fn assess(signals: &[MemberSignals], now_secs: u64, p: &RaidParams) -> RaidReport {
209 let mut by_skeleton: HashMap<String, HashSet<String>> = HashMap::new();
211 let mut sample_of: HashMap<String, String> = HashMap::new();
212 for s in signals {
213 for t in &s.texts {
214 let sk = skeleton_with(t, &p.known_shortcodes);
215 if sk.is_empty() {
216 continue;
217 }
218 by_skeleton.entry(sk.clone()).or_default().insert(s.npub.clone());
219 sample_of.entry(sk).or_insert_with(|| t.clone());
220 }
221 }
222 let volume: HashMap<&str, u64> = signals.iter().map(|s| (s.npub.as_str(), s.messages)).collect();
223 let convicting = |sk: &str, members: &HashSet<String>| -> bool {
229 let need = if sk.chars().count() < MIN_SKELETON_LEN {
230 p.min_cohort.saturating_mul(p.short_text_factor)
231 } else {
232 p.min_cohort
233 };
234 if members.len() < need {
235 return false;
236 }
237 let thin = members
238 .iter()
239 .filter(|n| volume.get(n.as_str()).copied().unwrap_or(0) <= p.quiet_messages)
240 .count();
241 thin * 2 >= members.len()
242 };
243
244 let mut cohorts: Vec<Cohort> = Vec::new();
245 let mut cohort_of: HashMap<String, u64> = HashMap::new();
247 for (sk, members) in &by_skeleton {
248 if !convicting(sk, members) {
249 continue;
250 }
251 let peers = members.len().saturating_sub(1) as u64;
252 for m in members {
253 let e = cohort_of.entry(m.clone()).or_insert(0);
254 *e = (*e).max(peers);
255 }
256 let mut list: Vec<String> = members.iter().cloned().collect();
257 list.sort();
258 let size = list.len();
259 list.truncate(COHORT_SAMPLE_CAP);
260 cohorts.push(Cohort { sample: sample_of.get(sk).cloned().unwrap_or_default(), size, members: list });
261 }
262 cohorts.sort_by(|a, b| b.size.cmp(&a.size));
263
264 let mut joins: Vec<(u64, &str)> = signals
266 .iter()
267 .filter(|s| s.joined_at_ms > 0 && !s.is_owner)
268 .map(|s| (s.joined_at_ms, s.npub.as_str()))
269 .collect();
270 joins.sort_by_key(|(at, _)| *at);
271 let gap_ms = p.burst_gap_secs.saturating_mul(1000);
272 let mut best: (usize, usize) = (0, 0); let mut start = 0usize;
274 for end in 0..joins.len() {
275 while joins[end].0.saturating_sub(joins[start].0) > gap_ms {
276 start += 1;
277 }
278 if end + 1 - start > best.1 - best.0 {
279 best = (start, end + 1);
280 }
281 }
282 let burst: HashSet<&str> = if best.1 - best.0 >= p.min_burst {
283 joins[best.0..best.1].iter().map(|(_, n)| *n).collect()
284 } else {
285 HashSet::new()
286 };
287 let (burst_from_ms, burst_to_ms) = if burst.is_empty() {
288 (0, 0)
289 } else {
290 (joins[best.0].0, joins[best.1 - 1].0)
291 };
292 let burst_convicted = burst.iter().filter(|n| cohort_of.contains_key(**n)).count() >= p.min_cohort;
295
296 let now_ms = now_secs.saturating_mul(1000);
298 let mut members: Vec<MemberAssessment> = Vec::with_capacity(signals.len());
299 for s in signals {
300 let distinct: u64 = s.texts.iter().map(|t| skeleton_with(t, &p.known_shortcodes)).filter(|sk| !sk.is_empty()).collect::<HashSet<_>>().len() as u64;
301 let cohort = cohort_of.get(&s.npub).copied().unwrap_or(0);
302 let in_burst = burst.contains(s.npub.as_str());
303 let first_seen_secs = match (s.joined_at_ms / 1000, s.first_secs) {
305 (0, f) => f,
306 (j, 0) => j,
307 (j, f) => j.min(f),
308 };
309 let tenure_secs = if first_seen_secs == 0 { 0 } else { now_secs.saturating_sub(first_seen_secs) };
310 let fresh = s.joined_at_ms > 0 && now_ms.saturating_sub(s.joined_at_ms) <= p.fresh_join_secs.saturating_mul(1000);
311
312 let mut reasons: Vec<String> = Vec::new();
313 let mut score: u32 = 0;
314
315 let verdict = if s.is_owner || s.is_me || s.is_admin {
316 if s.is_owner {
317 reasons.push("Community owner".into());
318 } else if s.is_me {
319 reasons.push("You".into());
320 } else {
321 reasons.push("Holds a role".into());
322 }
323 Verdict::Protected
324 } else {
325 if cohort > 0 {
326 score += 40 + (cohort.min(50) as u32);
327 reasons.push(format!("Posted the same message as {cohort} other member{}", if cohort == 1 { "" } else { "s" }));
328 }
329 if in_burst && burst_convicted {
330 score += 15;
331 reasons.push(format!("Joined in a burst of {}", burst.len()));
332 }
333 if fresh {
334 score += 10;
335 reasons.push("Joined in the last 24h".into());
336 }
337 if s.messages == 0 {
338 score += 5;
339 reasons.push("Has never posted".into());
340 } else if s.messages <= p.quiet_messages {
341 score += 5;
342 reasons.push(format!("Only {} message{}", s.messages, if s.messages == 1 { "" } else { "s" }));
343 }
344 if s.messages >= 2 && distinct == 1 {
345 score += 10;
346 reasons.push("Repeats one message".into());
347 }
348
349 let long_tenure = tenure_secs >= p.trusted_tenure_secs;
350 let talks = s.messages >= p.trusted_messages;
351 let varied = distinct >= p.trusted_distinct;
352
353 if (long_tenure && talks) || (talks && varied) {
357 reasons.clear();
359 reasons.push(if long_tenure && talks {
360 "Long-standing, active member".to_string()
361 } else {
362 "Varied conversation history".to_string()
363 });
364 Verdict::Trusted
365 } else if cohort > 0 || (in_burst && burst_convicted && s.messages == 0) {
366 Verdict::Suspect
367 } else {
368 Verdict::Neutral
369 }
370 };
371
372 members.push(MemberAssessment {
373 npub: s.npub.clone(),
374 verdict,
375 score,
376 reasons,
377 joined_at_ms: s.joined_at_ms,
378 invite_label: s.invite_label.clone(),
379 messages: s.messages,
380 distinct,
381 cohort,
382 tenure_secs,
383 last_secs: s.last_secs,
384 is_owner: s.is_owner,
385 is_admin: s.is_admin,
386 is_me: s.is_me,
387 });
388 }
389
390 members.sort_by(|a, b| {
393 let rank = |v: Verdict| match v {
394 Verdict::Suspect => 0,
395 Verdict::Neutral => 1,
396 Verdict::Trusted => 2,
397 Verdict::Protected => 3,
398 };
399 rank(a.verdict).cmp(&rank(b.verdict)).then(b.score.cmp(&a.score)).then(a.npub.cmp(&b.npub))
400 });
401
402 let suspects = members.iter().filter(|m| m.verdict == Verdict::Suspect).count();
403 let trusted = members.iter().filter(|m| m.verdict == Verdict::Trusted).count();
404 let protected = members.iter().filter(|m| m.verdict == Verdict::Protected).count();
405 RaidReport {
406 raid_detected: suspects >= p.min_cohort && !cohorts.is_empty(),
407 members,
408 suspects,
409 trusted,
410 protected,
411 cohorts,
412 burst_size: burst.len(),
413 burst_from_ms,
414 burst_to_ms,
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 const HOUR: u64 = 3_600;
423 const DAY: u64 = 86_400;
424
425 fn member(npub: &str) -> MemberSignals {
426 MemberSignals { npub: npub.to_string(), ..Default::default() }
427 }
428
429 const NOW: u64 = 1_700_000_000;
431
432 fn raider(npub: &str, at_secs: u64, text: &str) -> MemberSignals {
433 MemberSignals {
434 npub: npub.to_string(),
435 joined_at_ms: at_secs * 1000,
436 messages: 1,
437 first_secs: at_secs,
438 last_secs: at_secs,
439 texts: vec![text.to_string()],
440 ..Default::default()
441 }
442 }
443
444 fn regular(npub: &str, joined_days_ago: u64, messages: u64, texts: &[&str]) -> MemberSignals {
445 let joined = NOW - joined_days_ago * DAY;
446 MemberSignals {
447 npub: npub.to_string(),
448 joined_at_ms: joined * 1000,
449 messages,
450 first_secs: joined,
451 last_secs: NOW - HOUR,
452 texts: texts.iter().map(|t| t.to_string()).collect(),
453 ..Default::default()
454 }
455 }
456
457 #[test]
458 fn skeleton_ignores_the_cheap_variations() {
459 assert_eq!(skeleton("Hello World!"), skeleton("hello, world"));
460 assert_eq!(skeleton("hello world 123"), skeleton("HELLO WORLD 4567"));
461 assert_ne!(skeleton("hello world"), skeleton("goodbye world"));
462 }
463
464 #[test]
465 fn a_payload_wrapped_in_colons_still_has_a_skeleton() {
466 assert_eq!(skeleton(":buycheapcoinsnow:"), "buycheapcoinsnow");
467 assert!(skeleton(":fire:").is_empty());
469 assert!(skeleton(":+1:").is_empty());
470 assert_eq!(skeleton("nice one :fire:"), skeleton("nice one"));
471 let known = HashSet::from(["buycheapcoinsnow".to_string()]);
472 assert!(skeleton_with(":buycheapcoinsnow:", &known).is_empty());
473 }
474
475 #[test]
476 fn a_cohort_hiding_behind_shortcode_colons_still_convicts() {
477 let mut signals: Vec<MemberSignals> = (0..40)
478 .map(|i| raider(&format!("npub_raider_{i}"), NOW - 60, ":freeairdropclaimnow:"))
479 .collect();
480 signals.push(regular("npub_owner", 400, 900, &["morning all", "shipping today", "nice one"]));
481 signals[40].is_owner = true;
482
483 let r = assess(&signals, NOW, &RaidParams::default());
484 assert!(r.raid_detected, "colons must not blind the cohort");
485 assert_eq!(r.suspects, 40);
486 }
487
488 #[test]
489 fn a_hundred_fresh_npubs_saying_one_thing_is_a_raid() {
490 let mut signals: Vec<MemberSignals> = (0..100)
491 .map(|i| raider(&format!("npub_raider_{i}"), NOW - 60, "hello world"))
492 .collect();
493 signals.push(regular("npub_owner", 400, 900, &["morning all", "shipping today", "nice one"]));
494 signals[100].is_owner = true;
495
496 let r = assess(&signals, NOW, &RaidParams::default());
497 assert!(r.raid_detected, "cohort of 100 identical posts must convict");
498 assert_eq!(r.suspects, 100, "every raider, and only the raiders");
499 assert_eq!(r.members.iter().filter(|m| m.verdict == Verdict::Protected).count(), 1);
500 assert_eq!(r.cohorts.len(), 1);
501 assert_eq!(r.cohorts[0].size, 100, "the true size survives the display cap");
502 }
503
504 #[test]
505 fn a_quiet_newcomer_alone_is_not_convicted() {
506 let signals = vec![
507 regular("npub_old", 400, 900, &["morning all", "shipping today", "nice one"]),
508 MemberSignals {
509 npub: "npub_new".into(),
510 joined_at_ms: (NOW - 600) * 1000,
511 messages: 1,
512 first_secs: NOW - 500,
513 last_secs: NOW - 500,
514 texts: vec!["hey everyone, glad to be here".into()],
515 ..Default::default()
516 },
517 ];
518 let r = assess(&signals, NOW, &RaidParams::default());
519 assert_eq!(r.suspects, 0, "freshness alone must never convict");
520 assert_eq!(r.members.iter().find(|m| m.npub == "npub_new").unwrap().verdict, Verdict::Neutral);
521 }
522
523 #[test]
524 fn an_invite_link_burst_of_real_people_is_not_a_raid() {
525 let greetings = [
527 "hi there", "glad to join", "hello from berlin", "whats up", "found this via nostr",
528 "hey all", "good to be here", "long time lurker", "anyone here into rust", "morning",
529 "just joined, what is this about", "greetings", "hello hello", "howdy folks",
530 "nice community", "reading the pins now", "is there a channel for dev talk",
531 "coming over from the other group", "hola", "yo",
532 ];
533 let signals: Vec<MemberSignals> = (0..20)
534 .map(|i| {
535 let at = NOW - 300 + i * 10;
536 MemberSignals {
537 npub: format!("npub_person_{i}"),
538 joined_at_ms: at * 1000,
539 messages: 1,
540 first_secs: at,
541 last_secs: at,
542 texts: vec![greetings[i as usize].to_string()],
543 ..Default::default()
544 }
545 })
546 .collect();
547 let r = assess(&signals, NOW, &RaidParams::default());
548 assert_eq!(r.suspects, 0, "a burst with no duplicate text is just a busy afternoon");
549 assert!(!r.raid_detected);
550 }
551
552 #[test]
553 fn silent_members_of_a_convicted_burst_are_caught_too() {
554 let mut signals: Vec<MemberSignals> = (0..10)
555 .map(|i| raider(&format!("npub_loud_{i}"), NOW - 120 + i, "buy cheap coins now"))
556 .collect();
557 for i in 0..5 {
559 let at = NOW - 100 + i;
560 signals.push(MemberSignals {
561 npub: format!("npub_silent_{i}"),
562 joined_at_ms: at * 1000,
563 ..Default::default()
564 });
565 }
566 let r = assess(&signals, NOW, &RaidParams::default());
567 assert_eq!(r.suspects, 15, "the whole wave, loud and silent");
568 }
569
570 #[test]
571 fn short_shared_text_needs_a_bigger_cohort() {
572 let signals: Vec<MemberSignals> =
574 (0..4).map(|i| raider(&format!("npub_{i}"), NOW - 60 + i, "gm")).collect();
575 let r = assess(&signals, NOW, &RaidParams::default());
576 assert_eq!(r.suspects, 0, "min_cohort * short_text_factor = 9 needed for a 2-char skeleton");
577
578 let many: Vec<MemberSignals> =
579 (0..12).map(|i| raider(&format!("npub_{i}"), NOW - 60 + i, "gm")).collect();
580 assert_eq!(assess(&many, NOW, &RaidParams::default()).suspects, 12);
581 }
582
583 #[test]
584 fn tenure_and_variety_earn_trust_that_survives_a_raid() {
585 let mut signals: Vec<MemberSignals> = (0..20)
586 .map(|i| raider(&format!("npub_raider_{i}"), NOW - 60, "hello world"))
587 .collect();
588 signals.push(regular("npub_veteran", 90, 400, &["morning", "on it", "shipped", "hello world"]));
591 let r = assess(&signals, NOW, &RaidParams::default());
592 let vet = r.members.iter().find(|m| m.npub == "npub_veteran").unwrap();
593 assert_eq!(vet.verdict, Verdict::Trusted, "cohort membership must not outrank real history");
594 assert!(vet.cohort > 0, "they are genuinely in the cohort — the panel just keeps them ticked");
595 assert!(vet.reasons.iter().any(|s| s.contains("Long-standing")));
596 assert_eq!(r.suspects, 20, "the raiders, and only the raiders");
597 }
598
599 #[test]
600 fn role_holders_and_self_are_never_selectable() {
601 let mut admin = member("npub_admin");
602 admin.is_admin = true;
603 admin.joined_at_ms = (NOW - 60) * 1000;
604 admin.texts = vec!["hello world".into()];
605 admin.messages = 1;
606 let mut me = member("npub_me");
607 me.is_me = true;
608 let mut signals = vec![admin, me];
609 signals.extend((0..10).map(|i| raider(&format!("npub_r{i}"), NOW - 60, "hello world")));
610
611 let r = assess(&signals, NOW, &RaidParams::default());
612 assert_eq!(r.members.iter().find(|m| m.npub == "npub_admin").unwrap().verdict, Verdict::Protected);
613 assert_eq!(r.members.iter().find(|m| m.npub == "npub_me").unwrap().verdict, Verdict::Protected);
614 assert_eq!(r.suspects, 10, "protection wins over cohort evidence");
615 }
616
617 #[test]
618 fn suspects_sort_ahead_of_everyone_else() {
619 let mut signals = vec![regular("npub_old", 400, 900, &["a", "b", "c", "d", "e"])];
620 signals.extend((0..5).map(|i| raider(&format!("npub_r{i}"), NOW - 60, "free airdrop here")));
621 let r = assess(&signals, NOW, &RaidParams::default());
622 assert!(r.members[0].verdict == Verdict::Suspect);
623 assert_eq!(r.members.last().unwrap().npub, "npub_old");
624 }
625
626 #[test]
627 fn a_community_catchphrase_shared_by_regulars_is_not_a_cohort() {
628 let signals: Vec<MemberSignals> = (0..5)
631 .map(|i| regular(&format!("npub_regular_{i}"), 200, 400, &["gm vectorians", "on it", "shipped it", "nice"]))
632 .collect();
633 let r = assess(&signals, NOW, &RaidParams::default());
634 assert!(r.cohorts.is_empty(), "a shared phrase between chatty members is not evidence");
635 assert_eq!(r.suspects, 0);
636 }
637
638 #[test]
639 fn one_line_identities_still_convict_beside_a_chatty_member() {
640 let mut signals: Vec<MemberSignals> = (0..30)
643 .map(|i| raider(&format!("npub_r{i}"), NOW - 90 + i, "claim your free airdrop"))
644 .collect();
645 signals.push(regular("npub_regular", 200, 400, &["claim your free airdrop", "lol", "sure", "ok"]));
646 let r = assess(&signals, NOW, &RaidParams::default());
647 assert_eq!(r.suspects, 30);
648 assert_eq!(r.members.iter().find(|m| m.npub == "npub_regular").unwrap().verdict, Verdict::Trusted);
649 }
650
651 #[test]
652 fn shared_emoji_reactions_never_form_a_cohort() {
653 let signals: Vec<MemberSignals> = (0..6)
655 .map(|i| raider(&format!("npub_{i}"), NOW - 300 + i, ":vector_logo:"))
656 .collect();
657 let p = RaidParams { known_shortcodes: HashSet::from(["vector_logo".to_string()]), ..Default::default() };
658 let r = assess(&signals, NOW, &p);
659 assert_eq!(r.suspects, 0);
660 assert!(r.cohorts.is_empty(), "an emoji-only message carries no convicting content");
661 }
662
663 #[test]
664 fn a_shortcode_does_not_hide_the_text_around_it() {
665 assert_eq!(skeleton("free airdrop :fire: click here"), skeleton("free airdrop click here"));
666 assert_eq!(skeleton(":smile:"), "");
667 assert_eq!(skeleton("note: buy now"), skeleton("note buy now"));
669 assert!(!skeleton("ratio 3:1 wins").is_empty());
670 }
671
672 #[test]
673 fn an_empty_community_assesses_cleanly() {
674 let r = assess(&[], NOW, &RaidParams::default());
675 assert_eq!(r.suspects, 0);
676 assert!(!r.raid_detected);
677 assert_eq!(r.burst_size, 0);
678 }
679}