@joliegg/moderation 0.4.4 → 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 (55) hide show
  1. package/LICENSE.md +21 -118
  2. package/README.md +4 -6
  3. package/dist/actions.d.ts +28 -0
  4. package/dist/actions.js +48 -0
  5. package/dist/client.d.ts +19 -0
  6. package/dist/client.js +97 -0
  7. package/dist/{url-blacklist.json → data/url-blacklist.json} +1 -0
  8. package/dist/index.d.ts +3 -41
  9. package/dist/index.js +20 -213
  10. package/dist/providers/aws.d.ts +11 -0
  11. package/dist/providers/aws.js +58 -0
  12. package/dist/providers/google.d.ts +21 -0
  13. package/dist/providers/google.js +61 -0
  14. package/dist/providers/webrisk.d.ts +9 -0
  15. package/dist/providers/webrisk.js +33 -0
  16. package/dist/raid/age.d.ts +6 -0
  17. package/dist/raid/age.js +19 -0
  18. package/dist/raid/detector.d.ts +56 -0
  19. package/dist/raid/detector.js +88 -0
  20. package/dist/raid/index.d.ts +2 -0
  21. package/dist/raid/index.js +18 -0
  22. package/dist/spam/cache.d.ts +99 -0
  23. package/dist/spam/cache.js +210 -0
  24. package/dist/spam/index.d.ts +1 -0
  25. package/dist/spam/index.js +17 -0
  26. package/dist/text/index.d.ts +2 -0
  27. package/dist/text/index.js +18 -0
  28. package/dist/text/mentions.d.ts +31 -0
  29. package/dist/text/mentions.js +55 -0
  30. package/dist/text/normalize.d.ts +15 -0
  31. package/dist/text/normalize.js +45 -0
  32. package/dist/types/config.d.ts +13 -0
  33. package/dist/types/config.js +2 -0
  34. package/dist/types/index.d.ts +3 -10
  35. package/dist/types/index.js +15 -0
  36. package/package.json +61 -20
  37. package/src/actions.ts +50 -0
  38. package/src/client.ts +121 -0
  39. package/src/{url-blacklist.json → data/url-blacklist.json} +1 -0
  40. package/src/index.ts +3 -277
  41. package/src/providers/aws.ts +58 -0
  42. package/src/providers/google.ts +63 -0
  43. package/src/providers/webrisk.ts +30 -0
  44. package/src/raid/age.ts +19 -0
  45. package/src/raid/detector.ts +122 -0
  46. package/src/raid/index.ts +2 -0
  47. package/src/spam/cache.ts +342 -0
  48. package/src/spam/index.ts +1 -0
  49. package/src/text/index.ts +2 -0
  50. package/src/text/mentions.ts +91 -0
  51. package/src/text/normalize.ts +43 -0
  52. package/src/types/config.ts +14 -0
  53. package/src/types/index.ts +5 -11
  54. /package/dist/{url-shorteners.json → data/url-shorteners.json} +0 -0
  55. /package/src/{url-shorteners.json → data/url-shorteners.json} +0 -0
package/dist/index.js CHANGED
@@ -1,218 +1,25 @@
1
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
+ };
2
16
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
18
  };
5
19
  Object.defineProperty(exports, "__esModule", { value: true });
6
- const axios_1 = __importDefault(require("axios"));
7
- const client_rekognition_1 = require("@aws-sdk/client-rekognition");
8
- const language_1 = require("@google-cloud/language");
9
- const speech_1 = require("@google-cloud/speech");
10
- const sharp_1 = __importDefault(require("sharp"));
11
- const url_blacklist_json_1 = __importDefault(require("./url-blacklist.json"));
12
- const url_shorteners_json_1 = __importDefault(require("./url-shorteners.json"));
13
- const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB in bytes for Rekognition limit
14
- /**
15
- * Moderation Client
16
- *
17
- * @class ModerationClient
18
- */
19
- class ModerationClient {
20
- rekognitionClient;
21
- googleLanguageClient;
22
- googleSpeechClient;
23
- googleAPIKey;
24
- banList = [];
25
- urlBlackList = [];
26
- /**
27
- *
28
- * @param {ModerationConfiguration} configuration
29
- */
30
- constructor(configuration) {
31
- if (configuration.aws) {
32
- this.rekognitionClient = new client_rekognition_1.Rekognition(configuration.aws);
33
- }
34
- if (typeof configuration.google?.keyFile === 'string') {
35
- this.googleLanguageClient = new language_1.LanguageServiceClient({ keyFile: configuration.google.keyFile });
36
- this.googleSpeechClient = new speech_1.SpeechClient({ keyFile: configuration.google.keyFile });
37
- }
38
- if (typeof configuration.google?.apiKey === 'string') {
39
- this.googleAPIKey = configuration.google.apiKey;
40
- }
41
- if (Array.isArray(configuration.banList)) {
42
- this.banList = configuration.banList;
43
- }
44
- if (Array.isArray(configuration.urlBlackList)) {
45
- this.urlBlackList = configuration.urlBlackList;
46
- }
47
- }
48
- /**
49
- * Returns a list of moderation categories detected on a text
50
- *
51
- * @param {string} text The text to moderate
52
- * @param {number} [minimumConfidence = 50] The minimum confidence required for a category to be considered
53
- *
54
- * @returns {Promise<ModerationResult>} The list of results that were detected with the minimum confidence specified
55
- */
56
- async moderateText(text, minimumConfidence = 50) {
57
- const categories = [];
58
- if (Array.isArray(this.banList)) {
59
- const normalizedText = text.toLowerCase();
60
- const matches = this.banList.filter(w => normalizedText.indexOf(w) > -1);
61
- if (matches.length > 0) {
62
- const words = normalizedText.split(' ');
63
- categories.push({
64
- category: 'BAN_LIST',
65
- confidence: (matches.length / words.length) * 100,
66
- });
67
- }
68
- }
69
- if (typeof this.googleLanguageClient === 'undefined') {
70
- return { source: text, moderation: categories };
71
- }
72
- const [result] = await this.googleLanguageClient.moderateText({
73
- document: {
74
- content: text,
75
- type: 'PLAIN_TEXT',
76
- },
77
- });
78
- if (result && 'moderationCategories' in result) {
79
- if (Array.isArray(result.moderationCategories)) {
80
- const results = result.moderationCategories.map(c => ({
81
- category: c.name ?? 'Unknown',
82
- confidence: (c.confidence ?? 0) * 100,
83
- })).filter(c => c.confidence >= minimumConfidence);
84
- return { source: text, moderation: [...categories, ...results] };
85
- }
86
- }
87
- return { source: text, moderation: [] };
88
- }
89
- /**
90
- * Returns a list of moderation categories detected on an image
91
- *
92
- * @param {string} url
93
- * @param {number} [minimumConfidence = 50] The minimum confidence required for a category to be considered
94
- *
95
- *
96
- * @returns {Promise<ModerationResult[]>} The list of results that were detected with the minimum confidence specified
97
- */
98
- async moderateImage(url, minimumConfidence = 50) {
99
- if (typeof this.rekognitionClient === 'undefined') {
100
- return { source: url, moderation: [] };
101
- }
102
- const { data } = await axios_1.default.get(url, { responseType: 'arraybuffer' });
103
- let buffer = null;
104
- // GIFs will be split into frames
105
- if (url.toLowerCase().indexOf('.gif') > -1) {
106
- buffer = await (0, sharp_1.default)(data, { pages: -1 }).toFormat('png').toBuffer();
107
- }
108
- else if (url.toLowerCase().indexOf('.webp') > -1) {
109
- buffer = await (0, sharp_1.default)(data).toFormat('png').toBuffer();
110
- }
111
- else {
112
- // Download image as binary data
113
- buffer = Buffer.from(data, 'binary');
114
- }
115
- // Ensure image is not larger than 5MB (Rekognition limit)
116
- if (buffer.length > MAX_IMAGE_SIZE) {
117
- try {
118
- // Calculate new dimensions to reduce size
119
- const metadata = await (0, sharp_1.default)(buffer).metadata();
120
- const { width, height } = metadata;
121
- if (typeof width !== 'number' || typeof height !== 'number') {
122
- throw new Error('Invalid image metadata');
123
- }
124
- // Calculate the scaling factor
125
- const scalingFactor = Math.sqrt(MAX_IMAGE_SIZE / buffer.length);
126
- // Calculate new dimensions
127
- const newWidth = Math.floor(width * scalingFactor);
128
- const newHeight = Math.floor(height * scalingFactor);
129
- const resizedBuffer = await (0, sharp_1.default)(buffer)
130
- .resize(Math.round(newWidth), Math.round(newHeight))
131
- .toBuffer();
132
- buffer = resizedBuffer;
133
- }
134
- catch (error) {
135
- // We can't resize the image. We'll skip the resize and try to process it as is
136
- }
137
- }
138
- const { ModerationLabels } = await this.rekognitionClient.detectModerationLabels({
139
- Image: {
140
- Bytes: buffer
141
- },
142
- MinConfidence: minimumConfidence
143
- });
144
- if (Array.isArray(ModerationLabels)) {
145
- const moderation = ModerationLabels.map(l => ({
146
- category: l.Name ?? 'Unknown',
147
- confidence: l.Confidence ?? 0,
148
- }));
149
- return { source: url, moderation };
150
- }
151
- return { source: url, moderation: [] };
152
- }
153
- async moderateLink(url, allowShorteners = false) {
154
- try {
155
- const domain = new URL(url).hostname;
156
- const blacklisted = this.urlBlackList?.some(u => url.indexOf(u) > -1);
157
- if (blacklisted) {
158
- return { source: url, moderation: [{ category: 'CUSTOM_BLACK_LIST', confidence: 100 }] };
159
- }
160
- const globallyBlacklisted = url_blacklist_json_1.default.some(u => u === domain);
161
- if (globallyBlacklisted) {
162
- return { source: url, moderation: [{ category: 'BLACK_LIST', confidence: 100 }] };
163
- }
164
- if (!allowShorteners) {
165
- const isShortened = url_shorteners_json_1.default.some(u => u === domain);
166
- if (isShortened) {
167
- return { source: url, moderation: [{ category: 'URL_SHORTENER', confidence: 100 }] };
168
- }
169
- }
170
- }
171
- catch (error) {
172
- // Invalid URL
173
- return { source: url, moderation: [] };
174
- }
175
- if (typeof this.googleAPIKey !== 'string') {
176
- return { source: url, moderation: [] };
177
- }
178
- const types = [
179
- 'MALWARE',
180
- 'SOCIAL_ENGINEERING',
181
- 'UNWANTED_SOFTWARE',
182
- 'SOCIAL_ENGINEERING_EXTENDED_COVERAGE'
183
- ];
184
- const threatTypes = types.join('&threatTypes=');
185
- const requestUrl = `https://webrisk.googleapis.com/v1/uris:search?threatTypes=${threatTypes}&key=${this.googleAPIKey}`;
186
- const { data } = await axios_1.default.get(`${requestUrl}&uri=${encodeURIComponent(url)}`);
187
- const threats = data?.threat?.threatTypes;
188
- if (Array.isArray(threats)) {
189
- const moderation = threats.map(t => ({
190
- category: t,
191
- confidence: 100,
192
- }));
193
- return { source: url, moderation };
194
- }
195
- return { source: url, moderation: [] };
196
- }
197
- async moderateAudio(url, language = 'en-US', minimumConfidence = 50) {
198
- if (typeof this.googleSpeechClient === 'undefined') {
199
- return { source: url, moderation: [] };
200
- }
201
- const { data } = await axios_1.default.get(url, { responseType: 'arraybuffer' });
202
- const options = {
203
- encoding: 'OGG_OPUS',
204
- sampleRateHertz: 48000,
205
- languageCode: language,
206
- };
207
- const [response] = await this.googleSpeechClient.recognize({
208
- audio: { content: Buffer.from(data, 'binary').toString('base64') },
209
- config: options,
210
- });
211
- if (!Array.isArray(response?.results)) {
212
- return { source: url, moderation: [] };
213
- }
214
- const transcription = response?.results?.map((result) => result.alternatives?.at(0)?.transcript ?? '').join(' ');
215
- return this.moderateText(transcription, minimumConfidence);
216
- }
217
- }
218
- exports.default = ModerationClient;
20
+ exports.default = exports.ModerationClient = void 0;
21
+ var client_1 = require("./client");
22
+ Object.defineProperty(exports, "ModerationClient", { enumerable: true, get: function () { return client_1.ModerationClient; } });
23
+ Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(client_1).default; } });
24
+ __exportStar(require("./types"), exports);
25
+ __exportStar(require("./actions"), exports);
@@ -0,0 +1,11 @@
1
+ import { Rekognition } from '@aws-sdk/client-rekognition';
2
+ import type { ModerationCategory } from '../types';
3
+ /**
4
+ * AWS Rekognition image moderation adapter. Handles GIF and WEBP
5
+ * conversion to PNG and downscales images over the 5 MB Rekognition limit.
6
+ */
7
+ export declare class RekognitionProvider {
8
+ private client;
9
+ constructor(client: Rekognition);
10
+ moderateImage(url: string, minimumConfidence: number): Promise<ModerationCategory[]>;
11
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RekognitionProvider = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const sharp_1 = __importDefault(require("sharp"));
9
+ // AWS Rekognition 5MB inline-image limit
10
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
11
+ /**
12
+ * AWS Rekognition image moderation adapter. Handles GIF and WEBP
13
+ * conversion to PNG and downscales images over the 5 MB Rekognition limit.
14
+ */
15
+ class RekognitionProvider {
16
+ client;
17
+ constructor(client) {
18
+ this.client = client;
19
+ }
20
+ async moderateImage(url, minimumConfidence) {
21
+ const { data } = await axios_1.default.get(url, { responseType: 'arraybuffer' });
22
+ let buffer;
23
+ const lowered = url.toLowerCase();
24
+ if (lowered.includes('.gif')) {
25
+ buffer = await (0, sharp_1.default)(Buffer.from(data), { pages: -1 }).toFormat('png').toBuffer();
26
+ }
27
+ else if (lowered.includes('.webp')) {
28
+ buffer = await (0, sharp_1.default)(Buffer.from(data)).toFormat('png').toBuffer();
29
+ }
30
+ else {
31
+ buffer = Buffer.from(data);
32
+ }
33
+ if (buffer.length > MAX_IMAGE_SIZE) {
34
+ try {
35
+ const metadata = await (0, sharp_1.default)(buffer).metadata();
36
+ const { width, height } = metadata;
37
+ if (typeof width === 'number' && typeof height === 'number') {
38
+ const scalingFactor = Math.sqrt(MAX_IMAGE_SIZE / buffer.length);
39
+ buffer = await (0, sharp_1.default)(buffer)
40
+ .resize(Math.round(width * scalingFactor), Math.round(height * scalingFactor))
41
+ .toBuffer();
42
+ }
43
+ }
44
+ catch {
45
+ // Resize failed, we will use the original buffer.
46
+ }
47
+ }
48
+ const { ModerationLabels } = await this.client.detectModerationLabels({
49
+ Image: { Bytes: buffer },
50
+ MinConfidence: minimumConfidence,
51
+ });
52
+ if (Array.isArray(ModerationLabels)) {
53
+ return ModerationLabels.map(l => ({ category: l.Name ?? 'Unknown', confidence: l.Confidence ?? 0 }));
54
+ }
55
+ return [];
56
+ }
57
+ }
58
+ exports.RekognitionProvider = RekognitionProvider;
@@ -0,0 +1,21 @@
1
+ import { LanguageServiceClient } from '@google-cloud/language';
2
+ import { SpeechClient } from '@google-cloud/speech';
3
+ import type { ModerationCategory } from '../types';
4
+ /**
5
+ * Google Cloud Natural Language moderation adapter.
6
+ */
7
+ export declare class GoogleLanguageProvider {
8
+ private client;
9
+ constructor(client: LanguageServiceClient);
10
+ moderateText(text: string, minimumConfidence: number): Promise<ModerationCategory[]>;
11
+ }
12
+ /**
13
+ * Google Cloud Speech-to-Text adapter. Used for audio transcription
14
+ * before running the transcript through text moderation.
15
+ */
16
+ export declare class GoogleSpeechProvider {
17
+ private client;
18
+ constructor(client: SpeechClient);
19
+ transcribe(audioBuffer: Buffer, languageCode: string): Promise<string>;
20
+ }
21
+ export declare function fetchAudio(url: string): Promise<Buffer>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GoogleSpeechProvider = exports.GoogleLanguageProvider = void 0;
7
+ exports.fetchAudio = fetchAudio;
8
+ const axios_1 = __importDefault(require("axios"));
9
+ /**
10
+ * Google Cloud Natural Language moderation adapter.
11
+ */
12
+ class GoogleLanguageProvider {
13
+ client;
14
+ constructor(client) {
15
+ this.client = client;
16
+ }
17
+ async moderateText(text, minimumConfidence) {
18
+ const [result] = await this.client.moderateText({
19
+ document: { content: text, type: 'PLAIN_TEXT' },
20
+ });
21
+ if (!result || !('moderationCategories' in result) || !Array.isArray(result.moderationCategories)) {
22
+ return [];
23
+ }
24
+ return result.moderationCategories
25
+ .map(c => ({ category: c.name ?? 'Unknown', confidence: (c.confidence ?? 0) * 100 }))
26
+ .filter(c => c.confidence >= minimumConfidence);
27
+ }
28
+ }
29
+ exports.GoogleLanguageProvider = GoogleLanguageProvider;
30
+ /**
31
+ * Google Cloud Speech-to-Text adapter. Used for audio transcription
32
+ * before running the transcript through text moderation.
33
+ */
34
+ class GoogleSpeechProvider {
35
+ client;
36
+ constructor(client) {
37
+ this.client = client;
38
+ }
39
+ async transcribe(audioBuffer, languageCode) {
40
+ const config = {
41
+ encoding: 'OGG_OPUS',
42
+ sampleRateHertz: 48000,
43
+ languageCode,
44
+ };
45
+ const [response] = await this.client.recognize({
46
+ audio: { content: audioBuffer.toString('base64') },
47
+ config,
48
+ });
49
+ if (!Array.isArray(response?.results)) {
50
+ return '';
51
+ }
52
+ return response.results
53
+ .map((r) => r.alternatives?.at(0)?.transcript ?? '')
54
+ .join(' ');
55
+ }
56
+ }
57
+ exports.GoogleSpeechProvider = GoogleSpeechProvider;
58
+ async function fetchAudio(url) {
59
+ const { data } = await axios_1.default.get(url, { responseType: 'arraybuffer' });
60
+ return Buffer.from(data);
61
+ }
@@ -0,0 +1,9 @@
1
+ import type { ModerationCategory } from '../types';
2
+ /**
3
+ * Google Web Risk link moderation adapter.
4
+ */
5
+ export declare class WebRiskProvider {
6
+ private apiKey;
7
+ constructor(apiKey: string);
8
+ checkLink(url: string): Promise<ModerationCategory[]>;
9
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WebRiskProvider = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const THREAT_TYPES = [
9
+ 'MALWARE',
10
+ 'SOCIAL_ENGINEERING',
11
+ 'UNWANTED_SOFTWARE',
12
+ 'SOCIAL_ENGINEERING_EXTENDED_COVERAGE',
13
+ ];
14
+ /**
15
+ * Google Web Risk link moderation adapter.
16
+ */
17
+ class WebRiskProvider {
18
+ apiKey;
19
+ constructor(apiKey) {
20
+ this.apiKey = apiKey;
21
+ }
22
+ async checkLink(url) {
23
+ const threatTypes = THREAT_TYPES.join('&threatTypes=');
24
+ const requestUrl = `https://webrisk.googleapis.com/v1/uris:search?threatTypes=${threatTypes}&key=${this.apiKey}`;
25
+ const { data } = await axios_1.default.get(`${requestUrl}&uri=${encodeURIComponent(url)}`);
26
+ const threats = data?.threat?.threatTypes;
27
+ if (Array.isArray(threats)) {
28
+ return threats.map(t => ({ category: t, confidence: 100 }));
29
+ }
30
+ return [];
31
+ }
32
+ }
33
+ exports.WebRiskProvider = WebRiskProvider;
@@ -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);