1use super::document::*;
10use super::types::Severity;
11
12#[derive(Debug, Clone, serde::Serialize)]
14pub struct Dial {
15 pub key: &'static str,
16 pub label: &'static str,
17 pub kind: &'static str, pub hint: &'static str,
19}
20
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct Preset {
23 pub id: &'static str,
24 pub name: &'static str,
25 pub description: &'static str,
27 pub example: &'static str,
29 pub caveat: &'static str,
32 pub dials: Vec<Dial>,
33 pub policy: Policy,
35}
36
37fn tiers(per_message: Vec<Rung>, per_window: Vec<Rung>) -> Option<Tiers> {
38 Some(Tiers { per_message, per_window })
39}
40
41fn rung(hits: u32, severity: Severity, weight: u32) -> Rung {
42 Rung { hits, severity, weight, pierces_trusted: false }
43}
44
45fn rule(id: &str, matcher: Match, t: Option<Tiers>) -> Rule {
46 Rule {
47 id: id.into(),
48 matcher,
49 tiers: t,
50 severity: None,
51 weight: None,
52 pierces_trusted: false,
53 family: None,
54 armed_by: None,
55 exempt: Exempt::default(),
56 enforcement: Enforcement::Advisory,
57 }
58}
59
60fn single(id: &str, matcher: Match, severity: Severity, weight: u32, armed_by: Option<ArmedBy>) -> Rule {
61 Rule {
62 id: id.into(),
63 matcher,
64 tiers: None,
65 severity: Some(severity),
66 weight: Some(weight),
67 pierces_trusted: false,
68 family: None,
69 armed_by,
70 exempt: Exempt::default(),
71 enforcement: Enforcement::Advisory,
72 }
73}
74
75fn base(name: &str, rules: Vec<Rule>, hours: u64) -> Policy {
76 Policy {
77 format: FORMAT,
78 requires: vec![],
79 name: name.into(),
80 emoji_codes: vec![],
81 shields: Shields::default(),
82 window: Window { hours, max_messages: 4000 },
83 exempt: Exempt::default(),
84 rules,
85 }
86}
87
88pub const SHORTENERS: &[&str] = &[
92 "bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "adf.ly", "bit.do", "cutt.ly",
93 "rebrand.ly", "shorturl.at", "rb.gy", "tiny.cc", "shorte.st", "bc.vc", "clck.ru", "soo.gd", "s2r.co",
94 "tr.ee", "dub.sh", "e.vg", "paw.wf", "shm.to", "snl.ink", "surl.li", "url9.de", "waffl.link",
95];
96
97#[derive(Debug, Clone, serde::Serialize)]
104pub struct RuleSummary {
105 pub label: String,
106 pub detail: String,
107 pub armed: bool,
110}
111
112fn plural(n: u32, one: &str, many: &str) -> String {
113 if n == 1 { format!("{n} {one}") } else { format!("{n} {many}") }
114}
115
116fn describe_thresholds(r: &Rule) -> String {
117 let window_scoped = !matches!(r.matcher, Match::Repeat { within_secs: Some(_), .. });
120 if let Some(t) = &r.tiers {
121 let mut parts: Vec<String> = Vec::new();
122 for rung in &t.per_message {
123 parts.push(format!("{} in one message", plural(rung.hits, "hit", "hits")));
124 }
125 for rung in &t.per_window {
126 parts.push(if window_scoped {
127 format!("{} in the window", plural(rung.hits, "hit", "hits"))
128 } else {
129 plural(rung.hits, "hit", "hits")
130 });
131 }
132 if !parts.is_empty() {
133 return format!("Trips at {}.", parts.join(", then "));
134 }
135 }
136 String::new()
137}
138
139pub fn describe(policy: &Policy) -> Vec<RuleSummary> {
141 policy.rules.iter().map(describe_rule).collect()
142}
143
144pub fn describe_rule(r: &Rule) -> RuleSummary {
145 let (label, mut detail) = match &r.matcher {
146 Match::Keyword { patterns, .. } => (
147 "Words".to_string(),
148 format!(
149 "{} on the list, matched as whole words unless you wrap them in *.",
150 plural(patterns.len() as u32, "word", "words")
151 ),
152 ),
153 Match::Regex { patterns, .. } => (
154 "Patterns".to_string(),
155 plural(patterns.len() as u32, "expression", "expressions") + " matched against each message.",
156 ),
157 Match::Link { patterns } => (
158 "Link domains".to_string(),
159 format!(
160 "{} blocked, subdomains included.",
161 plural(patterns.len() as u32, "domain", "domains")
162 ),
163 ),
164 Match::Repeat { within_secs, .. } => (
165 "Same message repeated".to_string(),
166 match within_secs {
167 Some(secs) => format!(
168 "One account posting the same thing over and over inside {} minutes. Case, punctuation and digits are ignored, so changing them does not help.",
169 secs / 60
170 ),
171 None => "One account posting the same thing over and over. Case, punctuation and digits are ignored, so changing them does not help.".to_string(),
172 },
173 ),
174 Match::Rate { per_secs } => (
175 "Posting too fast".to_string(),
176 format!(
177 "One account's messages counted over any {} seconds, whatever they say.",
178 per_secs
179 ),
180 ),
181 Match::Mentions {} => (
182 "Mass tagging".to_string(),
183 "How many DISTINCT people one message names. Tagging the same person repeatedly counts once.".to_string(),
184 ),
185 Match::Cohort { min, .. } => (
186 "Many accounts, one line".to_string(),
187 format!(
188 "{} or more separate accounts posting the same line at the same time. The shape of a raid.",
189 min
190 ),
191 ),
192 Match::JoinBurst { gap_secs, min } => (
193 "Join flood".to_string(),
194 format!("{} or more joins within {} minutes.", min, gap_secs / 60),
195 ),
196 Match::TenureLt { secs } => (
197 "New account".to_string(),
198 format!("Joined less than {} hours ago.", secs / 3600),
199 ),
200 Match::MessagesLte { n } => (
201 "Barely posted".to_string(),
202 format!("Has posted at most {}.", plural(*n, "message", "messages")),
203 ),
204 };
205 let t = describe_thresholds(r);
206 if !t.is_empty() {
207 detail.push(' ');
208 detail.push_str(&t);
209 }
210 RuleSummary { label, detail, armed: r.armed_by.is_some() }
211}
212
213#[derive(Debug, Clone, serde::Serialize)]
216pub struct RuleKind {
217 pub id: &'static str,
218 pub label: &'static str,
219 pub description: &'static str,
221 pub input: &'static str,
223 pub input_label: &'static str,
224 pub input_hint: &'static str,
225 pub rule: Rule,
228}
229
230pub fn rule_kinds() -> Vec<RuleKind> {
238 vec![
239 RuleKind {
240 id: "keyword",
241 label: "Words",
242 description: "Someone posts a word from your list.",
243 input: "wordlist",
244 input_label: "Words to catch",
245 input_hint: "One per line. Use *word* to match inside longer words.",
246 rule: rule(
247 "words",
248 Match::Keyword { patterns: vec![], normalize: Normalize::Fold },
249 tiers(vec![rung(1, Severity::Minor, 10)], vec![rung(10, Severity::Major, 45)]),
250 ),
251 },
252 RuleKind {
253 id: "link",
254 label: "Link domains",
255 description: "Someone posts a link to a domain on your list.",
256 input: "domainlist",
257 input_label: "Domains to block",
258 input_hint: "One per line. Matches the domain and its subdomains.",
259 rule: rule(
260 "links",
261 Match::Link { patterns: vec![] },
262 tiers(vec![rung(1, Severity::Severe, 70)], vec![rung(3, Severity::Severe, 90)]),
263 ),
264 },
265 RuleKind {
266 id: "repeat",
267 label: "Same message repeated",
268 description: "One account posting the same thing over and over inside half an hour. Case, punctuation and digits are ignored, so changing them does not help.",
269 input: "none",
270 input_label: "",
271 input_hint: "",
272 rule: rule(
273 "repeat",
274 Match::Repeat { normalize: Normalize::Skeleton, within_secs: Some(super::harness::REPEAT_BURST_SECS) },
275 tiers(vec![], vec![rung(4, Severity::Major, 50), rung(8, Severity::Severe, 85)]),
276 ),
277 },
278 RuleKind {
279 id: "mentions",
280 label: "Mass tagging",
281 description: "One message naming a crowd. Counts distinct people, so twenty pings at one person is one person — annoying, but not a raid.",
282 input: "none",
283 input_label: "",
284 input_hint: "",
285 rule: rule(
286 "mentions",
287 Match::Mentions {},
288 tiers(vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)], vec![]),
289 ),
290 },
291 RuleKind {
292 id: "rate",
293 label: "Posting too fast",
294 description: "One account posting faster than a person reasonably types, whatever they are saying. Catches a spammer who varies their text just enough to look different every time.",
295 input: "seconds",
296 input_label: "Over how many seconds",
297 input_hint: "The span it counts within. 10 catches a burst; 60 catches a sustained flood.",
298 rule: rule(
299 "rate",
300 Match::Rate { per_secs: 10 },
301 tiers(vec![], vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)]),
302 ),
303 },
304 RuleKind {
305 id: "cohort",
306 label: "Many accounts, one line",
307 description: "Separate accounts posting the same line at the same time. The shape of a raid.",
308 input: "none",
309 input_label: "",
310 input_hint: "",
311 rule: single(
312 "cohort",
313 Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
314 Severity::Severe,
315 85,
316 None,
317 ),
318 },
319 ]
320}
321
322pub fn all() -> Vec<Preset> {
323 vec![
324 Preset {
325 id: super::harness::DEFAULTS_POLICY_ID,
326 name: "Vector's Defaults",
327 description: "Raid detection, running here already. Open it to read every rule, or save your own version.",
328 example: "a swarm of fresh accounts posting one line",
329 caveat: "A Built-In Anti-Raid Policy: detects raids proactively and alerts you with a list of suspects for quick handling.",
330 dials: vec![
331 Dial { key: "summary", label: "What these rules do", kind: "summary",
332 hint: "Every rule that runs here, in order." },
333 Dial { key: "strictness", label: "Sensitivity", kind: "strictness",
334 hint: "How much it takes to trip a rule, and how confident the result is." },
335 ],
336 policy: super::harness::default_policy(),
337 },
338 Preset {
339 id: "scam_links",
340 name: "Scam Links",
341 description: "Blocks link shorteners and redirectors that scams hide behind.",
342 example: "claim your airdrop at bit.ly/…",
343 caveat: "Only catches the shorteners on the bundled list, plus any domain you add.",
344 dials: vec![
345 Dial { key: "domains", label: "Also block these domains", kind: "domainlist",
346 hint: "One per line. The bundled shortener list is always included." },
347 Dial { key: "allow", label: "Never flag these domains", kind: "domainlist",
348 hint: "Your own links, docs, anything you trust." },
349 ],
350 policy: base(
351 "Scam Links",
352 vec![rule(
353 "shorteners",
354 Match::Link { patterns: SHORTENERS.iter().map(|s| s.to_string()).collect() },
355 tiers(vec![rung(1, Severity::Severe, 70)], vec![rung(3, Severity::Severe, 90)]),
356 )],
357 168,
358 ),
359 },
360 Preset {
361 id: "no_spam",
362 name: "No Spam",
363 description: "The same message over and over, from one account or many.",
364 example: "the same pitch, twelve times",
365 caveat: "Counts shapes, not words: changing case, punctuation or digits does not help a spammer.",
366 dials: vec![Dial {
367 key: "strictness",
368 label: "Sensitivity",
369 kind: "strictness",
370 hint: "How much it takes to trip a rule, and how confident the result is.",
371 }],
372 policy: base(
373 "No Spam",
374 vec![
375 rule(
376 "repeat",
377 Match::Repeat { normalize: Normalize::Skeleton, within_secs: Some(super::harness::REPEAT_BURST_SECS) },
378 tiers(vec![], vec![rung(4, Severity::Major, 50), rung(8, Severity::Severe, 85)]),
379 ),
380 single(
381 "cohort",
382 Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
383 Severity::Severe,
384 85,
385 None,
386 ),
387 ],
388 168,
389 ),
390 },
391 Preset {
392 id: "word_filter",
393 name: "Word Filter",
394 description: "Any list of words you choose, with per-channel exceptions.",
395 example: "spoilers, slurs, or a scam phrase",
396 caveat: "Whole words by default, so filtering \"art\" leaves \"start\" alone.",
397 dials: vec![
398 Dial { key: "words", label: "Words to catch", kind: "wordlist",
399 hint: "One per line. Use *word* to match inside longer words." },
400 Dial { key: "exempt_channels", label: "Allowed in these channels", kind: "channels",
401 hint: "The filter ignores these channels entirely." },
402 ],
403 policy: base(
404 "Word Filter",
405 vec![rule(
406 "words",
407 Match::Keyword { patterns: vec![], normalize: Normalize::Fold },
408 tiers(vec![rung(1, Severity::Minor, 10)], vec![rung(10, Severity::Major, 45)]),
409 )],
410 168,
411 ),
412 },
413 Preset {
414 id: "mass_tagging",
415 name: "Mass Tagging",
416 description: "One message that names a crowd, rather than a person.",
417 example: "a post tagging twelve people at once",
418 caveat: "Counts distinct people per message: pinging one person twenty times reads as one, because it is.",
419 dials: vec![
420 Dial { key: "strictness", label: "Sensitivity", kind: "strictness",
421 hint: "How much it takes to trip a rule, and how confident the result is." },
422 Dial { key: "exempt_channels", label: "Allowed in these channels", kind: "channels",
423 hint: "Announcement channels where tagging everyone is the point." },
424 ],
425 policy: base(
426 "Mass Tagging",
427 vec![rule(
428 "mentions",
429 Match::Mentions {},
430 tiers(vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)], vec![]),
431 )],
432 168,
433 ),
434 },
435 Preset {
436 id: "rate_limit",
437 name: "Rate Limit",
438 description: "Too many messages too quickly from one account, whatever they say.",
439 example: "twelve messages in ten seconds",
440 caveat: "Counts messages, not content: a fast typer in a lively channel can trip it, so preview before enabling.",
441 dials: vec![
442 Dial { key: "per_secs", label: "Over how many seconds", kind: "seconds",
443 hint: "The span it counts within. 10 catches a burst; 60 catches a sustained flood." },
444 Dial { key: "strictness", label: "Sensitivity", kind: "strictness",
445 hint: "How much it takes to trip a rule, and how confident the result is." },
446 ],
447 policy: base(
448 "Rate Limit",
449 vec![rule(
450 "rate",
451 Match::Rate { per_secs: 10 },
452 tiers(vec![], vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)]),
453 )],
454 168,
455 ),
456 },
457 Preset {
458 id: "blank",
459 name: "Start from scratch",
460 description: "An empty policy. Name it, add the rules you want, preview before anything runs.",
461 example: "whatever this community actually needs",
462 caveat: "A policy with no rules catches nothing, and the preview will tell you so.",
463 dials: vec![
464 Dial { key: "name", label: "Policy name", kind: "text",
465 hint: "What you will see in the list." },
466 Dial { key: "rules", label: "Rules", kind: "rules",
467 hint: "Add as many as you like. Any one of them can convict on its own." },
468 Dial { key: "exempt_channels", label: "Allowed in these channels", kind: "channels",
469 hint: "These channels are ignored entirely." },
470 ],
471 policy: base("New policy", vec![], 168),
472 },
473 ]
474}
475
476pub fn by_id(id: &str) -> Option<Preset> {
477 all().into_iter().find(|p| p.id == id)
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
487 fn every_preset_validates() {
488 for p in all() {
489 assert!(p.policy.validate().is_ok(), "preset {} must validate: {:?}", p.id, p.policy.validate());
490 }
491 }
492
493 #[test]
496 fn no_preset_lets_a_weak_signal_convict_alone() {
497 for p in all() {
498 for r in &p.policy.rules {
499 let describes_the_innocent =
504 matches!(r.matcher, Match::TenureLt { .. } | Match::MessagesLte { .. });
505 if describes_the_innocent {
506 assert!(r.armed_by.is_some(), "preset {} rule {} must never speak alone", p.id, r.id);
507 }
508 }
509 }
510 }
511
512 #[test]
515 fn every_preset_states_its_limits() {
516 for p in all() {
517 assert!(!p.caveat.is_empty(), "preset {} needs a caveat", p.id);
518 assert!(!p.example.is_empty(), "preset {} needs a concrete example", p.id);
519 assert!(!p.dials.is_empty(), "preset {} needs at least one dial", p.id);
520 }
521 }
522
523 #[test]
526 fn every_rule_of_every_preset_is_described() {
527 for p in all() {
528 let described = describe(&p.policy);
529 assert_eq!(described.len(), p.policy.rules.len(), "{} left rules undescribed", p.id);
530 for (d, r) in described.iter().zip(p.policy.rules.iter()) {
531 assert!(!d.label.is_empty() && !d.detail.is_empty(), "{} rule {} has no words", p.id, r.id);
532 assert_eq!(d.armed, r.armed_by.is_some(), "{} rule {} misreports arming", p.id, r.id);
533 }
534 }
535 }
536
537 #[test]
540 fn the_shipped_defaults_mark_their_aggravators() {
541 let d = describe(&super::super::harness::default_policy());
542 assert!(d.iter().any(|x| x.armed), "the defaults carry aggravators and none was marked");
543 assert!(d.iter().any(|x| !x.armed), "every rule marked armed would leave nothing that can convict");
544 }
545}