@joliegg/moderation 0.6.0 → 0.8.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.
Files changed (53) hide show
  1. package/dist/actions.d.ts +28 -0
  2. package/dist/actions.js +48 -0
  3. package/dist/client.d.ts +19 -0
  4. package/dist/client.js +97 -0
  5. package/dist/index.d.ts +3 -41
  6. package/dist/index.js +20 -213
  7. package/dist/providers/aws.d.ts +11 -0
  8. package/dist/providers/aws.js +58 -0
  9. package/dist/providers/google.d.ts +21 -0
  10. package/dist/providers/google.js +61 -0
  11. package/dist/providers/webrisk.d.ts +9 -0
  12. package/dist/providers/webrisk.js +33 -0
  13. package/dist/raid/age.d.ts +6 -0
  14. package/dist/raid/age.js +19 -0
  15. package/dist/raid/detector.d.ts +56 -0
  16. package/dist/raid/detector.js +88 -0
  17. package/dist/raid/index.d.ts +2 -0
  18. package/dist/raid/index.js +18 -0
  19. package/dist/spam/cache.d.ts +99 -0
  20. package/dist/spam/cache.js +210 -0
  21. package/dist/spam/index.d.ts +1 -0
  22. package/dist/spam/index.js +17 -0
  23. package/dist/text/index.d.ts +2 -0
  24. package/dist/text/index.js +18 -0
  25. package/dist/text/mentions.d.ts +31 -0
  26. package/dist/text/mentions.js +55 -0
  27. package/dist/text/normalize.d.ts +15 -0
  28. package/dist/text/normalize.js +45 -0
  29. package/dist/types/config.d.ts +13 -0
  30. package/dist/types/config.js +2 -0
  31. package/dist/types/index.d.ts +3 -10
  32. package/dist/types/index.js +15 -0
  33. package/package.json +54 -13
  34. package/src/actions.ts +50 -0
  35. package/src/client.ts +121 -0
  36. package/src/index.ts +3 -277
  37. package/src/providers/aws.ts +58 -0
  38. package/src/providers/google.ts +63 -0
  39. package/src/providers/webrisk.ts +30 -0
  40. package/src/raid/age.ts +19 -0
  41. package/src/raid/detector.ts +122 -0
  42. package/src/raid/index.ts +2 -0
  43. package/src/spam/cache.ts +342 -0
  44. package/src/spam/index.ts +1 -0
  45. package/src/text/index.ts +2 -0
  46. package/src/text/mentions.ts +91 -0
  47. package/src/text/normalize.ts +43 -0
  48. package/src/types/config.ts +14 -0
  49. package/src/types/index.ts +5 -11
  50. /package/dist/{url-blacklist.json → data/url-blacklist.json} +0 -0
  51. /package/dist/{url-shorteners.json → data/url-shorteners.json} +0 -0
  52. /package/src/{url-blacklist.json → data/url-blacklist.json} +0 -0
  53. /package/src/{url-shorteners.json → data/url-shorteners.json} +0 -0
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Is a member's account "too new" for the given threshold?
3
+ *
4
+ * Returns `false` when both timestamps are missing.
5
+ */
6
+ export declare function isAccountTooNew(joinedTimestamp: number | null, createdTimestamp: number | null, minAgeDays: number): boolean;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isAccountTooNew = isAccountTooNew;
4
+ /**
5
+ * Is a member's account "too new" for the given threshold?
6
+ *
7
+ * Returns `false` when both timestamps are missing.
8
+ */
9
+ function isAccountTooNew(joinedTimestamp, createdTimestamp, minAgeDays) {
10
+ // For raids, we care about join time, not account creation
11
+ // Altough we can derive some trust from that too.
12
+ const reference = joinedTimestamp ?? createdTimestamp;
13
+ if (reference === null) {
14
+ return false;
15
+ }
16
+ const age = Date.now() - reference;
17
+ const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000;
18
+ return age < minAgeMs;
19
+ }
@@ -0,0 +1,56 @@
1
+ export interface RaidDetectorOptions {
2
+ /** Number of joins within the window that constitutes a raid. Default to 10 (very small community) but this really depends on community size and activity. */
3
+ joinThreshold?: number;
4
+ /** Sliding window in seconds. Default to 60 seconds (1 minute). */
5
+ joinWindow?: number;
6
+ }
7
+ export interface MemberJoin {
8
+ memberId: string;
9
+ joinedTimestamp: number;
10
+ createdTimestamp: number;
11
+ }
12
+ export interface RaidTrackResult {
13
+ isRaid: boolean;
14
+ joinCount: number;
15
+ windowSeconds: number;
16
+ }
17
+ export type EnableResult = 'enabled' | 'already_active' | 'already_enabling';
18
+ /**
19
+ * Platform-agnostic raid detector. Tracks recent joins per guild in a
20
+ * sliding window and surfaces the "raid" signal when the join count
21
+ * crosses the configured threshold.
22
+ *
23
+ * State transitions (`tryEnable`, `disable`) are guarded by an
24
+ * in-memory mutex so concurrent `handleMemberJoin` invocations during
25
+ * a join burst cannot both see "not active" and double-fire the
26
+ * enable side effects.
27
+ *
28
+ * The detector owns the sliding-window state. It does NOT own the
29
+ * enforcement — callers decide what to do when `isRaid` is true
30
+ * (timeout, kick, auto-disable timer, mod-channel alert, etc.).
31
+ */
32
+ export declare class RaidDetector {
33
+ readonly joinThreshold: number;
34
+ readonly joinWindow: number;
35
+ private state;
36
+ private enabling;
37
+ constructor(options?: RaidDetectorOptions);
38
+ private getState;
39
+ private cleanupJoins;
40
+ /**
41
+ * Record a join and return whether the guild has crossed the raid
42
+ * threshold inside the window. `tryEnable` is a separate call so the
43
+ * caller can act on the raid signal atomically.
44
+ */
45
+ track(guildId: string, member: MemberJoin): RaidTrackResult;
46
+ isActive(guildId: string): boolean;
47
+ /**
48
+ * Attempt to flip the guild into raid-active state. Returns:
49
+ * - 'enabled' if this call performed the transition
50
+ * - 'already_active' if raid mode was already on
51
+ * - 'already_enabling' if another concurrent call is mid-transition
52
+ */
53
+ tryEnable(guildId: string): Promise<EnableResult>;
54
+ disable(guildId: string): void;
55
+ getJoinCount(guildId: string, windowSeconds?: number): number;
56
+ }
@@ -0,0 +1,88 @@
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
+ if (this.enabling.has(guildId))
66
+ return 'already_enabling';
67
+ this.enabling.add(guildId);
68
+ try {
69
+ state.raidActive = true;
70
+ return 'enabled';
71
+ }
72
+ finally {
73
+ this.enabling.delete(guildId);
74
+ }
75
+ }
76
+ disable(guildId) {
77
+ const state = this.getState(guildId);
78
+ state.raidActive = false;
79
+ this.enabling.delete(guildId);
80
+ }
81
+ getJoinCount(guildId, windowSeconds) {
82
+ const now = Date.now();
83
+ const state = this.getState(guildId);
84
+ const window = (windowSeconds ?? this.joinWindow / 1000) * 1000;
85
+ return state.joins.filter(j => now - j.timestamp < window).length;
86
+ }
87
+ }
88
+ exports.RaidDetector = RaidDetector;
@@ -0,0 +1,2 @@
1
+ export * from './detector';
2
+ export * from './age';
@@ -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,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,2 @@
1
+ export * from './normalize';
2
+ export * from './mentions';
@@ -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);
@@ -0,0 +1,31 @@
1
+ export interface MentionConfig {
2
+ /** Maximum number of distinct user mentions per message. */
3
+ maxUserMentions: number;
4
+ /** Maximum number of distinct role mentions per message. */
5
+ maxRoleMentions: number;
6
+ /** Maximum number of total mentions (user + role) per message. */
7
+ maxTotalMentions: number;
8
+ /** Whether to treat `@everyone` as spam for the sender. */
9
+ blockEveryone: boolean;
10
+ /** Whether to treat `@here` as spam for the sender. */
11
+ blockHere: boolean;
12
+ }
13
+ export interface MentionCounts {
14
+ userMentions: number;
15
+ roleMentions: number;
16
+ hasEveryone: boolean;
17
+ hasHere: boolean;
18
+ }
19
+ export type MentionSpamReason = 'mention_everyone' | 'mention_here' | 'mention_users' | 'mention_roles' | 'mention_total';
20
+ export interface MentionSpamResult {
21
+ isSpam: boolean;
22
+ reason: MentionSpamReason | null;
23
+ details: string | null;
24
+ }
25
+ export declare const DEFAULT_MENTION_CONFIG: MentionConfig;
26
+ /**
27
+ * Mention-spam check.
28
+ *
29
+ * `@everyone` detection takes priority over other reasons.
30
+ */
31
+ export declare function checkMentionSpam(counts: MentionCounts, config: MentionConfig): MentionSpamResult;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_MENTION_CONFIG = void 0;
4
+ exports.checkMentionSpam = checkMentionSpam;
5
+ exports.DEFAULT_MENTION_CONFIG = {
6
+ maxUserMentions: 5,
7
+ maxRoleMentions: 3,
8
+ maxTotalMentions: 8,
9
+ blockEveryone: true,
10
+ blockHere: true,
11
+ };
12
+ /**
13
+ * Mention-spam check.
14
+ *
15
+ * `@everyone` detection takes priority over other reasons.
16
+ */
17
+ function checkMentionSpam(counts, config) {
18
+ if (config.blockEveryone && counts.hasEveryone) {
19
+ return {
20
+ isSpam: true,
21
+ reason: 'mention_everyone',
22
+ details: '@everyone mentioned without permission',
23
+ };
24
+ }
25
+ if (config.blockHere && counts.hasHere) {
26
+ return {
27
+ isSpam: true,
28
+ reason: 'mention_here',
29
+ details: '@here mentioned without permission',
30
+ };
31
+ }
32
+ if (counts.userMentions > config.maxUserMentions) {
33
+ return {
34
+ isSpam: true,
35
+ reason: 'mention_users',
36
+ details: `${counts.userMentions} user mentions (limit: ${config.maxUserMentions})`,
37
+ };
38
+ }
39
+ if (counts.roleMentions > config.maxRoleMentions) {
40
+ return {
41
+ isSpam: true,
42
+ reason: 'mention_roles',
43
+ details: `${counts.roleMentions} role mentions (limit: ${config.maxRoleMentions})`,
44
+ };
45
+ }
46
+ const total = counts.userMentions + counts.roleMentions;
47
+ if (total > config.maxTotalMentions) {
48
+ return {
49
+ isSpam: true,
50
+ reason: 'mention_total',
51
+ details: `${total} total mentions (limit: ${config.maxTotalMentions})`,
52
+ };
53
+ }
54
+ return { isSpam: false, reason: null, details: null };
55
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Canonicalizes user-submitted text for content matching:
3
+ *
4
+ * 1. trim surrounding whitespace
5
+ * 2. lowercase
6
+ * 3. strip zero-width / invisible characters
7
+ * 4. NFKC normalize (collapses bold, italic, fullwidth, circled,
8
+ * small-caps, and other compatibility variants to ASCII)
9
+ * 5. collapse internal whitespace runs to single spaces
10
+ *
11
+ * Useful for spam hashing, ban-list matching, and any other comparison
12
+ * where users should not be able to defeat a match by visually similar
13
+ * but technically distinct input.
14
+ */
15
+ export declare function normalizeText(input: string): string;