@amatiscorp/disguard 1.0.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1028 -0
  3. package/dist/AntiSpam.d.ts +36 -0
  4. package/dist/AntiSpam.d.ts.map +1 -0
  5. package/dist/AntiSpam.js +196 -0
  6. package/dist/AntiSpam.js.map +1 -0
  7. package/dist/defaults.d.ts +5 -0
  8. package/dist/defaults.d.ts.map +1 -0
  9. package/dist/defaults.js +184 -0
  10. package/dist/defaults.js.map +1 -0
  11. package/dist/detectors/caps.d.ts +3 -0
  12. package/dist/detectors/caps.d.ts.map +1 -0
  13. package/dist/detectors/caps.js +29 -0
  14. package/dist/detectors/caps.js.map +1 -0
  15. package/dist/detectors/duplicates.d.ts +3 -0
  16. package/dist/detectors/duplicates.d.ts.map +1 -0
  17. package/dist/detectors/duplicates.js +40 -0
  18. package/dist/detectors/duplicates.js.map +1 -0
  19. package/dist/detectors/emojis.d.ts +3 -0
  20. package/dist/detectors/emojis.d.ts.map +1 -0
  21. package/dist/detectors/emojis.js +35 -0
  22. package/dist/detectors/emojis.js.map +1 -0
  23. package/dist/detectors/flood.d.ts +3 -0
  24. package/dist/detectors/flood.d.ts.map +1 -0
  25. package/dist/detectors/flood.js +24 -0
  26. package/dist/detectors/flood.js.map +1 -0
  27. package/dist/detectors/images.d.ts +9 -0
  28. package/dist/detectors/images.d.ts.map +1 -0
  29. package/dist/detectors/images.js +105 -0
  30. package/dist/detectors/images.js.map +1 -0
  31. package/dist/detectors/incident.d.ts +10 -0
  32. package/dist/detectors/incident.d.ts.map +1 -0
  33. package/dist/detectors/incident.js +18 -0
  34. package/dist/detectors/incident.js.map +1 -0
  35. package/dist/detectors/links.d.ts +3 -0
  36. package/dist/detectors/links.d.ts.map +1 -0
  37. package/dist/detectors/links.js +85 -0
  38. package/dist/detectors/links.js.map +1 -0
  39. package/dist/detectors/mentions.d.ts +3 -0
  40. package/dist/detectors/mentions.d.ts.map +1 -0
  41. package/dist/detectors/mentions.js +47 -0
  42. package/dist/detectors/mentions.js.map +1 -0
  43. package/dist/enforcement.d.ts +4 -0
  44. package/dist/enforcement.d.ts.map +1 -0
  45. package/dist/enforcement.js +153 -0
  46. package/dist/enforcement.js.map +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +22 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/store/MemoryStore.d.ts +18 -0
  52. package/dist/store/MemoryStore.d.ts.map +1 -0
  53. package/dist/store/MemoryStore.js +96 -0
  54. package/dist/store/MemoryStore.js.map +1 -0
  55. package/dist/types.d.ts +208 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +3 -0
  58. package/dist/types.js.map +1 -0
  59. package/dist/utils/hash.d.ts +9 -0
  60. package/dist/utils/hash.d.ts.map +1 -0
  61. package/dist/utils/hash.js +42 -0
  62. package/dist/utils/hash.js.map +1 -0
  63. package/dist/utils/normalize.d.ts +5 -0
  64. package/dist/utils/normalize.d.ts.map +1 -0
  65. package/dist/utils/normalize.js +35 -0
  66. package/dist/utils/normalize.js.map +1 -0
  67. package/dist/utils/similarity.d.ts +5 -0
  68. package/dist/utils/similarity.d.ts.map +1 -0
  69. package/dist/utils/similarity.js +45 -0
  70. package/dist/utils/similarity.js.map +1 -0
  71. package/dist/utils/urls.d.ts +18 -0
  72. package/dist/utils/urls.d.ts.map +1 -0
  73. package/dist/utils/urls.js +148 -0
  74. package/dist/utils/urls.js.map +1 -0
  75. package/package.json +62 -0
package/README.md ADDED
@@ -0,0 +1,1028 @@
1
+ # Disguard
2
+
3
+ npm: [`@amatiscorp/disguard`](https://www.npmjs.com/package/@amatiscorp/disguard)
4
+
5
+ Configurable antispam for [discord.js](https://discord.js.org) v14 bots.
6
+
7
+ It is **not** a bot. You plug it into your existing `Client` and decide every threshold, allowlist, and punishment.
8
+
9
+ Detects flood, repeated text, phishing / unwanted links, duplicate images, mention spam, excessive caps, and emoji spam.
10
+
11
+ > **Español:** librería antispam configurable para bots de discord.js. No es un bot. La documentación en español está más abajo: [Español](#español).
12
+
13
+ ---
14
+
15
+ **Languages**
16
+
17
+ - [English](#english)
18
+ - [Español](#español)
19
+
20
+ ---
21
+
22
+ # English
23
+
24
+ ## Table of contents
25
+
26
+ - [Features](#features)
27
+ - [Requirements](#requirements)
28
+ - [Install](#install)
29
+ - [Quick start](#quick-start)
30
+ - [Intents and permissions](#intents-and-permissions)
31
+ - [How it works](#how-it-works)
32
+ - [Presets](#presets)
33
+ - [Full configuration](#full-configuration)
34
+ - [Callbacks](#callbacks)
35
+ - [API](#api)
36
+ - [Recipes](#recipes)
37
+ - [Local testing](#local-testing)
38
+ - [What to send when testing](#what-to-send-when-testing)
39
+ - [FAQ](#faq)
40
+ - [Development](#development)
41
+ - [License](#license)
42
+
43
+ ## Features
44
+
45
+ | Detector | What it catches |
46
+ | --- | --- |
47
+ | **flood** | Too many messages in a short sliding window |
48
+ | **duplicate** | Same (or very similar) text sent repeatedly |
49
+ | **link** | Shorteners, raw IPs, punycode, brand lookalikes (`dlscord`, `steamcommunnity`), phishing keyword + URL, custom blocklists |
50
+ | **image** | Repeated images, stickers, and embed media |
51
+ | **mention** | `@everyone`, `@here`, too many unique mentions |
52
+ | **caps** | Messages that are mostly uppercase |
53
+ | **emoji** | Too many emojis or stickers in one message |
54
+
55
+ Also included:
56
+
57
+ - Three presets: `lenient`, `balanced`, `strict`
58
+ - Ignore lists for users, roles, channels, categories, and guilds
59
+ - Strike system with decay
60
+ - Optional delete / warn / timeout / kick / ban (kick and ban are **off** by default)
61
+ - `dryRun` to tune rules without punishing anyone
62
+ - Edit scanning (links and mentions)
63
+ - Zero extra runtime dependencies (peer: `discord.js`)
64
+ - In-memory store only — no database required
65
+ - Full TypeScript types
66
+
67
+ ## Requirements
68
+
69
+ - Node.js **18+**
70
+ - discord.js **^14**
71
+ - Privileged intents: **Message Content Intent** (and **Server Members Intent** if you use timeouts)
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ npm install @amatiscorp/disguard discord.js
77
+ ```
78
+
79
+ ## Quick start
80
+
81
+ ### CommonJS
82
+
83
+ ```js
84
+ const { Client, GatewayIntentBits, Partials } = require("discord.js");
85
+ const { AntiSpam } = require("@amatiscorp/disguard");
86
+
87
+ const client = new Client({
88
+ intents: [
89
+ GatewayIntentBits.Guilds,
90
+ GatewayIntentBits.GuildMessages,
91
+ GatewayIntentBits.MessageContent,
92
+ GatewayIntentBits.GuildMembers,
93
+ ],
94
+ partials: [Partials.Message],
95
+ });
96
+
97
+ const antispam = new AntiSpam(client, {
98
+ preset: "balanced",
99
+ ignored: {
100
+ roles: ["STAFF_ROLE_ID"],
101
+ channels: ["BOT_COMMANDS_CHANNEL_ID"],
102
+ },
103
+ punishment: {
104
+ deleteMessage: true,
105
+ warnUser: true,
106
+ timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },
107
+ logChannelId: "MOD_LOG_CHANNEL_ID",
108
+ },
109
+ });
110
+
111
+ client.once("ready", () => {
112
+ antispam.start();
113
+ console.log(`Ready as ${client.user.tag}`);
114
+ });
115
+
116
+ client.login(process.env.DISCORD_TOKEN);
117
+
118
+ process.on("SIGINT", () => {
119
+ antispam.stop();
120
+ client.destroy();
121
+ });
122
+ ```
123
+
124
+ ### TypeScript / ESM
125
+
126
+ ```ts
127
+ import { Client, GatewayIntentBits, Partials } from "discord.js";
128
+ import { AntiSpam } from "@amatiscorp/disguard";
129
+
130
+ const client = new Client({
131
+ intents: [
132
+ GatewayIntentBits.Guilds,
133
+ GatewayIntentBits.GuildMessages,
134
+ GatewayIntentBits.MessageContent,
135
+ GatewayIntentBits.GuildMembers,
136
+ ],
137
+ partials: [Partials.Message],
138
+ });
139
+
140
+ const antispam = new AntiSpam(client, {
141
+ preset: "strict",
142
+ dryRun: false,
143
+ onDetect(incident, message) {
144
+ console.log(incident.type, incident.reason, message.id);
145
+ },
146
+ });
147
+
148
+ client.once("ready", () => antispam.start());
149
+ await client.login(process.env.DISCORD_TOKEN);
150
+ ```
151
+
152
+ You can also use the factory:
153
+
154
+ ```js
155
+ const { createAntiSpam } = require("@amatiscorp/disguard");
156
+ const antispam = createAntiSpam(client, { preset: "balanced" });
157
+ ```
158
+
159
+ A full runnable example lives in [`examples/basic.js`](examples/basic.js). From this repo:
160
+
161
+ ```bash
162
+ copy .env.example .env
163
+ # put your bot token in .env
164
+ npm run dev
165
+ ```
166
+
167
+ ## Intents and permissions
168
+
169
+ | Goal | Intent / permission |
170
+ | --- | --- |
171
+ | Read message text | `MessageContent` + `GuildMessages` |
172
+ | Timeouts | `GuildMembers` + **Moderate Members** |
173
+ | Delete messages | **Manage Messages** |
174
+ | Warn in the channel | **Send Messages** |
175
+ | Log embeds | **Embed Links** |
176
+ | Kick / ban (opt-in) | **Kick Members** / **Ban Members** |
177
+
178
+ The bot will not sanction the guild owner or anyone above it in the role hierarchy. Those actions are skipped and reported in `onAction`.
179
+
180
+ Enable **Message Content Intent** in the [Discord Developer Portal](https://discord.com/developers/applications) → your app → Bot → Privileged Gateway Intents.
181
+
182
+ ## How it works
183
+
184
+ 1. Ignores bots, webhooks, the owner, administrators, and anything in your ignore lists.
185
+ 2. Keeps a short **in-memory** history per user and guild (no database).
186
+ 3. Runs detectors in this order: flood → duplicates → links → images → mentions → caps → emojis. The first match wins.
187
+ 4. Adds one strike (with optional decay) and applies the configured punishment.
188
+ 5. Message edits only re-check **links** and **mentions**, so editing `hello` into a phishing URL is still caught without counting as flood.
189
+
190
+ Call `antispam.stop()` on shutdown, hot reload, or plugin unload.
191
+
192
+ ## Presets
193
+
194
+ Pass `preset` and then override only what you care about.
195
+
196
+ | Preset | When to use |
197
+ | --- | --- |
198
+ | `lenient` | Busy community. Higher limits, no automatic timeout, shorteners allowed. |
199
+ | `balanced` | Default. Reasonable coverage with few false positives. |
200
+ | `strict` | Small servers or raid-prone ones. Blocks invites, lower thresholds, faster timeouts. |
201
+
202
+ ```js
203
+ new AntiSpam(client, {
204
+ preset: "strict",
205
+ flood: { maxMessages: 4 }, // overrides only this field
206
+ });
207
+ ```
208
+
209
+ | Setting | `lenient` | `balanced` | `strict` |
210
+ | --- | --- | --- | --- |
211
+ | Flood | 8 / 4s | 5 / 4s | 3 / 4s |
212
+ | Duplicate repeats | 4 | 3 | 2 |
213
+ | Duplicate similarity | 0.95 | 0.90 | 0.85 |
214
+ | Image repeats | 4 | 3 | 2 |
215
+ | Mentions | 10 | 6 | 3 |
216
+ | Block invites | no | no | yes |
217
+ | Block shorteners | no | yes | yes |
218
+ | Auto timeout | off | 60s from 2 strikes | 5 min from 1 strike |
219
+
220
+ ## Full configuration
221
+
222
+ Every option is optional. Missing fields fall back to the preset (or `balanced` if you omit `preset`).
223
+
224
+ ### Global
225
+
226
+ | Option | Type | Default | Description |
227
+ | --- | --- | --- | --- |
228
+ | `enabled` | `boolean` | `true` | Master switch. |
229
+ | `dryRun` | `boolean` | `false` | Detect and fire callbacks **without** deleting or punishing. Use this to tune. |
230
+ | `ignoreBots` | `boolean` | `true` | Skip other bots. |
231
+ | `ignoreWebhooks` | `boolean` | `true` | Skip webhooks. |
232
+ | `ignoreOwner` | `boolean` | `true` | Skip the guild owner. |
233
+ | `ignoreAdministrators` | `boolean` | `true` | Skip members with Administrator. |
234
+ | `checkEdits` | `boolean` | `true` | Re-scan edits for links and mentions. |
235
+ | `cleanupIntervalMs` | `number` | `60000` | How often the memory store is pruned. |
236
+
237
+ ### Ignore lists
238
+
239
+ ```js
240
+ ignored: {
241
+ users: ["123"],
242
+ roles: ["456"],
243
+ channels: ["789"],
244
+ categories: ["101"],
245
+ guilds: ["202"],
246
+ }
247
+ ```
248
+
249
+ IDs are snowflakes as strings. A staff role in `ignored.roles` bypasses every detector.
250
+
251
+ ### Flood
252
+
253
+ ```js
254
+ flood: {
255
+ enabled: true,
256
+ maxMessages: 5, // the 5th message inside the window triggers
257
+ windowMs: 4000,
258
+ severity: "medium",
259
+ }
260
+ ```
261
+
262
+ Uses a sliding window, not a fixed clock. Five different messages in 4 seconds count as flood.
263
+
264
+ ### Duplicates
265
+
266
+ Text is normalized first: lowercase, markdown stripped, URLs removed, whitespace collapsed.
267
+
268
+ `similarity` is `0–1`. `1` means exact match only. `0.9` also catches `hello!!!` vs `hello!`.
269
+
270
+ ```js
271
+ duplicates: {
272
+ enabled: true,
273
+ maxRepeats: 3,
274
+ windowMs: 12_000,
275
+ similarity: 0.9,
276
+ severity: "medium",
277
+ }
278
+ ```
279
+
280
+ ### Links and phishing
281
+
282
+ ```js
283
+ links: {
284
+ enabled: true,
285
+ blockInvites: false,
286
+ blockShorteners: true,
287
+ blockIpLinks: true,
288
+ blockPunycode: true,
289
+ blockBrandLookalikes: true,
290
+ detectPhishingKeywords: true,
291
+ allowList: ["youtube.com", "github.com"],
292
+ blockList: ["bad-domain.test"],
293
+ suspiciousTlds: [], // e.g. ["tk", "gq"]
294
+ customPatterns: [], // regex against the whole message
295
+ extraPhishingKeywords: ["fake giveaway"],
296
+ severity: "high",
297
+ }
298
+ ```
299
+
300
+ `allowList` wins over heuristics. Official Discord, YouTube, GitHub, Spotify, and a few others are already allowed.
301
+
302
+ Built-in checks (each can be turned off):
303
+
304
+ - URL shorteners (`bit.ly`, `t.co`, `tinyurl.com`, …)
305
+ - Literal IPs (`http://1.2.3.4`)
306
+ - Punycode / homographs (`xn--...`)
307
+ - Brand clones of Discord, Steam, GitHub, PayPal, Roblox, and others
308
+ - Scam phrases plus any link (`free nitro`, `steam gift`, `verifica tu cuenta`, …)
309
+ - Discord invite links if `blockInvites` is `true`
310
+ - Markdown-masked links: `[click](https://evil.test)`
311
+
312
+ ```js
313
+ // Block foreign server invites
314
+ links: { blockInvites: true }
315
+
316
+ // Allow one shortener you actually use
317
+ links: {
318
+ blockShorteners: true,
319
+ allowList: ["youtube.com", "youtu.be", "bit.ly"],
320
+ }
321
+
322
+ // Extra regex + extra scam words
323
+ links: {
324
+ customPatterns: ["steamcommunity\\.ru"],
325
+ extraPhishingKeywords: ["wallet drain", "airdrop now"],
326
+ }
327
+ ```
328
+
329
+ ### Images
330
+
331
+ ```js
332
+ images: {
333
+ enabled: true,
334
+ maxRepeats: 3,
335
+ windowMs: 20_000,
336
+ hashMode: "meta", // or "content"
337
+ maxDownloadBytes: 2_097_152,
338
+ includeStickers: true,
339
+ includeEmbeds: true,
340
+ crossUserThreshold: 0, // e.g. 4 = same image from 4 different users
341
+ severity: "medium",
342
+ }
343
+ ```
344
+
345
+ - `meta` (recommended): hash of size + MIME type + filename. No download.
346
+ - `content`: downloads the file and SHA-256s it. Better when the same bytes are re-uploaded under another name. Slower and uses bandwidth.
347
+
348
+ `crossUserThreshold: 4` is useful against copypasta / raid image floods.
349
+
350
+ ### Mentions, caps, emojis
351
+
352
+ ```js
353
+ mentions: {
354
+ enabled: true,
355
+ maxMentions: 6,
356
+ blockEveryone: true,
357
+ blockHere: true,
358
+ severity: "high",
359
+ }
360
+
361
+ caps: {
362
+ enabled: true,
363
+ minLength: 16, // ignore short shouts
364
+ maxPercent: 75, // 0–100
365
+ severity: "low",
366
+ }
367
+
368
+ emojis: {
369
+ enabled: true,
370
+ maxEmojis: 12,
371
+ maxStickers: 3,
372
+ severity: "low",
373
+ }
374
+ ```
375
+
376
+ ### Punishment
377
+
378
+ Kick and ban stay **disabled** on purpose. Turn them on only if you really want that.
379
+
380
+ ```js
381
+ punishment: {
382
+ deleteMessage: true,
383
+ warnUser: true,
384
+ dmUser: false,
385
+ warnMessage: "{user}, your message was blocked: {reason}. Strikes: {strikes}.",
386
+ timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },
387
+ kick: { enabled: false, minStrikes: 5 },
388
+ ban: { enabled: false, minStrikes: 8 },
389
+ escalate: true,
390
+ logChannelId: "CHANNEL_ID_OR_NULL",
391
+ strikeDecayMs: 15 * 60_000, // strikes expire after 15 minutes
392
+ }
393
+ ```
394
+
395
+ `warnMessage` placeholders: `{user}` `{reason}` `{type}` `{strikes}`.
396
+
397
+ With `escalate: true`, Disguard applies **one** hard action (timeout, or kick, or ban — whichever threshold you hit). Delete and warn can still run together.
398
+
399
+ If the bot lacks a permission, or the target is higher in the hierarchy, that action is skipped and listed in `result.skipped`.
400
+
401
+ ## Callbacks
402
+
403
+ ```js
404
+ new AntiSpam(client, {
405
+ dryRun: true,
406
+ onDetect(incident, message) {
407
+ // Fired after a detector matches, before punishment.
408
+ },
409
+ onAction(result) {
410
+ // applied / skipped / dryRun / error
411
+ },
412
+ onError(error, context) {
413
+ console.error("[disguard]", context, error);
414
+ },
415
+ });
416
+ ```
417
+
418
+ ### `Incident`
419
+
420
+ ```ts
421
+ {
422
+ type: "flood" | "duplicate" | "link" | "image" | "mention" | "caps" | "emoji",
423
+ severity: "low" | "medium" | "high" | "critical",
424
+ userId: string,
425
+ guildId: string,
426
+ channelId: string,
427
+ messageId: string,
428
+ reason: string,
429
+ details: Record<string, unknown>,
430
+ recommendedActions: Array<"delete" | "warn" | "timeout" | "kick" | "ban">,
431
+ timestamp: number,
432
+ }
433
+ ```
434
+
435
+ ### `ActionResult`
436
+
437
+ ```ts
438
+ {
439
+ incident: Incident,
440
+ dryRun: boolean,
441
+ applied: ActionType[],
442
+ skipped: Array<{ action: ActionType; reason: string }>,
443
+ error?: string,
444
+ }
445
+ ```
446
+
447
+ ## API
448
+
449
+ ```ts
450
+ import { AntiSpam, createAntiSpam, resolveConfig, DEFAULT_CONFIG } from "@amatiscorp/disguard";
451
+
452
+ const antispam = new AntiSpam(client, options);
453
+ // same as createAntiSpam(client, options)
454
+
455
+ antispam.start();
456
+ antispam.stop();
457
+
458
+ const config = antispam.getConfig();
459
+ antispam.setConfig({ flood: { maxMessages: 8 } }); // deep merge, other keys stay
460
+
461
+ antispam.getStrikes(guildId, userId);
462
+ antispam.resetUser(guildId, userId);
463
+
464
+ // Analyze only — no punishment, no callbacks
465
+ const incident = await antispam.analyze(message);
466
+ const edited = await antispam.analyze(message, { isEdit: true });
467
+
468
+ antispam.shouldIgnore(message); // boolean
469
+ ```
470
+
471
+ Helpers you can import for your own tools:
472
+
473
+ ```ts
474
+ import {
475
+ extractUrls,
476
+ normalizeText,
477
+ similarity,
478
+ DEFAULT_PHISHING_KEYWORDS,
479
+ DEFAULT_SHORTENERS,
480
+ OFFICIAL_BRANDS,
481
+ resolveConfig,
482
+ DEFAULT_CONFIG,
483
+ } from "@amatiscorp/disguard";
484
+
485
+ const urls = extractUrls("see [x](https://evil.test) and discord.gg/abc");
486
+ const near = similarity("hello world", "hello world!");
487
+ const config = resolveConfig("strict", { flood: { maxMessages: 2 } });
488
+ ```
489
+
490
+ ## Recipes
491
+
492
+ **Detect only — you handle sanctions**
493
+
494
+ ```js
495
+ const antispam = new AntiSpam(client, {
496
+ punishment: {
497
+ deleteMessage: false,
498
+ warnUser: false,
499
+ timeout: { enabled: false, durationMs: 0, minStrikes: 99 },
500
+ },
501
+ onDetect(incident, message) {
502
+ // tickets, database, your own Automod pipeline...
503
+ },
504
+ });
505
+ ```
506
+
507
+ **Tune without touching anyone**
508
+
509
+ ```js
510
+ new AntiSpam(client, {
511
+ dryRun: true,
512
+ onAction(result) {
513
+ console.log(result.incident.type, result.applied);
514
+ },
515
+ });
516
+ ```
517
+
518
+ **Per-guild rules at runtime**
519
+
520
+ ```js
521
+ client.on("interactionCreate", async (interaction) => {
522
+ if (!interaction.isChatInputCommand()) return;
523
+ if (interaction.commandName === "antispam-strict") {
524
+ antispam.setConfig({ preset: undefined, flood: { maxMessages: 3 } });
525
+ await interaction.reply("Flood limit set to 3.");
526
+ }
527
+ });
528
+ ```
529
+
530
+ `setConfig` deep-merges. It does not re-apply a preset unless you build one with `resolveConfig` yourself:
531
+
532
+ ```js
533
+ const { resolveConfig } = require("@amatiscorp/disguard");
534
+ antispam.setConfig(resolveConfig("strict", { ignored: antispam.getConfig().ignored }));
535
+ ```
536
+
537
+ **Real image hashing**
538
+
539
+ ```js
540
+ images: { hashMode: "content", maxDownloadBytes: 1_000_000 }
541
+ ```
542
+
543
+ **Reset a user after a false positive**
544
+
545
+ ```js
546
+ antispam.resetUser(guildId, userId);
547
+ ```
548
+
549
+ ## Local testing
550
+
551
+ This repository includes a demo bot.
552
+
553
+ 1. Create an application in the [Developer Portal](https://discord.com/developers/applications).
554
+ 2. Enable **Message Content Intent** and **Server Members Intent**.
555
+ 3. Invite the bot with Manage Messages + Moderate Members + Send Messages.
556
+ 4. Copy the env template and put the **bot** token (not a user token):
557
+
558
+ ```powershell
559
+ copy .env.example .env
560
+ ```
561
+
562
+ ```
563
+ DISCORD_TOKEN=your_bot_token_here
564
+ ```
565
+
566
+ 5. Run:
567
+
568
+ ```powershell
569
+ npm run dev
570
+ ```
571
+
572
+ Node does not load `.env` by itself. The example reads it via `examples/load-env.js`.
573
+
574
+ Test with an account that is **not** the server owner and **not** an Administrator, or Disguard will ignore you.
575
+
576
+ ## What to send when testing
577
+
578
+ Wait a few seconds between categories so flood does not eat the next test.
579
+
580
+ | Test | What to send |
581
+ | --- | --- |
582
+ | Flood | 5 messages in under 4 seconds |
583
+ | Duplicate | The same line 3 times, or `hello` then `hello!!!` |
584
+ | Shortener | `https://bit.ly/abc123` |
585
+ | Raw IP | `http://1.2.3.4/login` |
586
+ | Brand clone | `https://dlscord.com/nitro` or `https://steamcommunnity.com/gift` |
587
+ | Phishing combo | `Free Nitro https://totally-legit.gift/claim` |
588
+ | Custom blocklist | `https://malicioso.ejemplo/x` (demo `blockList`) |
589
+ | Allowed | `https://youtube.com` and `https://github.com` should pass |
590
+ | Images | Same photo or sticker 3 times |
591
+ | Mentions | `@everyone`, `@here`, or 7 different users |
592
+ | Caps | `THIS IS A MESSAGE IN ALL CAPS` (16+ letters) |
593
+ | Emojis | 13+ emojis, or 4 stickers |
594
+ | Edit | Send `hello`, then edit it to `Free Nitro https://bit.ly/test` |
595
+
596
+ Watch the terminal: `[detect] flood|duplicate|link|image|mention|caps|emoji`.
597
+
598
+ The second strike applies a 1 minute timeout with the default example config. Strikes decay after 15 minutes, or call `resetUser`.
599
+
600
+ ## FAQ
601
+
602
+ **Why does nothing happen when I spam?**
603
+ You are probably the guild owner or an Administrator. Those are ignored by default. Use a second account, or set `ignoreOwner: false` / `ignoreAdministrators: false` while testing.
604
+
605
+ **`TokenInvalid` on `npm run dev`?**
606
+ The token never reached Node, or it is a user token / revoked bot token. The example now loads `.env` automatically. Do not wrap the token in quotes. Do not prefix it with `Bot `.
607
+
608
+ **Can I use this without deleting messages?**
609
+ Yes. Set `punishment.deleteMessage: false` and handle `onDetect` yourself.
610
+
611
+ **Does it work in DMs?**
612
+ No. Guild messages only.
613
+
614
+ **Is there a database?**
615
+ No. History lives in memory and is dropped on restart.
616
+
617
+ **Will it ban people by default?**
618
+ No. Ban and kick are opt-in.
619
+
620
+ ## Development
621
+
622
+ ```bash
623
+ npm install
624
+ npm test
625
+ npm run build
626
+ npm run dev
627
+ ```
628
+
629
+ ## License
630
+
631
+ MIT
632
+
633
+ ---
634
+
635
+ # Español
636
+
637
+ ## Tabla de contenidos
638
+
639
+ - [Qué es](#qué-es)
640
+ - [Requisitos](#requisitos)
641
+ - [Instalación](#instalación)
642
+ - [Inicio rápido](#inicio-rápido)
643
+ - [Intents y permisos](#intents-y-permisos)
644
+ - [Cómo funciona](#cómo-funciona)
645
+ - [Presets](#presets-1)
646
+ - [Configuración completa](#configuración-completa)
647
+ - [Callbacks](#callbacks-1)
648
+ - [API](#api-1)
649
+ - [Recetas](#recetas)
650
+ - [Probar en local](#probar-en-local)
651
+ - [Qué enviar para testear](#qué-enviar-para-testear)
652
+ - [Preguntas frecuentes](#preguntas-frecuentes)
653
+ - [Desarrollo](#desarrollo-1)
654
+ - [Licencia](#licencia)
655
+
656
+ ## Qué es
657
+
658
+ **Disguard** es una librería antispam para bots de discord.js v14. No es un bot: la enchufas a tu `Client` y tú decides umbrales, listas y castigos.
659
+
660
+ Detecta flood, texto repetido, phishing / enlaces no deseados, imágenes repetidas, spam de menciones, mayúsculas y emojis.
661
+
662
+ ## Requisitos
663
+
664
+ - Node.js **18+**
665
+ - discord.js **^14**
666
+ - Intent de **contenido de mensaje** (y **miembros del servidor** si usas timeouts)
667
+
668
+ ## Instalación
669
+
670
+ ```bash
671
+ npm install @amatiscorp/disguard discord.js
672
+ ```
673
+
674
+ ## Inicio rápido
675
+
676
+ ### CommonJS
677
+
678
+ ```js
679
+ const { Client, GatewayIntentBits, Partials } = require("discord.js");
680
+ const { AntiSpam } = require("@amatiscorp/disguard");
681
+
682
+ const client = new Client({
683
+ intents: [
684
+ GatewayIntentBits.Guilds,
685
+ GatewayIntentBits.GuildMessages,
686
+ GatewayIntentBits.MessageContent,
687
+ GatewayIntentBits.GuildMembers,
688
+ ],
689
+ partials: [Partials.Message],
690
+ });
691
+
692
+ const antispam = new AntiSpam(client, {
693
+ preset: "balanced",
694
+ ignored: {
695
+ roles: ["ID_ROL_STAFF"],
696
+ channels: ["ID_CANAL_BOTS"],
697
+ },
698
+ punishment: {
699
+ deleteMessage: true,
700
+ warnUser: true,
701
+ timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },
702
+ logChannelId: "ID_CANAL_LOGS",
703
+ },
704
+ });
705
+
706
+ client.once("ready", () => {
707
+ antispam.start();
708
+ console.log(`Listo como ${client.user.tag}`);
709
+ });
710
+
711
+ client.login(process.env.DISCORD_TOKEN);
712
+ ```
713
+
714
+ ### TypeScript
715
+
716
+ ```ts
717
+ import { AntiSpam } from "@amatiscorp/disguard";
718
+
719
+ const antispam = new AntiSpam(client, {
720
+ preset: "strict",
721
+ onDetect(incident) {
722
+ console.log(incident.type, incident.reason);
723
+ },
724
+ });
725
+
726
+ antispam.start();
727
+ ```
728
+
729
+ Ejemplo completo en [`examples/basic.js`](examples/basic.js):
730
+
731
+ ```powershell
732
+ copy .env.example .env
733
+ npm run dev
734
+ ```
735
+
736
+ ## Intents y permisos
737
+
738
+ | Qué quieres | Intent / permiso |
739
+ | --- | --- |
740
+ | Leer el texto | `MessageContent` + `GuildMessages` |
741
+ | Timeouts | `GuildMembers` + **Moderate Members** |
742
+ | Borrar mensajes | **Manage Messages** |
743
+ | Avisar en el canal | **Send Messages** |
744
+ | Embed de logs | **Embed Links** |
745
+ | Kick / ban (opt-in) | **Kick Members** / **Ban Members** |
746
+
747
+ No puede sancionar al dueño del servidor ni a quien esté por encima del bot en la jerarquía.
748
+
749
+ Activa **Message Content Intent** en el [Portal de Discord](https://discord.com/developers/applications) → tu app → Bot.
750
+
751
+ ## Cómo funciona
752
+
753
+ 1. Ignora bots, webhooks, el dueño, administradores y tus listas de ignore.
754
+ 2. Guarda en **memoria** un historial corto por usuario y servidor. Sin base de datos.
755
+ 3. Pasa el mensaje por: flood → duplicados → enlaces → imágenes → menciones → caps → emojis. El primero que dispare gana.
756
+ 4. Suma un strike (con caducidad) y aplica el castigo configurado.
757
+ 5. Las ediciones solo revisan **enlaces** y **menciones**.
758
+
759
+ Llama `antispam.stop()` al apagar el proceso.
760
+
761
+ ## Presets
762
+
763
+ | Preset | Uso |
764
+ | --- | --- |
765
+ | `lenient` | Comunidad activa. Más holgura, sin timeout automático. |
766
+ | `balanced` | Por defecto. Equilibrio entre cobertura y falsos positivos. |
767
+ | `strict` | Servidores pequeños o con raids. Bloquea invitaciones y aprieta umbrales. |
768
+
769
+ ```js
770
+ new AntiSpam(client, {
771
+ preset: "strict",
772
+ flood: { maxMessages: 4 }, // solo pisa esto
773
+ });
774
+ ```
775
+
776
+ ## Configuración completa
777
+
778
+ Todo es opcional. Lo que no pongas usa el preset.
779
+
780
+ ### Global
781
+
782
+ | Opción | Tipo | Default | Qué hace |
783
+ | --- | --- | --- | --- |
784
+ | `enabled` | `boolean` | `true` | Interruptor maestro. |
785
+ | `dryRun` | `boolean` | `false` | Detecta y dispara callbacks **sin** borrar ni sancionar. |
786
+ | `ignoreBots` | `boolean` | `true` | Ignora otros bots. |
787
+ | `ignoreWebhooks` | `boolean` | `true` | Ignora webhooks. |
788
+ | `ignoreOwner` | `boolean` | `true` | Ignora al dueño. |
789
+ | `ignoreAdministrators` | `boolean` | `true` | Ignora quien tenga Administrator. |
790
+ | `checkEdits` | `boolean` | `true` | Revisa ediciones (enlaces y menciones). |
791
+ | `cleanupIntervalMs` | `number` | `60000` | Limpieza de memoria. |
792
+
793
+ ### Listas de ignore
794
+
795
+ ```js
796
+ ignored: {
797
+ users: ["id"],
798
+ roles: ["id"],
799
+ channels: ["id"],
800
+ categories: ["id"],
801
+ guilds: ["id"],
802
+ }
803
+ ```
804
+
805
+ ### Flood
806
+
807
+ ```js
808
+ flood: {
809
+ enabled: true,
810
+ maxMessages: 5, // el 5º mensaje dentro de la ventana dispara
811
+ windowMs: 4000,
812
+ severity: "medium",
813
+ }
814
+ ```
815
+
816
+ ### Duplicados
817
+
818
+ El texto se normaliza (minúsculas, sin markdown, sin URLs). `similarity` de `0.9` caza `hola!!!` ≈ `hola!`. `1` exige igualdad exacta.
819
+
820
+ ```js
821
+ duplicates: {
822
+ enabled: true,
823
+ maxRepeats: 3,
824
+ windowMs: 12_000,
825
+ similarity: 0.9,
826
+ severity: "medium",
827
+ }
828
+ ```
829
+
830
+ ### Enlaces y phishing
831
+
832
+ ```js
833
+ links: {
834
+ enabled: true,
835
+ blockInvites: false,
836
+ blockShorteners: true,
837
+ blockIpLinks: true,
838
+ blockPunycode: true,
839
+ blockBrandLookalikes: true,
840
+ detectPhishingKeywords: true,
841
+ allowList: ["youtube.com", "github.com"],
842
+ blockList: ["dominio-malo.test"],
843
+ suspiciousTlds: [],
844
+ customPatterns: ["steamcommunity\\.ru"],
845
+ extraPhishingKeywords: ["sorteo falso"],
846
+ severity: "high",
847
+ }
848
+ ```
849
+
850
+ La `allowList` gana a las heurísticas. `discord.com` y `youtube.com` ya vienen permitidos.
851
+
852
+ Heurísticas incluidas (todas se pueden apagar): acortadores, IPs, punycode, clones de marcas, palabras de estafa + enlace, invitaciones si `blockInvites` es `true`, y enlaces enmascarados `[texto](url)`.
853
+
854
+ ### Imágenes
855
+
856
+ ```js
857
+ images: {
858
+ enabled: true,
859
+ maxRepeats: 3,
860
+ windowMs: 20_000,
861
+ hashMode: "meta", // o "content"
862
+ maxDownloadBytes: 2_097_152,
863
+ includeStickers: true,
864
+ includeEmbeds: true,
865
+ crossUserThreshold: 0, // p.ej. 4 = misma imagen por 4 usuarios
866
+ severity: "medium",
867
+ }
868
+ ```
869
+
870
+ - `meta` (recomendado): tamaño + tipo + nombre. No descarga nada.
871
+ - `content`: descarga el archivo y hace SHA-256. Más preciso y más lento.
872
+
873
+ ### Menciones, mayúsculas, emojis
874
+
875
+ ```js
876
+ mentions: { enabled: true, maxMentions: 6, blockEveryone: true, blockHere: true, severity: "high" },
877
+ caps: { enabled: true, minLength: 16, maxPercent: 75, severity: "low" },
878
+ emojis: { enabled: true, maxEmojis: 12, maxStickers: 3, severity: "low" },
879
+ ```
880
+
881
+ ### Castigos
882
+
883
+ Kick y ban van **apagados**. Actívalos solo si lo tienes claro.
884
+
885
+ ```js
886
+ punishment: {
887
+ deleteMessage: true,
888
+ warnUser: true,
889
+ dmUser: false,
890
+ warnMessage: "{user}, tu mensaje se ha bloqueado: {reason}. Strikes: {strikes}.",
891
+ timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },
892
+ kick: { enabled: false, minStrikes: 5 },
893
+ ban: { enabled: false, minStrikes: 8 },
894
+ escalate: true,
895
+ logChannelId: "ID_O_NULL",
896
+ strikeDecayMs: 15 * 60_000,
897
+ }
898
+ ```
899
+
900
+ Placeholders: `{user}` `{reason}` `{type}` `{strikes}`.
901
+
902
+ Con `escalate: true` se aplica **un** castigo fuerte (timeout, kick o ban). Aviso y borrado se pueden sumar.
903
+
904
+ ## Callbacks
905
+
906
+ ```js
907
+ new AntiSpam(client, {
908
+ dryRun: true,
909
+ onDetect(incident, message) {},
910
+ onAction(result) {},
911
+ onError(error, context) {
912
+ console.error(context, error);
913
+ },
914
+ });
915
+ ```
916
+
917
+ `Incident` y `ActionResult` tienen la misma forma que en la sección en inglés.
918
+
919
+ ## API
920
+
921
+ ```ts
922
+ const antispam = new AntiSpam(client, options);
923
+
924
+ antispam.start();
925
+ antispam.stop();
926
+ antispam.getConfig();
927
+ antispam.setConfig({ flood: { maxMessages: 8 } });
928
+ antispam.getStrikes(guildId, userId);
929
+ antispam.resetUser(guildId, userId);
930
+
931
+ const incident = await antispam.analyze(message);
932
+ const editado = await antispam.analyze(message, { isEdit: true });
933
+ ```
934
+
935
+ También: `createAntiSpam`, `resolveConfig`, `DEFAULT_CONFIG`, `extractUrls`, `normalizeText`, `similarity`.
936
+
937
+ ## Recetas
938
+
939
+ **Solo detectar**
940
+
941
+ ```js
942
+ new AntiSpam(client, {
943
+ punishment: {
944
+ deleteMessage: false,
945
+ warnUser: false,
946
+ timeout: { enabled: false, durationMs: 0, minStrikes: 99 },
947
+ },
948
+ onDetect(incident, message) {
949
+ // tu lógica
950
+ },
951
+ });
952
+ ```
953
+
954
+ **Probar sin tocar a nadie:** `dryRun: true`.
955
+
956
+ **Hash real de imágenes:** `images: { hashMode: "content" }`.
957
+
958
+ **Quitar strikes:** `antispam.resetUser(guildId, userId)`.
959
+
960
+ **Cambiar a strict en caliente**
961
+
962
+ ```js
963
+ const { resolveConfig } = require("@amatiscorp/disguard");
964
+ antispam.setConfig(resolveConfig("strict", { ignored: antispam.getConfig().ignored }));
965
+ ```
966
+
967
+ ## Probar en local
968
+
969
+ 1. Crea el bot en el portal y activa Message Content + Server Members.
970
+ 2. Invítalo con Manage Messages, Moderate Members y Send Messages.
971
+ 3. Copia `.env.example` a `.env` y pon el token del **bot**.
972
+ 4. `npm run dev`.
973
+
974
+ Node no carga el `.env` solo; el ejemplo sí lo lee. Prueba con una cuenta que **no** sea owner ni admin.
975
+
976
+ ## Qué enviar para testear
977
+
978
+ Espera unos segundos entre categorías.
979
+
980
+ | Test | Qué mandar |
981
+ | --- | --- |
982
+ | Flood | 5 mensajes en menos de 4 segundos |
983
+ | Duplicado | La misma línea 3 veces, o `hola` y `hola!!!` |
984
+ | Acortador | `https://bit.ly/abc123` |
985
+ | IP | `http://1.2.3.4/login` |
986
+ | Clon | `https://dlscord.com/nitro` |
987
+ | Phishing | `Free Nitro https://totally-legit.gift/claim` |
988
+ | Blocklist | `https://malicioso.ejemplo/x` |
989
+ | Permitidos | `https://youtube.com` y `https://github.com` no deben saltar |
990
+ | Imágenes | La misma foto o sticker 3 veces |
991
+ | Menciones | `@everyone`, `@here`, o 7 usuarios |
992
+ | Caps | `ESTO ES UN MENSAJE TODO EN MAYUSCULAS` |
993
+ | Emojis | Más de 12 emojis, o 4 stickers |
994
+ | Edición | Manda `hola` y edítalo a `Free Nitro https://bit.ly/test` |
995
+
996
+ En la terminal: `[detect] flood|duplicate|link|image|mention|caps|emoji`.
997
+
998
+ Al segundo strike el ejemplo mete timeout de 1 minuto. Los strikes caducan a los 15 minutos, o usa `resetUser`.
999
+
1000
+ ## Preguntas frecuentes
1001
+
1002
+ **No pasa nada cuando spameo.**
1003
+ Seguramente eres el dueño o tienes Administrator. Usa otra cuenta, o pon `ignoreOwner: false` / `ignoreAdministrators: false` mientras pruebas.
1004
+
1005
+ **`TokenInvalid`.**
1006
+ El `.env` no se estaba leyendo, o el token es de usuario / está revocado. El ejemplo ya carga el `.env`. Sin comillas y sin prefijo `Bot `.
1007
+
1008
+ **¿Funciona en DMs?**
1009
+ No. Solo servidores.
1010
+
1011
+ **¿Hay base de datos?**
1012
+ No. Todo vive en memoria y se pierde al reiniciar.
1013
+
1014
+ **¿Banea por defecto?**
1015
+ No. Kick y ban son opt-in.
1016
+
1017
+ ## Desarrollo
1018
+
1019
+ ```bash
1020
+ npm install
1021
+ npm test
1022
+ npm run build
1023
+ npm run dev
1024
+ ```
1025
+
1026
+ ## Licencia
1027
+
1028
+ MIT