@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.
Files changed (77) hide show
  1. package/dist/actions.d.ts +28 -0
  2. package/dist/actions.js +48 -0
  3. package/dist/audit/emitter.d.ts +26 -0
  4. package/dist/audit/emitter.js +63 -0
  5. package/dist/audit/events.d.ts +75 -0
  6. package/dist/audit/events.js +2 -0
  7. package/dist/audit/index.d.ts +2 -0
  8. package/dist/audit/index.js +18 -0
  9. package/dist/client.d.ts +22 -0
  10. package/dist/client.js +107 -0
  11. package/dist/index.d.ts +3 -41
  12. package/dist/index.js +20 -213
  13. package/dist/providers/aws.d.ts +11 -0
  14. package/dist/providers/aws.js +58 -0
  15. package/dist/providers/google.d.ts +21 -0
  16. package/dist/providers/google.js +61 -0
  17. package/dist/providers/openai.d.ts +15 -0
  18. package/dist/providers/openai.js +54 -0
  19. package/dist/providers/webrisk.d.ts +9 -0
  20. package/dist/providers/webrisk.js +33 -0
  21. package/dist/raid/age.d.ts +6 -0
  22. package/dist/raid/age.js +19 -0
  23. package/dist/raid/detector.d.ts +56 -0
  24. package/dist/raid/detector.js +90 -0
  25. package/dist/raid/index.d.ts +2 -0
  26. package/dist/raid/index.js +18 -0
  27. package/dist/rubrics/defaults.d.ts +19 -0
  28. package/dist/rubrics/defaults.js +32 -0
  29. package/dist/rubrics/index.d.ts +3 -0
  30. package/dist/rubrics/index.js +19 -0
  31. package/dist/rubrics/rubric.d.ts +21 -0
  32. package/dist/rubrics/rubric.js +57 -0
  33. package/dist/rubrics/types.d.ts +27 -0
  34. package/dist/rubrics/types.js +2 -0
  35. package/dist/spam/cache.d.ts +99 -0
  36. package/dist/spam/cache.js +210 -0
  37. package/dist/spam/index.d.ts +1 -0
  38. package/dist/spam/index.js +17 -0
  39. package/dist/text/index.d.ts +2 -0
  40. package/dist/text/index.js +18 -0
  41. package/dist/text/mentions.d.ts +31 -0
  42. package/dist/text/mentions.js +55 -0
  43. package/dist/text/normalize.d.ts +15 -0
  44. package/dist/text/normalize.js +45 -0
  45. package/dist/types/config.d.ts +13 -0
  46. package/dist/types/config.js +2 -0
  47. package/dist/types/index.d.ts +3 -10
  48. package/dist/types/index.js +15 -0
  49. package/package.json +66 -13
  50. package/src/actions.ts +50 -0
  51. package/src/audit/emitter.ts +77 -0
  52. package/src/audit/events.ts +89 -0
  53. package/src/audit/index.ts +2 -0
  54. package/src/client.ts +137 -0
  55. package/src/index.ts +3 -277
  56. package/src/providers/aws.ts +58 -0
  57. package/src/providers/google.ts +63 -0
  58. package/src/providers/openai.ts +64 -0
  59. package/src/providers/webrisk.ts +30 -0
  60. package/src/raid/age.ts +19 -0
  61. package/src/raid/detector.ts +133 -0
  62. package/src/raid/index.ts +2 -0
  63. package/src/rubrics/defaults.ts +32 -0
  64. package/src/rubrics/index.ts +3 -0
  65. package/src/rubrics/rubric.ts +62 -0
  66. package/src/rubrics/types.ts +30 -0
  67. package/src/spam/cache.ts +342 -0
  68. package/src/spam/index.ts +1 -0
  69. package/src/text/index.ts +2 -0
  70. package/src/text/mentions.ts +91 -0
  71. package/src/text/normalize.ts +43 -0
  72. package/src/types/config.ts +14 -0
  73. package/src/types/index.ts +5 -11
  74. /package/dist/{url-blacklist.json → data/url-blacklist.json} +0 -0
  75. /package/dist/{url-shorteners.json → data/url-shorteners.json} +0 -0
  76. /package/src/{url-blacklist.json → data/url-blacklist.json} +0 -0
  77. /package/src/{url-shorteners.json → data/url-shorteners.json} +0 -0
package/src/index.ts CHANGED
@@ -1,277 +1,3 @@
1
- import axios from 'axios';
2
-
3
- import { Rekognition } from '@aws-sdk/client-rekognition';
4
- import { LanguageServiceClient } from '@google-cloud/language';
5
- import { SpeechClient, protos } from '@google-cloud/speech';
6
-
7
- import sharp from 'sharp';
8
-
9
- import URLBlackList from './url-blacklist.json';
10
- import URLShortenerList from './url-shorteners.json';
11
-
12
-
13
- import { ModerationCategory, ModerationConfiguration, ModerationResult, ThreatsResponse } from './types';
14
-
15
-
16
- type IRecognitionConfig = protos.google.cloud.speech.v1.IRecognitionConfig;
17
-
18
- type ISpeechRecognitionResult = protos.google.cloud.speech.v1.ISpeechRecognitionResult;
19
-
20
- const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB in bytes for Rekognition limit
21
-
22
- /**
23
- * Moderation Client
24
- *
25
- * @class ModerationClient
26
- */
27
- class ModerationClient {
28
-
29
- private rekognitionClient?: Rekognition;
30
- private googleLanguageClient?: LanguageServiceClient;
31
- private googleSpeechClient?: SpeechClient;
32
- private googleAPIKey?: string;
33
- private banList?: string[] = [];
34
- private urlBlackList?: string[] = [];
35
-
36
- /**
37
- *
38
- * @param {ModerationConfiguration} configuration
39
- */
40
- constructor (configuration: ModerationConfiguration) {
41
- if (configuration.aws) {
42
- this.rekognitionClient = new Rekognition(configuration.aws);
43
- }
44
-
45
- if (typeof configuration.google?.keyFile === 'string') {
46
- this.googleLanguageClient = new LanguageServiceClient({ keyFile: configuration.google.keyFile });
47
- this.googleSpeechClient = new SpeechClient({ keyFile: configuration.google.keyFile });
48
- }
49
-
50
- if (typeof configuration.google?.apiKey === 'string') {
51
- this.googleAPIKey = configuration.google.apiKey;
52
- }
53
-
54
- if (Array.isArray(configuration.banList)) {
55
- this.banList = configuration.banList;
56
- }
57
-
58
- if (Array.isArray(configuration.urlBlackList)) {
59
- this.urlBlackList = configuration.urlBlackList;
60
- }
61
- }
62
-
63
- /**
64
- * Returns a list of moderation categories detected on a text
65
- *
66
- * @param {string} text The text to moderate
67
- * @param {number} [minimumConfidence = 50] The minimum confidence required for a category to be considered
68
- *
69
- * @returns {Promise<ModerationResult>} The list of results that were detected with the minimum confidence specified
70
- */
71
- async moderateText (text: string, minimumConfidence: number = 50): Promise<ModerationResult> {
72
- const categories: ModerationCategory[] = [];
73
-
74
- if (Array.isArray(this.banList)) {
75
- const normalizedText = text.toLowerCase();
76
- const matches = this.banList.filter(w => normalizedText.indexOf(w) > -1);
77
-
78
- if (matches.length > 0) {
79
- const words = normalizedText.split(' ');
80
-
81
- categories.push({
82
- category: 'BAN_LIST',
83
- confidence: (matches.length / words.length) * 100,
84
- });
85
- }
86
- }
87
-
88
-
89
- if (typeof this.googleLanguageClient === 'undefined') {
90
- return { source: text, moderation: categories };
91
- }
92
-
93
- const [ result ] = await this.googleLanguageClient.moderateText({
94
- document: {
95
- content: text,
96
- type: 'PLAIN_TEXT',
97
- },
98
- });
99
-
100
- if (result && 'moderationCategories' in result) {
101
- if (Array.isArray(result.moderationCategories)) {
102
- const results = result.moderationCategories.map(c => ({
103
- category: c.name ?? 'Unknown',
104
- confidence: (c.confidence ?? 0) * 100,
105
- })).filter(c => c.confidence >= minimumConfidence);
106
- return { source: text, moderation: [...categories, ...results] };
107
- }
108
- }
109
-
110
- return { source: text, moderation: [] };
111
- }
112
-
113
- /**
114
- * Returns a list of moderation categories detected on an image
115
- *
116
- * @param {string} url
117
- * @param {number} [minimumConfidence = 50] The minimum confidence required for a category to be considered
118
- *
119
- *
120
- * @returns {Promise<ModerationResult[]>} The list of results that were detected with the minimum confidence specified
121
- */
122
- async moderateImage (url: string, minimumConfidence: number = 50): Promise<ModerationResult> {
123
- if (typeof this.rekognitionClient === 'undefined') {
124
- return { source: url, moderation: [] };
125
- }
126
-
127
- const { data } = await axios.get<string>(url, { responseType: 'arraybuffer' });
128
-
129
- let buffer: Buffer;
130
-
131
- // GIFs will be split into frames
132
- if (url.toLowerCase().indexOf('.gif') > -1) {
133
- buffer = await sharp(data, { pages: -1 }).toFormat('png').toBuffer();
134
- } else if (url.toLowerCase().indexOf('.webp') > -1) {
135
- buffer = await sharp(data).toFormat('png').toBuffer();
136
- } else {
137
- // Download image as binary data
138
- buffer = Buffer.from(data, 'binary');
139
- }
140
-
141
- // Ensure image is not larger than 5MB (Rekognition limit)
142
- if (buffer.length > MAX_IMAGE_SIZE) {
143
- try {
144
- // Calculate new dimensions to reduce size
145
- const metadata = await sharp(buffer).metadata();
146
-
147
- const { width, height } = metadata;
148
-
149
- if (typeof width !== 'number' || typeof height !== 'number') {
150
- throw new Error('Invalid image metadata');
151
- }
152
-
153
- // Calculate the scaling factor
154
- const scalingFactor = Math.sqrt(MAX_IMAGE_SIZE / buffer.length);
155
-
156
- // Calculate new dimensions
157
- const newWidth = Math.floor(width * scalingFactor);
158
- const newHeight = Math.floor(height * scalingFactor);
159
-
160
- const resizedBuffer = await sharp(buffer)
161
- .resize(Math.round(newWidth), Math.round(newHeight))
162
- .toBuffer();
163
-
164
- buffer = resizedBuffer;
165
- } catch {
166
- // We can't resize the image. We'll skip the resize and try to process it as is
167
- }
168
- }
169
-
170
- const { ModerationLabels } = await this.rekognitionClient.detectModerationLabels({
171
- Image: {
172
- Bytes: buffer
173
- },
174
- MinConfidence: minimumConfidence
175
- });
176
-
177
- if (Array.isArray(ModerationLabels)) {
178
- const moderation = ModerationLabels.map(l => ({
179
- category: l.Name ?? 'Unknown',
180
- confidence: l.Confidence ?? 0,
181
- }));
182
-
183
- return { source: url, moderation };
184
- }
185
-
186
- return { source: url, moderation: [] };
187
- }
188
-
189
- async moderateLink (url: string, allowShorteners = false): Promise<ModerationResult> {
190
- try {
191
- const domain = new URL(url).hostname;
192
-
193
- const blacklisted = this.urlBlackList?.some(u => u.indexOf(url) > -1);
194
-
195
- if (blacklisted) {
196
- return { source: url, moderation: [{ category: 'CUSTOM_BLACK_LIST', confidence: 100 }] };
197
- }
198
-
199
- const globallyBlacklisted = URLBlackList.some(u => u === domain);
200
-
201
- if (globallyBlacklisted) {
202
- return { source: url, moderation: [{ category: 'BLACK_LIST', confidence: 100 }] };
203
- }
204
-
205
- if (!allowShorteners) {
206
- const isShortened = URLShortenerList.some(u => u === domain);
207
-
208
- if (isShortened) {
209
- return { source: url, moderation: [{ category: 'URL_SHORTENER', confidence: 100 }] };
210
- }
211
- }
212
- } catch {
213
- // Invalid URL
214
- return { source: url, moderation: [] };
215
- }
216
-
217
- if (typeof this.googleAPIKey !== 'string') {
218
- return { source: url, moderation: [] };
219
- }
220
-
221
- const types = [
222
- 'MALWARE',
223
- 'SOCIAL_ENGINEERING',
224
- 'UNWANTED_SOFTWARE',
225
- 'SOCIAL_ENGINEERING_EXTENDED_COVERAGE'
226
- ];
227
-
228
- const threatTypes = types.join('&threatTypes=');
229
- const requestUrl = `https://webrisk.googleapis.com/v1/uris:search?threatTypes=${threatTypes}&key=${this.googleAPIKey}`;
230
-
231
- const { data } = await axios.get<ThreatsResponse>(`${requestUrl}&uri=${encodeURIComponent(url)}`);
232
-
233
- const threats = data?.threat?.threatTypes;
234
-
235
- if (Array.isArray(threats)) {
236
- const moderation = threats.map(t => ({
237
- category: t,
238
- confidence: 100,
239
- }));
240
-
241
- return { source: url, moderation };
242
- }
243
-
244
- return { source: url, moderation: [] };
245
- }
246
-
247
- async moderateAudio (url: string, language: string = 'en-US', minimumConfidence: number = 50): Promise<ModerationResult> {
248
- if (typeof this.googleSpeechClient === 'undefined') {
249
- return { source: url, moderation: [] };
250
- }
251
-
252
- const { data } = await axios.get<string>(url, { responseType: 'arraybuffer' });
253
-
254
-
255
- const options: IRecognitionConfig = {
256
- encoding: 'OGG_OPUS',
257
- sampleRateHertz: 48000,
258
- languageCode: language,
259
- };
260
-
261
- const [ response ] = await this.googleSpeechClient.recognize ({
262
- audio: { content: Buffer.from(data, 'binary').toString('base64') },
263
- config: options,
264
- });
265
-
266
- if (!Array.isArray(response?.results)) {
267
- return { source: url, moderation: [] };
268
- }
269
-
270
- const transcription = response?.results?.map((result: ISpeechRecognitionResult) => result.alternatives?.at(0)?.transcript ?? '').join(' ');
271
-
272
- return this.moderateText(transcription, minimumConfidence);
273
- }
274
-
275
- }
276
-
277
- export default ModerationClient;
1
+ export { ModerationClient, default } from './client';
2
+ export * from './types';
3
+ export * from './actions';
@@ -0,0 +1,58 @@
1
+ import { Rekognition } from '@aws-sdk/client-rekognition';
2
+ import axios from 'axios';
3
+ import sharp from 'sharp';
4
+
5
+ import type { ModerationCategory } from '../types';
6
+
7
+ // AWS Rekognition 5MB inline-image limit
8
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
9
+
10
+ /**
11
+ * AWS Rekognition image moderation adapter. Handles GIF and WEBP
12
+ * conversion to PNG and downscales images over the 5 MB Rekognition limit.
13
+ */
14
+ export class RekognitionProvider {
15
+ constructor(private client: Rekognition) {}
16
+
17
+ async moderateImage(url: string, minimumConfidence: number): Promise<ModerationCategory[]> {
18
+ const { data } = await axios.get<ArrayBuffer>(url, { responseType: 'arraybuffer' });
19
+ let buffer: Buffer;
20
+
21
+ const lowered = url.toLowerCase();
22
+
23
+ if (lowered.includes('.gif')) {
24
+ buffer = await sharp(Buffer.from(data), { pages: -1 }).toFormat('png').toBuffer();
25
+ } else if (lowered.includes('.webp')) {
26
+ buffer = await sharp(Buffer.from(data)).toFormat('png').toBuffer();
27
+ } else {
28
+ buffer = Buffer.from(data);
29
+ }
30
+
31
+ if (buffer.length > MAX_IMAGE_SIZE) {
32
+ try {
33
+ const metadata = await sharp(buffer).metadata();
34
+ const { width, height } = metadata;
35
+
36
+ if (typeof width === 'number' && typeof height === 'number') {
37
+ const scalingFactor = Math.sqrt(MAX_IMAGE_SIZE / buffer.length);
38
+ buffer = await sharp(buffer)
39
+ .resize(Math.round(width * scalingFactor), Math.round(height * scalingFactor))
40
+ .toBuffer();
41
+ }
42
+ } catch {
43
+ // Resize failed, we will use the original buffer.
44
+ }
45
+ }
46
+
47
+ const { ModerationLabels } = await this.client.detectModerationLabels({
48
+ Image: { Bytes: buffer },
49
+ MinConfidence: minimumConfidence,
50
+ });
51
+
52
+ if (Array.isArray(ModerationLabels)) {
53
+ return ModerationLabels.map(l => ({ category: l.Name ?? 'Unknown', confidence: l.Confidence ?? 0 }));
54
+ }
55
+
56
+ return [];
57
+ }
58
+ }
@@ -0,0 +1,63 @@
1
+ import { LanguageServiceClient } from '@google-cloud/language';
2
+ import { SpeechClient, protos } from '@google-cloud/speech';
3
+ import axios from 'axios';
4
+
5
+ import type { ModerationCategory } from '../types';
6
+
7
+ type IRecognitionConfig = protos.google.cloud.speech.v1.IRecognitionConfig;
8
+ type ISpeechRecognitionResult = protos.google.cloud.speech.v1.ISpeechRecognitionResult;
9
+
10
+ /**
11
+ * Google Cloud Natural Language moderation adapter.
12
+ */
13
+ export class GoogleLanguageProvider {
14
+ constructor(private client: LanguageServiceClient) {}
15
+
16
+ async moderateText(text: string, minimumConfidence: number): Promise<ModerationCategory[]> {
17
+ const [result] = await this.client.moderateText({
18
+ document: { content: text, type: 'PLAIN_TEXT' },
19
+ });
20
+
21
+ if (!result || !('moderationCategories' in result) || !Array.isArray(result.moderationCategories)) {
22
+ return [];
23
+ }
24
+
25
+ return result.moderationCategories
26
+ .map(c => ({ category: c.name ?? 'Unknown', confidence: (c.confidence ?? 0) * 100 }))
27
+ .filter(c => c.confidence >= minimumConfidence);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Google Cloud Speech-to-Text adapter. Used for audio transcription
33
+ * before running the transcript through text moderation.
34
+ */
35
+ export class GoogleSpeechProvider {
36
+ constructor(private client: SpeechClient) {}
37
+
38
+ async transcribe(audioBuffer: Buffer, languageCode: string): Promise<string> {
39
+ const config: IRecognitionConfig = {
40
+ encoding: 'OGG_OPUS',
41
+ sampleRateHertz: 48000,
42
+ languageCode,
43
+ };
44
+
45
+ const [response] = await this.client.recognize({
46
+ audio: { content: audioBuffer.toString('base64') },
47
+ config,
48
+ });
49
+
50
+ if (!Array.isArray(response?.results)) {
51
+ return '';
52
+ }
53
+
54
+ return response.results
55
+ .map((r: ISpeechRecognitionResult) => r.alternatives?.at(0)?.transcript ?? '')
56
+ .join(' ');
57
+ }
58
+ }
59
+
60
+ export async function fetchAudio(url: string): Promise<Buffer> {
61
+ const { data } = await axios.get<ArrayBuffer>(url, { responseType: 'arraybuffer' });
62
+ return Buffer.from(data);
63
+ }
@@ -0,0 +1,64 @@
1
+ import type { ModerationCategory } from '../types';
2
+
3
+ interface OpenAIModerationResponse {
4
+ results?: Array<{
5
+ flagged: boolean;
6
+ categories: Record<string, boolean>;
7
+ category_scores: Record<string, number>;
8
+ }>;
9
+ }
10
+
11
+ /**
12
+ * OpenAI Moderation API adapter.
13
+ *
14
+ * Uses the free `omni-moderation-latest` endpoint (no token cost,
15
+ * no SDK). Returns `ModerationCategory[]` in the same shape the
16
+ * Google and AWS providers return, so downstream callers can treat
17
+ * every provider uniformly.
18
+ */
19
+ export class OpenAIModerationProvider {
20
+ constructor(
21
+ private apiKey: string,
22
+ private model: string = 'omni-moderation-latest',
23
+ ) {}
24
+
25
+ async moderateText(text: string, minimumConfidence: number = 0): Promise<ModerationCategory[]> {
26
+ if (!this.apiKey) {
27
+ return [];
28
+ }
29
+
30
+ let response: Response;
31
+
32
+ try {
33
+ response = await fetch('https://api.openai.com/v1/moderations', {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Authorization': `Bearer ${this.apiKey}`,
37
+ 'Content-Type': 'application/json',
38
+ },
39
+ body: JSON.stringify({ model: this.model, input: text }),
40
+ });
41
+ } catch {
42
+ return [];
43
+ }
44
+
45
+ if (!response.ok) {
46
+ return [];
47
+ }
48
+
49
+ const data = (await response.json()) as OpenAIModerationResponse;
50
+ const result = data.results?.[0];
51
+
52
+ if (!result) {
53
+ return [];
54
+ }
55
+
56
+ return Object.entries(result.categories)
57
+ .filter(([, flagged]) => flagged)
58
+ .map(([category]) => ({
59
+ category,
60
+ confidence: (result.category_scores[category] ?? 0) * 100,
61
+ }))
62
+ .filter(c => c.confidence >= minimumConfidence);
63
+ }
64
+ }
@@ -0,0 +1,30 @@
1
+ import axios from 'axios';
2
+ import type { ModerationCategory, ThreatsResponse } from '../types';
3
+
4
+ const THREAT_TYPES = [
5
+ 'MALWARE',
6
+ 'SOCIAL_ENGINEERING',
7
+ 'UNWANTED_SOFTWARE',
8
+ 'SOCIAL_ENGINEERING_EXTENDED_COVERAGE',
9
+ ];
10
+
11
+ /**
12
+ * Google Web Risk link moderation adapter.
13
+ */
14
+ export class WebRiskProvider {
15
+ constructor(private apiKey: string) {}
16
+
17
+ async checkLink(url: string): Promise<ModerationCategory[]> {
18
+ const threatTypes = THREAT_TYPES.join('&threatTypes=');
19
+ const requestUrl = `https://webrisk.googleapis.com/v1/uris:search?threatTypes=${threatTypes}&key=${this.apiKey}`;
20
+ const { data } = await axios.get<ThreatsResponse>(`${requestUrl}&uri=${encodeURIComponent(url)}`);
21
+
22
+ const threats = data?.threat?.threatTypes;
23
+
24
+ if (Array.isArray(threats)) {
25
+ return threats.map(t => ({ category: t, confidence: 100 }));
26
+ }
27
+
28
+ return [];
29
+ }
30
+ }
@@ -0,0 +1,19 @@
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 function isAccountTooNew(joinedTimestamp: number | null, createdTimestamp: number | null, minAgeDays: number): boolean {
7
+ // For raids, we care about join time, not account creation
8
+ // Altough we can derive some trust from that too.
9
+ const reference = joinedTimestamp ?? createdTimestamp;
10
+
11
+ if (reference === null) {
12
+ return false;
13
+ }
14
+
15
+ const age = Date.now() - reference;
16
+ const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000;
17
+
18
+ return age < minAgeMs;
19
+ }
@@ -0,0 +1,133 @@
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
+
8
+ export interface MemberJoin {
9
+ memberId: string;
10
+ joinedTimestamp: number;
11
+ createdTimestamp: number;
12
+ }
13
+
14
+ export interface RaidTrackResult {
15
+ isRaid: boolean;
16
+ joinCount: number;
17
+ windowSeconds: number;
18
+ }
19
+
20
+ export type EnableResult = 'enabled' | 'already_active' | 'already_enabling';
21
+
22
+ interface JoinEvent {
23
+ memberId: string;
24
+ timestamp: number;
25
+ }
26
+
27
+ interface GuildState {
28
+ joins: JoinEvent[];
29
+ raidActive: boolean;
30
+ }
31
+
32
+ /**
33
+ * Platform-agnostic raid detector. Tracks recent joins per guild in a
34
+ * sliding window and surfaces the "raid" signal when the join count
35
+ * crosses the configured threshold.
36
+ *
37
+ * State transitions (`tryEnable`, `disable`) are guarded by an
38
+ * in-memory mutex so concurrent `handleMemberJoin` invocations during
39
+ * a join burst cannot both see "not active" and double-fire the
40
+ * enable side effects.
41
+ *
42
+ * The detector owns the sliding-window state. It does NOT own the
43
+ * enforcement — callers decide what to do when `isRaid` is true
44
+ * (timeout, kick, auto-disable timer, mod-channel alert, etc.).
45
+ */
46
+ export class RaidDetector {
47
+ readonly joinThreshold: number;
48
+ readonly joinWindow: number;
49
+
50
+ private state = new Map<string, GuildState>();
51
+ private enabling = new Set<string>();
52
+
53
+ constructor(options: RaidDetectorOptions = {}) {
54
+ this.joinThreshold = options.joinThreshold ?? 10;
55
+ this.joinWindow = (options.joinWindow ?? 60) * 1000;
56
+ }
57
+
58
+ private getState(guildId: string): GuildState {
59
+ if (!this.state.has(guildId)) {
60
+ this.state.set(guildId, { joins: [], raidActive: false });
61
+ }
62
+
63
+ return this.state.get(guildId)!;
64
+ }
65
+
66
+ private cleanupJoins(state: GuildState, now: number): void {
67
+ state.joins = state.joins.filter(j => now - j.timestamp < this.joinWindow);
68
+ }
69
+
70
+ /**
71
+ * Record a join and return whether the guild has crossed the raid
72
+ * threshold inside the window. `tryEnable` is a separate call so the
73
+ * caller can act on the raid signal atomically.
74
+ */
75
+ track(guildId: string, member: MemberJoin): RaidTrackResult {
76
+ const now = Date.now();
77
+ const state = this.getState(guildId);
78
+
79
+ this.cleanupJoins(state, now);
80
+ state.joins.push({ memberId: member.memberId, timestamp: now });
81
+
82
+ return {
83
+ isRaid: state.joins.length >= this.joinThreshold,
84
+ joinCount: state.joins.length,
85
+ windowSeconds: this.joinWindow / 1000,
86
+ };
87
+ }
88
+
89
+ isActive(guildId: string): boolean {
90
+ return this.state.get(guildId)?.raidActive ?? false;
91
+ }
92
+
93
+ /**
94
+ * Attempt to flip the guild into raid-active state. Returns:
95
+ * - 'enabled' if this call performed the transition
96
+ * - 'already_active' if raid mode was already on
97
+ * - 'already_enabling' if another concurrent call is mid-transition
98
+ */
99
+ async tryEnable(guildId: string): Promise<EnableResult> {
100
+ const state = this.getState(guildId);
101
+
102
+ if (state.raidActive) {
103
+ return 'already_active';
104
+ }
105
+
106
+ if (this.enabling.has(guildId)) {
107
+ return 'already_enabling';
108
+ }
109
+
110
+ this.enabling.add(guildId);
111
+
112
+ try {
113
+ state.raidActive = true;
114
+ return 'enabled';
115
+ } finally {
116
+ this.enabling.delete(guildId);
117
+ }
118
+ }
119
+
120
+ disable(guildId: string): void {
121
+ const state = this.getState(guildId);
122
+ state.raidActive = false;
123
+ this.enabling.delete(guildId);
124
+ }
125
+
126
+ getJoinCount(guildId: string, windowSeconds?: number): number {
127
+ const now = Date.now();
128
+ const state = this.getState(guildId);
129
+ const window = (windowSeconds ?? this.joinWindow / 1000) * 1000;
130
+
131
+ return state.joins.filter(j => now - j.timestamp < window).length;
132
+ }
133
+ }
@@ -0,0 +1,2 @@
1
+ export * from './detector';
2
+ export * from './age';
@@ -0,0 +1,32 @@
1
+ import { ACTION_TYPES } from '../actions';
2
+ import { ScoringRubric } from './rubric';
3
+
4
+ /**
5
+ * Aggressive defaults. Flag anything moderately suspicious, delete at
6
+ * 75%, timeout above 90%. Good for tight-knit communities with low
7
+ * tolerance for borderline content.
8
+ */
9
+ export const STRICT_RUBRIC = new ScoringRubric([
10
+ { match: { minConfidence: 50 }, action: ACTION_TYPES.WARN, severity: 'low' },
11
+ { match: { minConfidence: 75 }, action: ACTION_TYPES.DELETE, severity: 'medium' },
12
+ { match: { minConfidence: 90 }, action: ACTION_TYPES.TIMEOUT, severity: 'high' },
13
+ ]);
14
+
15
+ /**
16
+ * Conservative defaults. Only act on very high confidence. Good for
17
+ * large communities where false positives are worse than false
18
+ * negatives.
19
+ */
20
+ export const PERMISSIVE_RUBRIC = new ScoringRubric([
21
+ { match: { minConfidence: 95 }, action: ACTION_TYPES.DELETE, severity: 'high' },
22
+ { match: { minConfidence: 99 }, action: ACTION_TYPES.TIMEOUT, severity: 'critical' },
23
+ ]);
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
+ export const NSFW_ONLY_RUBRIC = new ScoringRubric([
31
+ { match: { category: 'sexual', minConfidence: 70 }, action: ACTION_TYPES.DELETE, severity: 'high' },
32
+ ]);
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export * from './rubric';
3
+ export * from './defaults';