@joliegg/moderation 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions.d.ts +28 -0
- package/dist/actions.js +48 -0
- package/dist/audit/emitter.d.ts +26 -0
- package/dist/audit/emitter.js +63 -0
- package/dist/audit/events.d.ts +75 -0
- package/dist/audit/events.js +2 -0
- package/dist/audit/index.d.ts +2 -0
- package/dist/audit/index.js +18 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +107 -0
- package/dist/index.d.ts +3 -41
- package/dist/index.js +20 -213
- package/dist/providers/aws.d.ts +11 -0
- package/dist/providers/aws.js +58 -0
- package/dist/providers/google.d.ts +21 -0
- package/dist/providers/google.js +61 -0
- package/dist/providers/openai.d.ts +15 -0
- package/dist/providers/openai.js +54 -0
- package/dist/providers/webrisk.d.ts +9 -0
- package/dist/providers/webrisk.js +33 -0
- package/dist/raid/age.d.ts +6 -0
- package/dist/raid/age.js +19 -0
- package/dist/raid/detector.d.ts +56 -0
- package/dist/raid/detector.js +90 -0
- package/dist/raid/index.d.ts +2 -0
- package/dist/raid/index.js +18 -0
- package/dist/rubrics/defaults.d.ts +19 -0
- package/dist/rubrics/defaults.js +32 -0
- package/dist/rubrics/index.d.ts +3 -0
- package/dist/rubrics/index.js +19 -0
- package/dist/rubrics/rubric.d.ts +21 -0
- package/dist/rubrics/rubric.js +57 -0
- package/dist/rubrics/types.d.ts +27 -0
- package/dist/rubrics/types.js +2 -0
- package/dist/spam/cache.d.ts +99 -0
- package/dist/spam/cache.js +210 -0
- package/dist/spam/index.d.ts +1 -0
- package/dist/spam/index.js +17 -0
- package/dist/text/index.d.ts +2 -0
- package/dist/text/index.js +18 -0
- package/dist/text/mentions.d.ts +31 -0
- package/dist/text/mentions.js +55 -0
- package/dist/text/normalize.d.ts +15 -0
- package/dist/text/normalize.js +45 -0
- package/dist/types/config.d.ts +13 -0
- package/dist/types/config.js +2 -0
- package/dist/types/index.d.ts +3 -10
- package/dist/types/index.js +15 -0
- package/package.json +66 -13
- package/src/actions.ts +50 -0
- package/src/audit/emitter.ts +77 -0
- package/src/audit/events.ts +89 -0
- package/src/audit/index.ts +2 -0
- package/src/client.ts +137 -0
- package/src/index.ts +3 -277
- package/src/providers/aws.ts +58 -0
- package/src/providers/google.ts +63 -0
- package/src/providers/openai.ts +64 -0
- package/src/providers/webrisk.ts +30 -0
- package/src/raid/age.ts +19 -0
- package/src/raid/detector.ts +133 -0
- package/src/raid/index.ts +2 -0
- package/src/rubrics/defaults.ts +32 -0
- package/src/rubrics/index.ts +3 -0
- package/src/rubrics/rubric.ts +62 -0
- package/src/rubrics/types.ts +30 -0
- package/src/spam/cache.ts +342 -0
- package/src/spam/index.ts +1 -0
- package/src/text/index.ts +2 -0
- package/src/text/mentions.ts +91 -0
- package/src/text/normalize.ts +43 -0
- package/src/types/config.ts +14 -0
- package/src/types/index.ts +5 -11
- /package/dist/{url-blacklist.json → data/url-blacklist.json} +0 -0
- /package/dist/{url-shorteners.json → data/url-shorteners.json} +0 -0
- /package/src/{url-blacklist.json → data/url-blacklist.json} +0 -0
- /package/src/{url-shorteners.json → data/url-shorteners.json} +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RaidDetector = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Platform-agnostic raid detector. Tracks recent joins per guild in a
|
|
6
|
+
* sliding window and surfaces the "raid" signal when the join count
|
|
7
|
+
* crosses the configured threshold.
|
|
8
|
+
*
|
|
9
|
+
* State transitions (`tryEnable`, `disable`) are guarded by an
|
|
10
|
+
* in-memory mutex so concurrent `handleMemberJoin` invocations during
|
|
11
|
+
* a join burst cannot both see "not active" and double-fire the
|
|
12
|
+
* enable side effects.
|
|
13
|
+
*
|
|
14
|
+
* The detector owns the sliding-window state. It does NOT own the
|
|
15
|
+
* enforcement — callers decide what to do when `isRaid` is true
|
|
16
|
+
* (timeout, kick, auto-disable timer, mod-channel alert, etc.).
|
|
17
|
+
*/
|
|
18
|
+
class RaidDetector {
|
|
19
|
+
joinThreshold;
|
|
20
|
+
joinWindow;
|
|
21
|
+
state = new Map();
|
|
22
|
+
enabling = new Set();
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.joinThreshold = options.joinThreshold ?? 10;
|
|
25
|
+
this.joinWindow = (options.joinWindow ?? 60) * 1000;
|
|
26
|
+
}
|
|
27
|
+
getState(guildId) {
|
|
28
|
+
if (!this.state.has(guildId)) {
|
|
29
|
+
this.state.set(guildId, { joins: [], raidActive: false });
|
|
30
|
+
}
|
|
31
|
+
return this.state.get(guildId);
|
|
32
|
+
}
|
|
33
|
+
cleanupJoins(state, now) {
|
|
34
|
+
state.joins = state.joins.filter(j => now - j.timestamp < this.joinWindow);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Record a join and return whether the guild has crossed the raid
|
|
38
|
+
* threshold inside the window. `tryEnable` is a separate call so the
|
|
39
|
+
* caller can act on the raid signal atomically.
|
|
40
|
+
*/
|
|
41
|
+
track(guildId, member) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const state = this.getState(guildId);
|
|
44
|
+
this.cleanupJoins(state, now);
|
|
45
|
+
state.joins.push({ memberId: member.memberId, timestamp: now });
|
|
46
|
+
return {
|
|
47
|
+
isRaid: state.joins.length >= this.joinThreshold,
|
|
48
|
+
joinCount: state.joins.length,
|
|
49
|
+
windowSeconds: this.joinWindow / 1000,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
isActive(guildId) {
|
|
53
|
+
return this.state.get(guildId)?.raidActive ?? false;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Attempt to flip the guild into raid-active state. Returns:
|
|
57
|
+
* - 'enabled' if this call performed the transition
|
|
58
|
+
* - 'already_active' if raid mode was already on
|
|
59
|
+
* - 'already_enabling' if another concurrent call is mid-transition
|
|
60
|
+
*/
|
|
61
|
+
async tryEnable(guildId) {
|
|
62
|
+
const state = this.getState(guildId);
|
|
63
|
+
if (state.raidActive) {
|
|
64
|
+
return 'already_active';
|
|
65
|
+
}
|
|
66
|
+
if (this.enabling.has(guildId)) {
|
|
67
|
+
return 'already_enabling';
|
|
68
|
+
}
|
|
69
|
+
this.enabling.add(guildId);
|
|
70
|
+
try {
|
|
71
|
+
state.raidActive = true;
|
|
72
|
+
return 'enabled';
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
this.enabling.delete(guildId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
disable(guildId) {
|
|
79
|
+
const state = this.getState(guildId);
|
|
80
|
+
state.raidActive = false;
|
|
81
|
+
this.enabling.delete(guildId);
|
|
82
|
+
}
|
|
83
|
+
getJoinCount(guildId, windowSeconds) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const state = this.getState(guildId);
|
|
86
|
+
const window = (windowSeconds ?? this.joinWindow / 1000) * 1000;
|
|
87
|
+
return state.joins.filter(j => now - j.timestamp < window).length;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.RaidDetector = RaidDetector;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./detector"), exports);
|
|
18
|
+
__exportStar(require("./age"), exports);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ScoringRubric } from './rubric';
|
|
2
|
+
/**
|
|
3
|
+
* Aggressive defaults. Flag anything moderately suspicious, delete at
|
|
4
|
+
* 75%, timeout above 90%. Good for tight-knit communities with low
|
|
5
|
+
* tolerance for borderline content.
|
|
6
|
+
*/
|
|
7
|
+
export declare const STRICT_RUBRIC: ScoringRubric;
|
|
8
|
+
/**
|
|
9
|
+
* Conservative defaults. Only act on very high confidence. Good for
|
|
10
|
+
* large communities where false positives are worse than false
|
|
11
|
+
* negatives.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PERMISSIVE_RUBRIC: ScoringRubric;
|
|
14
|
+
/**
|
|
15
|
+
* NSFW-only rubric. Ignores harassment, hate, violence — only acts on
|
|
16
|
+
* sexual content. Useful when text moderation is handled out-of-band
|
|
17
|
+
* but image / link moderation still needs safety filtering.
|
|
18
|
+
*/
|
|
19
|
+
export declare const NSFW_ONLY_RUBRIC: ScoringRubric;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NSFW_ONLY_RUBRIC = exports.PERMISSIVE_RUBRIC = exports.STRICT_RUBRIC = void 0;
|
|
4
|
+
const actions_1 = require("../actions");
|
|
5
|
+
const rubric_1 = require("./rubric");
|
|
6
|
+
/**
|
|
7
|
+
* Aggressive defaults. Flag anything moderately suspicious, delete at
|
|
8
|
+
* 75%, timeout above 90%. Good for tight-knit communities with low
|
|
9
|
+
* tolerance for borderline content.
|
|
10
|
+
*/
|
|
11
|
+
exports.STRICT_RUBRIC = new rubric_1.ScoringRubric([
|
|
12
|
+
{ match: { minConfidence: 50 }, action: actions_1.ACTION_TYPES.WARN, severity: 'low' },
|
|
13
|
+
{ match: { minConfidence: 75 }, action: actions_1.ACTION_TYPES.DELETE, severity: 'medium' },
|
|
14
|
+
{ match: { minConfidence: 90 }, action: actions_1.ACTION_TYPES.TIMEOUT, severity: 'high' },
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Conservative defaults. Only act on very high confidence. Good for
|
|
18
|
+
* large communities where false positives are worse than false
|
|
19
|
+
* negatives.
|
|
20
|
+
*/
|
|
21
|
+
exports.PERMISSIVE_RUBRIC = new rubric_1.ScoringRubric([
|
|
22
|
+
{ match: { minConfidence: 95 }, action: actions_1.ACTION_TYPES.DELETE, severity: 'high' },
|
|
23
|
+
{ match: { minConfidence: 99 }, action: actions_1.ACTION_TYPES.TIMEOUT, severity: 'critical' },
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* NSFW-only rubric. Ignores harassment, hate, violence — only acts on
|
|
27
|
+
* sexual content. Useful when text moderation is handled out-of-band
|
|
28
|
+
* but image / link moderation still needs safety filtering.
|
|
29
|
+
*/
|
|
30
|
+
exports.NSFW_ONLY_RUBRIC = new rubric_1.ScoringRubric([
|
|
31
|
+
{ match: { category: 'sexual', minConfidence: 70 }, action: actions_1.ACTION_TYPES.DELETE, severity: 'high' },
|
|
32
|
+
]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./types"), exports);
|
|
18
|
+
__exportStar(require("./rubric"), exports);
|
|
19
|
+
__exportStar(require("./defaults"), exports);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ModerationCategory } from '../types';
|
|
2
|
+
import type { RubricResult, RubricRule } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Composable classifier-to-action mapping.
|
|
5
|
+
*
|
|
6
|
+
* Callers hand a `ScoringRubric` their classifier output
|
|
7
|
+
* (`ModerationCategory[]`) and get back a decision: the recommended
|
|
8
|
+
* action, the aggregate severity, and the categories that contributed.
|
|
9
|
+
*
|
|
10
|
+
* Apps can use the ship-with defaults (`STRICT_RUBRIC`,
|
|
11
|
+
* `PERMISSIVE_RUBRIC`, `NSFW_ONLY_RUBRIC`) or pass custom rules.
|
|
12
|
+
*
|
|
13
|
+
* Rules are evaluated in input order; the highest-severity match wins
|
|
14
|
+
* the recommended action. Every matched category is returned in the
|
|
15
|
+
* result so callers can explain the decision.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ScoringRubric {
|
|
18
|
+
private rules;
|
|
19
|
+
constructor(rules: RubricRule[]);
|
|
20
|
+
evaluate(categories: ModerationCategory[]): RubricResult;
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ScoringRubric = void 0;
|
|
4
|
+
const SEVERITY_ORDER = {
|
|
5
|
+
low: 0,
|
|
6
|
+
medium: 1,
|
|
7
|
+
high: 2,
|
|
8
|
+
critical: 3,
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Composable classifier-to-action mapping.
|
|
12
|
+
*
|
|
13
|
+
* Callers hand a `ScoringRubric` their classifier output
|
|
14
|
+
* (`ModerationCategory[]`) and get back a decision: the recommended
|
|
15
|
+
* action, the aggregate severity, and the categories that contributed.
|
|
16
|
+
*
|
|
17
|
+
* Apps can use the ship-with defaults (`STRICT_RUBRIC`,
|
|
18
|
+
* `PERMISSIVE_RUBRIC`, `NSFW_ONLY_RUBRIC`) or pass custom rules.
|
|
19
|
+
*
|
|
20
|
+
* Rules are evaluated in input order; the highest-severity match wins
|
|
21
|
+
* the recommended action. Every matched category is returned in the
|
|
22
|
+
* result so callers can explain the decision.
|
|
23
|
+
*/
|
|
24
|
+
class ScoringRubric {
|
|
25
|
+
rules;
|
|
26
|
+
constructor(rules) {
|
|
27
|
+
this.rules = rules;
|
|
28
|
+
}
|
|
29
|
+
evaluate(categories) {
|
|
30
|
+
const matched = [];
|
|
31
|
+
const reasons = [];
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const category of categories) {
|
|
34
|
+
for (const rule of this.rules) {
|
|
35
|
+
const categoryMatches = rule.match.category === undefined || category.category.includes(rule.match.category);
|
|
36
|
+
if (!categoryMatches) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (category.confidence < rule.match.minConfidence) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
matched.push(category);
|
|
43
|
+
reasons.push(`${category.category} (${category.confidence.toFixed(0)}%) >= ${rule.match.minConfidence}% → ${rule.action}`);
|
|
44
|
+
if (!best || SEVERITY_ORDER[rule.severity] > SEVERITY_ORDER[best.severity]) {
|
|
45
|
+
best = rule;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
action: best?.action ?? null,
|
|
51
|
+
severity: best?.severity ?? 'low',
|
|
52
|
+
matched,
|
|
53
|
+
reasons,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.ScoringRubric = ScoringRubric;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ModerationCategory, Severity } from '../types';
|
|
2
|
+
import type { ActionType } from '../actions';
|
|
3
|
+
export interface RubricMatch {
|
|
4
|
+
/**
|
|
5
|
+
* Category name to match. Omit for a wildcard that fires on any category.
|
|
6
|
+
* Matched with substring `includes`, so a rule for `sexual` also fires
|
|
7
|
+
* on compound categories like `sexual/minors`.
|
|
8
|
+
*/
|
|
9
|
+
category?: string;
|
|
10
|
+
/** Minimum confidence (0-100) required for this rule to fire. */
|
|
11
|
+
minConfidence: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RubricRule {
|
|
14
|
+
match: RubricMatch;
|
|
15
|
+
action: ActionType;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
}
|
|
18
|
+
export interface RubricResult {
|
|
19
|
+
/** The action the highest-severity matched rule recommends. */
|
|
20
|
+
action: ActionType | null;
|
|
21
|
+
/** Aggregate severity — matches the winning rule, or `'low'` if nothing matched. */
|
|
22
|
+
severity: Severity;
|
|
23
|
+
/** Every category that matched at least one rule. */
|
|
24
|
+
matched: ModerationCategory[];
|
|
25
|
+
/** Human-readable explanations — one per matched rule. */
|
|
26
|
+
reasons: string[];
|
|
27
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export interface SpamCacheOptions {
|
|
2
|
+
/** Maximum messages allowed within the rate-limit window. Default 8. */
|
|
3
|
+
rateLimit?: number;
|
|
4
|
+
/** Rate-limit window in seconds. Default 10. */
|
|
5
|
+
rateLimitWindow?: number;
|
|
6
|
+
/** How many identical messages trigger a duplicate alert. Default 3. */
|
|
7
|
+
duplicateThreshold?: number;
|
|
8
|
+
/** Duplicate-detection window in seconds. Default 30. */
|
|
9
|
+
duplicateWindow?: number;
|
|
10
|
+
/** Daytime timeout duration in minutes. Default 180. */
|
|
11
|
+
timeoutDurationDay?: number;
|
|
12
|
+
/** Hour of day (0-23) when nighttime timeouts start. Default 23. */
|
|
13
|
+
nightStartHour?: number;
|
|
14
|
+
/** Hour of day (0-23) when nighttime timeouts end. Default 11. */
|
|
15
|
+
nightEndHour?: number;
|
|
16
|
+
/** IANA timezone for night detection. Default 'America/Mexico_City'. */
|
|
17
|
+
timezone?: string;
|
|
18
|
+
/** LRU capacity for tracked users. Default 10000. */
|
|
19
|
+
maxUsers?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SpamContent {
|
|
22
|
+
text?: string;
|
|
23
|
+
attachments?: {
|
|
24
|
+
name: string;
|
|
25
|
+
size: number;
|
|
26
|
+
}[];
|
|
27
|
+
stickerIds?: string[];
|
|
28
|
+
messageId?: string | null;
|
|
29
|
+
channelId?: string | null;
|
|
30
|
+
}
|
|
31
|
+
export interface SpamMessageRef {
|
|
32
|
+
messageId: string;
|
|
33
|
+
channelId: string;
|
|
34
|
+
}
|
|
35
|
+
export type SpamReason = 'rate_limit' | 'duplicate';
|
|
36
|
+
export interface SpamResult {
|
|
37
|
+
isSpam: boolean;
|
|
38
|
+
reason: SpamReason | null;
|
|
39
|
+
details: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Message references that contributed to the spam trigger.
|
|
42
|
+
*/
|
|
43
|
+
priorMessageIds?: SpamMessageRef[];
|
|
44
|
+
}
|
|
45
|
+
export interface SpamCacheStats {
|
|
46
|
+
trackedUsers: number;
|
|
47
|
+
maxUsers: number;
|
|
48
|
+
totalTimestamps: number;
|
|
49
|
+
totalHashes: number;
|
|
50
|
+
config: {
|
|
51
|
+
rateLimit: number;
|
|
52
|
+
rateLimitWindowSeconds: number;
|
|
53
|
+
duplicateThreshold: number;
|
|
54
|
+
duplicateWindowSeconds: number;
|
|
55
|
+
timeoutDurationDayMinutes: number;
|
|
56
|
+
nightHours: string;
|
|
57
|
+
timezone: string;
|
|
58
|
+
isNightTime: boolean;
|
|
59
|
+
currentTimeoutMinutes: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Spam Cache. Tracks per-user message timestamps and content hashes to detect
|
|
64
|
+
* three kinds of abuse:
|
|
65
|
+
*
|
|
66
|
+
* - Rate limit: too many messages in a rolling window
|
|
67
|
+
* - Duplicate: the same content repeated across messages
|
|
68
|
+
* - Cross-channel: the same user hopping channels in quick succession
|
|
69
|
+
*
|
|
70
|
+
* Consumers are responsible for calling `cleanup()` periodically to
|
|
71
|
+
* evict expired entries.
|
|
72
|
+
*/
|
|
73
|
+
export declare class SpamCache {
|
|
74
|
+
readonly rateLimit: number;
|
|
75
|
+
readonly rateLimitWindow: number;
|
|
76
|
+
readonly duplicateThreshold: number;
|
|
77
|
+
readonly duplicateWindow: number;
|
|
78
|
+
readonly timeoutDurationDay: number;
|
|
79
|
+
readonly nightStartHour: number;
|
|
80
|
+
readonly nightEndHour: number;
|
|
81
|
+
readonly timezone: string;
|
|
82
|
+
readonly maxUsers: number;
|
|
83
|
+
private userTracking;
|
|
84
|
+
constructor(options?: SpamCacheOptions);
|
|
85
|
+
private hashContent;
|
|
86
|
+
private generateContentId;
|
|
87
|
+
private getTracking;
|
|
88
|
+
private cleanupTracking;
|
|
89
|
+
track(userId: string, content: SpamContent): SpamResult;
|
|
90
|
+
reset(userId: string): void;
|
|
91
|
+
clear(): void;
|
|
92
|
+
getStats(): SpamCacheStats;
|
|
93
|
+
private getCurrentTime;
|
|
94
|
+
isNightTime(): boolean;
|
|
95
|
+
getMinutesUntilNightEnd(): number;
|
|
96
|
+
getTimeoutDurationMinutes(): number;
|
|
97
|
+
getTimeoutDurationMs(): number;
|
|
98
|
+
cleanup(): number;
|
|
99
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SpamCache = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const normalize_1 = require("../text/normalize");
|
|
6
|
+
/**
|
|
7
|
+
* MD5 hash for content fingerprinting.
|
|
8
|
+
*
|
|
9
|
+
* Prefers Bun.CryptoHasher when available, falls back to node:crypto
|
|
10
|
+
*/
|
|
11
|
+
const md5 = (input) => {
|
|
12
|
+
// @ts-expect-error — Bun global is not in node types
|
|
13
|
+
if (typeof Bun !== 'undefined' && Bun.CryptoHasher) {
|
|
14
|
+
// @ts-expect-error — same
|
|
15
|
+
const hasher = new Bun.CryptoHasher('md5');
|
|
16
|
+
hasher.update(input);
|
|
17
|
+
return hasher.digest('hex');
|
|
18
|
+
}
|
|
19
|
+
return (0, node_crypto_1.createHash)('md5').update(input).digest('hex');
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Spam Cache. Tracks per-user message timestamps and content hashes to detect
|
|
23
|
+
* three kinds of abuse:
|
|
24
|
+
*
|
|
25
|
+
* - Rate limit: too many messages in a rolling window
|
|
26
|
+
* - Duplicate: the same content repeated across messages
|
|
27
|
+
* - Cross-channel: the same user hopping channels in quick succession
|
|
28
|
+
*
|
|
29
|
+
* Consumers are responsible for calling `cleanup()` periodically to
|
|
30
|
+
* evict expired entries.
|
|
31
|
+
*/
|
|
32
|
+
class SpamCache {
|
|
33
|
+
rateLimit;
|
|
34
|
+
rateLimitWindow;
|
|
35
|
+
duplicateThreshold;
|
|
36
|
+
duplicateWindow;
|
|
37
|
+
timeoutDurationDay;
|
|
38
|
+
nightStartHour;
|
|
39
|
+
nightEndHour;
|
|
40
|
+
timezone;
|
|
41
|
+
maxUsers;
|
|
42
|
+
userTracking = new Map();
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.rateLimit = options.rateLimit ?? 8;
|
|
45
|
+
this.rateLimitWindow = (options.rateLimitWindow ?? 10) * 1000;
|
|
46
|
+
this.duplicateThreshold = options.duplicateThreshold ?? 3;
|
|
47
|
+
this.duplicateWindow = (options.duplicateWindow ?? 30) * 1000;
|
|
48
|
+
this.timeoutDurationDay = options.timeoutDurationDay ?? 180;
|
|
49
|
+
this.nightStartHour = options.nightStartHour ?? 23;
|
|
50
|
+
this.nightEndHour = options.nightEndHour ?? 11;
|
|
51
|
+
this.timezone = options.timezone ?? 'America/Mexico_City';
|
|
52
|
+
this.maxUsers = options.maxUsers ?? 10000;
|
|
53
|
+
}
|
|
54
|
+
hashContent(content) {
|
|
55
|
+
return md5((0, normalize_1.normalizeText)(content));
|
|
56
|
+
}
|
|
57
|
+
generateContentId(options) {
|
|
58
|
+
const { text, attachments = [], stickerIds = [] } = options;
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (text && text.trim()) {
|
|
61
|
+
parts.push(`text:${this.hashContent(text)}`);
|
|
62
|
+
}
|
|
63
|
+
if (attachments.length > 0) {
|
|
64
|
+
const fingerprints = attachments.map(a => `${a.name}:${a.size}`).sort();
|
|
65
|
+
parts.push(`attachments:${this.hashContent(fingerprints.join('|'))}`);
|
|
66
|
+
}
|
|
67
|
+
if (stickerIds.length > 0) {
|
|
68
|
+
const sorted = [...stickerIds].sort();
|
|
69
|
+
parts.push(`stickers:${sorted.join(',')}`);
|
|
70
|
+
}
|
|
71
|
+
if (parts.length === 0) {
|
|
72
|
+
return `empty:${Date.now()}`;
|
|
73
|
+
}
|
|
74
|
+
return parts.join('::');
|
|
75
|
+
}
|
|
76
|
+
getTracking(userId) {
|
|
77
|
+
if (!this.userTracking.has(userId)) {
|
|
78
|
+
if (this.userTracking.size >= this.maxUsers) {
|
|
79
|
+
const firstKey = this.userTracking.keys().next().value;
|
|
80
|
+
if (firstKey !== undefined) {
|
|
81
|
+
this.userTracking.delete(firstKey);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.userTracking.set(userId, { timestamps: [], messageHashes: [] });
|
|
85
|
+
}
|
|
86
|
+
return this.userTracking.get(userId);
|
|
87
|
+
}
|
|
88
|
+
cleanupTracking(tracking, now) {
|
|
89
|
+
tracking.timestamps = tracking.timestamps.filter(e => now - e.time < this.rateLimitWindow);
|
|
90
|
+
tracking.messageHashes = tracking.messageHashes.filter(e => now - e.timestamp < this.duplicateWindow);
|
|
91
|
+
}
|
|
92
|
+
track(userId, content) {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const tracking = this.getTracking(userId);
|
|
95
|
+
this.cleanupTracking(tracking, now);
|
|
96
|
+
const messageId = content.messageId ?? null;
|
|
97
|
+
const channelId = content.channelId ?? null;
|
|
98
|
+
const contentId = this.generateContentId(content);
|
|
99
|
+
tracking.timestamps.push({ time: now, channelId, messageId });
|
|
100
|
+
const collectPriorMessageIds = () => tracking.timestamps
|
|
101
|
+
.filter(t => t.messageId && t.channelId)
|
|
102
|
+
.map(t => ({ messageId: t.messageId, channelId: t.channelId }));
|
|
103
|
+
if (tracking.timestamps.length > this.rateLimit) {
|
|
104
|
+
return {
|
|
105
|
+
isSpam: true,
|
|
106
|
+
reason: 'rate_limit',
|
|
107
|
+
details: `Sent ${tracking.timestamps.length} messages in ${this.rateLimitWindow / 1000} seconds (limit: ${this.rateLimit})`,
|
|
108
|
+
priorMessageIds: collectPriorMessageIds(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const uniqueChannels = new Set(tracking.timestamps.map(t => t.channelId).filter(Boolean)).size;
|
|
112
|
+
if (uniqueChannels >= 3) {
|
|
113
|
+
return {
|
|
114
|
+
isSpam: true,
|
|
115
|
+
reason: 'rate_limit',
|
|
116
|
+
details: `Cross-channel spam detected: Posted in ${uniqueChannels} channels in ${this.rateLimitWindow / 1000} seconds`,
|
|
117
|
+
priorMessageIds: collectPriorMessageIds(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!contentId.startsWith('empty:')) {
|
|
121
|
+
const duplicates = tracking.messageHashes.filter(e => e.hash === contentId);
|
|
122
|
+
const duplicateCount = duplicates.length;
|
|
123
|
+
tracking.messageHashes.push({ hash: contentId, timestamp: now, messageId, channelId });
|
|
124
|
+
if (duplicateCount >= this.duplicateThreshold - 1) {
|
|
125
|
+
const priorMessageIds = duplicates
|
|
126
|
+
.filter(e => e.messageId && e.channelId)
|
|
127
|
+
.map(e => ({ messageId: e.messageId, channelId: e.channelId }));
|
|
128
|
+
return {
|
|
129
|
+
isSpam: true,
|
|
130
|
+
reason: 'duplicate',
|
|
131
|
+
details: `Sent the same content ${duplicateCount + 1} times in ${this.duplicateWindow / 1000} seconds (limit: ${this.duplicateThreshold})`,
|
|
132
|
+
priorMessageIds,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
tracking.messageHashes.push({ hash: contentId, timestamp: now, messageId, channelId });
|
|
138
|
+
}
|
|
139
|
+
return { isSpam: false, reason: null, details: null };
|
|
140
|
+
}
|
|
141
|
+
reset(userId) {
|
|
142
|
+
this.userTracking.delete(userId);
|
|
143
|
+
}
|
|
144
|
+
clear() {
|
|
145
|
+
this.userTracking.clear();
|
|
146
|
+
}
|
|
147
|
+
getStats() {
|
|
148
|
+
let totalTimestamps = 0;
|
|
149
|
+
let totalHashes = 0;
|
|
150
|
+
for (const tracking of this.userTracking.values()) {
|
|
151
|
+
totalTimestamps += tracking.timestamps.length;
|
|
152
|
+
totalHashes += tracking.messageHashes.length;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
trackedUsers: this.userTracking.size,
|
|
156
|
+
maxUsers: this.maxUsers,
|
|
157
|
+
totalTimestamps,
|
|
158
|
+
totalHashes,
|
|
159
|
+
config: {
|
|
160
|
+
rateLimit: this.rateLimit,
|
|
161
|
+
rateLimitWindowSeconds: this.rateLimitWindow / 1000,
|
|
162
|
+
duplicateThreshold: this.duplicateThreshold,
|
|
163
|
+
duplicateWindowSeconds: this.duplicateWindow / 1000,
|
|
164
|
+
timeoutDurationDayMinutes: this.timeoutDurationDay,
|
|
165
|
+
nightHours: `${this.nightStartHour}:00 - ${this.nightEndHour}:00`,
|
|
166
|
+
timezone: this.timezone,
|
|
167
|
+
isNightTime: this.isNightTime(),
|
|
168
|
+
currentTimeoutMinutes: this.getTimeoutDurationMinutes(),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
getCurrentTime() {
|
|
173
|
+
const now = new Date();
|
|
174
|
+
const hour = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: this.timezone, hour: 'numeric', hour12: false }).format(now));
|
|
175
|
+
const minute = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: this.timezone, minute: 'numeric' }).format(now));
|
|
176
|
+
return { hour, minute };
|
|
177
|
+
}
|
|
178
|
+
isNightTime() {
|
|
179
|
+
const { hour } = this.getCurrentTime();
|
|
180
|
+
if (this.nightStartHour > this.nightEndHour) {
|
|
181
|
+
return hour >= this.nightStartHour || hour < this.nightEndHour;
|
|
182
|
+
}
|
|
183
|
+
return hour >= this.nightStartHour && hour < this.nightEndHour;
|
|
184
|
+
}
|
|
185
|
+
getMinutesUntilNightEnd() {
|
|
186
|
+
const { hour, minute } = this.getCurrentTime();
|
|
187
|
+
const hoursUntilEnd = hour >= this.nightStartHour ? (24 - hour) + this.nightEndHour : this.nightEndHour - hour;
|
|
188
|
+
const totalMinutes = hoursUntilEnd * 60 - minute;
|
|
189
|
+
return Math.max(totalMinutes, 60);
|
|
190
|
+
}
|
|
191
|
+
getTimeoutDurationMinutes() {
|
|
192
|
+
return this.isNightTime() ? this.getMinutesUntilNightEnd() : this.timeoutDurationDay;
|
|
193
|
+
}
|
|
194
|
+
getTimeoutDurationMs() {
|
|
195
|
+
return this.getTimeoutDurationMinutes() * 60 * 1000;
|
|
196
|
+
}
|
|
197
|
+
cleanup() {
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const toDelete = [];
|
|
200
|
+
for (const [userId, tracking] of this.userTracking.entries()) {
|
|
201
|
+
this.cleanupTracking(tracking, now);
|
|
202
|
+
if (tracking.timestamps.length === 0 && tracking.messageHashes.length === 0) {
|
|
203
|
+
toDelete.push(userId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
toDelete.forEach(userId => this.userTracking.delete(userId));
|
|
207
|
+
return toDelete.length;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
exports.SpamCache = SpamCache;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './cache';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./cache"), exports);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./normalize"), exports);
|
|
18
|
+
__exportStar(require("./mentions"), exports);
|