{"_id":"@amatiscorp/disguard","_rev":"6-85498b8477ce91665a6c0b0424052852","name":"@amatiscorp/disguard","dist-tags":{"latest":"1.5.0"},"versions":{"1.0.0":{"name":"@amatiscorp/disguard","version":"1.0.0","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"author":{"name":"Jackson"},"license":"MIT","_id":"@amatiscorp/disguard@1.0.0","maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"homepage":"https://github.com/Amatis-Corp/NPM-Security-Discord#readme","bugs":{"url":"https://github.com/Amatis-Corp/NPM-Security-Discord/issues"},"dist":{"shasum":"a65cce70a3eef77ea6c0296af6b5dac7d83a45cf","tarball":"https://registry.npmjs.org/@amatiscorp/disguard/-/disguard-1.0.0.tgz","fileCount":75,"integrity":"sha512-p3rW0b4jPNJrwRWQmQM1HDF4S4TQxLTwpxgWGlSaGfftCyIz3oZIPhwSPDu+5hMAwL/1CJImp/ddYRMQ0nYGWA==","signatures":[{"sig":"MEUCIQDPw/FZ/G90cGc6592GXxGfV8dEv6e34BFYEk2E24uHNgIgcWOZakhhEJhgIOnnZ03ABGiaQMzsDXKw74/61IsOrYg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":152972},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"c992e7cec646474a19ad2344cdfbe18f1c156c65","scripts":{"dev":"node examples/basic.js","test":"vitest run","build":"tsc","clean":"node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"","test:watch":"vitest","prepublishOnly":"npm run clean && npm run build"},"_npmUser":{"name":"clasifed","email":"clasifed@proton.me"},"repository":{"url":"git+https://github.com/Amatis-Corp/NPM-Security-Discord.git","type":"git"},"_npmVersion":"11.17.0","description":"Configurable antispam for discord.js bots: flood, repeated messages, phishing, duplicate images, and more.","directories":{},"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.8","discord.js":"^14.18.0","typescript":"^5.8.2","@types/node":"^22.13.10"},"peerDependencies":{"discord.js":"^14.0.0"},"_npmOperationalInternal":{"tmp":"tmp/disguard_1.0.0_1788088561137_0.011994737843740166","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@amatiscorp/disguard","version":"1.1.0","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"author":{"name":"Jackson"},"license":"MIT","_id":"@amatiscorp/disguard@1.1.0","maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"homepage":"https://github.com/Amatis-Corp/NPM-Security-Discord#readme","bugs":{"url":"https://github.com/Amatis-Corp/NPM-Security-Discord/issues"},"dist":{"shasum":"9cfe58443a0f326d5f56e47caaca1a3ade651046","tarball":"https://registry.npmjs.org/@amatiscorp/disguard/-/disguard-1.1.0.tgz","fileCount":87,"integrity":"sha512-2oQZu5HZq/57pbBJob6hE2U1F/wrUy7aPIBuGWRdO77DmJbURFdNNrJTokA416K7dLPlMnlAsk7N60CQFo6XgA==","signatures":[{"sig":"MEQCIE7Auopyr58M4Ihb9zB+XeAiz8ki4aslc93Cy6aMJKegAiB9QVnrPnnOH+lv3jpHAjCLsNYqI3n/KUEynv0PV8QKsA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":179699},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"d9ec0766e65246756989b67b7977194abfa41f7a","scripts":{"dev":"node examples/basic.js","test":"vitest run","build":"tsc","clean":"node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"","test:watch":"vitest","prepublishOnly":"npm run clean && npm run build"},"_npmUser":{"name":"clasifed","email":"clasifed@proton.me"},"repository":{"url":"git+https://github.com/Amatis-Corp/NPM-Security-Discord.git","type":"git"},"_npmVersion":"11.17.0","description":"Configurable antispam for discord.js bots: flood, repeated messages, phishing, duplicate images, and more.","directories":{},"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.8","discord.js":"^14.18.0","typescript":"^5.8.2","@types/node":"^22.13.10"},"peerDependencies":{"discord.js":"^14.0.0"},"_npmOperationalInternal":{"tmp":"tmp/disguard_1.1.0_1788089022782_0.01240723016560663","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@amatiscorp/disguard","version":"1.2.0","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"author":{"name":"Jackson"},"license":"MIT","_id":"@amatiscorp/disguard@1.2.0","maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"homepage":"https://github.com/Amatis-Corp/NPM-Security-Discord#readme","bugs":{"url":"https://github.com/Amatis-Corp/NPM-Security-Discord/issues"},"dist":{"shasum":"ee364e437c510286df7bead9af5f6baa39144f2f","tarball":"https://registry.npmjs.org/@amatiscorp/disguard/-/disguard-1.2.0.tgz","fileCount":99,"integrity":"sha512-cO6Jj/L0ymu0LDGysK8vsMwWpkRvAirKuS0MxUD0XOzL11klx8gdglsHSoXhVwDyPa8lNGuKJUvdRftLhgzrhg==","signatures":[{"sig":"MEQCIDgJwsHGiFnFk39NFN7vcNhLyHPioI9THz5GTIQXXTQXAiAcur0HuDzivvsj6iYnuxxqKYCX88IooKVhC+36yzHUDQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":208853},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"dc421c7a76cd866e9b7c114cac857394e4b7a0b9","scripts":{"dev":"node examples/basic.js","test":"vitest run","build":"tsc","clean":"node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"","test:watch":"vitest","prepublishOnly":"npm run clean && npm run build"},"_npmUser":{"name":"clasifed","email":"clasifed@proton.me"},"repository":{"url":"git+https://github.com/Amatis-Corp/NPM-Security-Discord.git","type":"git"},"_npmVersion":"11.17.0","description":"Configurable antispam for discord.js bots: flood, repeated messages, phishing, duplicate images, and more.","directories":{},"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.8","discord.js":"^14.18.0","typescript":"^5.8.2","@types/node":"^22.13.10"},"peerDependencies":{"discord.js":"^14.0.0"},"_npmOperationalInternal":{"tmp":"tmp/disguard_1.2.0_1788089366900_0.1915356308797045","host":"s3://npm-registry-packages-npm-production"}},"1.3.0":{"name":"@amatiscorp/disguard","version":"1.3.0","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"author":{"name":"Jackson"},"license":"MIT","_id":"@amatiscorp/disguard@1.3.0","maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"homepage":"https://github.com/Amatis-Corp/NPM-Security-Discord#readme","bugs":{"url":"https://github.com/Amatis-Corp/NPM-Security-Discord/issues"},"dist":{"shasum":"94348f10acc62722ee9d86917b650427cb4f2ea8","tarball":"https://registry.npmjs.org/@amatiscorp/disguard/-/disguard-1.3.0.tgz","fileCount":119,"integrity":"sha512-kt+BJU/xGuOnU24YzR6N5gNlCaMCnI7441bpdy3igksquizXAn+TK5NE2IRng2NasZySXBk3O/l1gRv8oGG94A==","signatures":[{"sig":"MEUCIFyeWwNgCU9bnoJwduIPl/qJNfQ+B87dx/KiD/72hhpUAiEAvxykKnbMLNr4iQwV7c+I0dC8VHp0WOZvlYYDkv+p5zA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":254823},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"ec0e59f25bc4e8638e7bd431cccfb5022b446ef7","scripts":{"dev":"node examples/basic.js","test":"vitest run","build":"tsc","clean":"node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"","test:watch":"vitest","prepublishOnly":"npm run clean && npm run build"},"_npmUser":{"name":"clasifed","email":"clasifed@proton.me"},"repository":{"url":"git+https://github.com/Amatis-Corp/NPM-Security-Discord.git","type":"git"},"_npmVersion":"11.17.0","description":"Configurable antispam for discord.js bots: flood, channel hop, blocked words, phishing, ghost pings, duplicate images, and more.","directories":{},"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.8","discord.js":"^14.18.0","typescript":"^5.8.2","@types/node":"^22.13.10"},"peerDependencies":{"discord.js":"^14.0.0"},"_npmOperationalInternal":{"tmp":"tmp/disguard_1.3.0_1788242486944_0.638523887047562","host":"s3://npm-registry-packages-npm-production"}},"1.4.0":{"name":"@amatiscorp/disguard","version":"1.4.0","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"author":{"name":"Jackson"},"license":"MIT","_id":"@amatiscorp/disguard@1.4.0","maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"homepage":"https://github.com/Amatis-Corp/disguard#readme","bugs":{"url":"https://github.com/Amatis-Corp/disguard/issues"},"dist":{"shasum":"8efef57ad993001c17093b9ea8f6010d644bbc2a","tarball":"https://registry.npmjs.org/@amatiscorp/disguard/-/disguard-1.4.0.tgz","fileCount":135,"integrity":"sha512-Q91BfZPT9CAVB/+jVA/wHLtj2xkvkZV3EMbkpQcajE9hwTGO3Cc1MlV2Na2OjDupjHOOZ1lSKyYQ4K+YWx3sJQ==","signatures":[{"sig":"MEQCIDe+WTwgq9MArLlJAU4v86DATKrGhWVS+23JiPChMN+uAiBX3GlwHTce29PuZMTYomyM7sS/b5uMSNCUaEsdchYw4A==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":285774},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"0887a915163d6f2bd0cc5ef925646045d07ec763","scripts":{"dev":"node examples/basic.js","test":"vitest run","build":"tsc","clean":"node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"","test:watch":"vitest","prepublishOnly":"npm run clean && npm run build"},"_npmUser":{"name":"clasifed","email":"clasifed@proton.me"},"repository":{"url":"git+https://github.com/Amatis-Corp/disguard.git","type":"git"},"_npmVersion":"11.17.0","description":"Configurable antispam for discord.js bots: flood, secrets, channel hop, phishing, ghost pings, and more.","directories":{},"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.8","discord.js":"^14.18.0","typescript":"^5.8.2","@types/node":"^22.13.10"},"peerDependencies":{"discord.js":"^14.0.0"},"_npmOperationalInternal":{"tmp":"tmp/disguard_1.4.0_1788243692518_0.6310686500198233","host":"s3://npm-registry-packages-npm-production"}},"1.5.0":{"name":"@amatiscorp/disguard","version":"1.5.0","description":"Configurable antispam for discord.js bots: flood, secrets, channel hop, phishing, ghost pings, and more.","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"license":"MIT","author":{"name":"Jackson"},"repository":{"type":"git","url":"git+https://github.com/Amatis-Corp/disguard.git"},"bugs":{"url":"https://github.com/Amatis-Corp/disguard/issues"},"homepage":"https://github.com/Amatis-Corp/disguard#readme","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"publishConfig":{"access":"public"},"engines":{"node":">=18"},"scripts":{"build":"tsc","clean":"node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"","prepublishOnly":"npm run clean && npm run build","test":"vitest run","test:watch":"vitest","dev":"node examples/basic.js"},"peerDependencies":{"discord.js":"^14.0.0"},"devDependencies":{"@types/node":"^22.13.10","discord.js":"^14.18.0","typescript":"^5.8.2","vitest":"^3.0.8"},"gitHead":"a27e168e2c6f55ef27090a940501b8195c4d4a29","_id":"@amatiscorp/disguard@1.5.0","_nodeVersion":"24.19.0","_npmVersion":"11.17.0","dist":{"integrity":"sha512-a5erLDnsnVCDxyDTR5mtWU2PpEmKakcexDnX7ZXY4/yOIpTbMBpijLWUjMd/itDp09dXaSqaNo2gXLL9SbzBlw==","shasum":"4d43ad213da1e1ddc715ffee1a41690f5793177c","tarball":"https://registry.npmjs.org/@amatiscorp/disguard/-/disguard-1.5.0.tgz","fileCount":151,"unpackedSize":311806,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCY9ADlpccCC5X0Conr7zmLaF9jDXqYyzxxKSFUdkQK+AIgLKzvZrOJ9K5t1Iew7Tm9/3A3S0N9J5IfIpaYf1+90Y8="}]},"_npmUser":{"name":"clasifed","email":"clasifed@proton.me"},"directories":{},"maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/disguard_1.5.0_1788434236627_0.36244767378104426"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-30T11:16:00.876Z","modified":"2026-09-03T11:17:17.237Z","1.0.0":"2026-08-30T11:16:01.292Z","1.1.0":"2026-08-30T11:23:42.928Z","1.2.0":"2026-08-30T11:29:27.039Z","1.3.0":"2026-09-01T06:01:27.086Z","1.4.0":"2026-09-01T06:21:32.648Z","1.5.0":"2026-09-03T11:17:16.772Z"},"bugs":{"url":"https://github.com/Amatis-Corp/disguard/issues"},"author":{"name":"Jackson"},"license":"MIT","homepage":"https://github.com/Amatis-Corp/disguard#readme","keywords":["disguard","discord","discord.js","antispam","anti-spam","moderation","phishing","security","automod"],"repository":{"type":"git","url":"git+https://github.com/Amatis-Corp/disguard.git"},"description":"Configurable antispam for discord.js bots: flood, secrets, channel hop, phishing, ghost pings, and more.","maintainers":[{"name":"clasifed","email":"clasifed@proton.me"}],"readme":"# Disguard\r\n\r\n<div align=\"center\">\r\n\r\n**Configurable antispam for discord.js v14**\r\n\r\nFlood · phishing · leaked secrets · channel hop · ghost pings · TypeScript\r\n\r\n[![npm version](https://img.shields.io/npm/v/@amatiscorp/disguard.svg?style=flat-square)](https://www.npmjs.com/package/@amatiscorp/disguard)\r\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)\r\n[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg?style=flat-square)](https://www.typescriptlang.org/)\r\n\r\n[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Español](#español)\r\n\r\n</div>\r\n\r\n---\r\n\r\nnpm: [`@amatiscorp/disguard`](https://www.npmjs.com/package/@amatiscorp/disguard)\r\n\r\nConfigurable antispam for [discord.js](https://discord.js.org) v14 bots.\r\n\r\nIt is **not** a bot. You plug it into your existing `Client` and decide every threshold, allowlist, and punishment.\r\n\r\nDetects flood, leaked tokens/webhooks, channel hopping, blocked words, phishing, duplicate images, mention spam, ghost pings, and more.\r\n\r\n**Languages**\r\n\r\n- [English](#english)\r\n- [Español](#español)\r\n\r\n---\r\n\r\n# English\r\n\r\n## What's new in 1.5.0\r\n\r\nChannel raids, reply spam, empty messages, embed walls, file allowlists, and per-user overlays. Kick/ban stay **off**. `raid` is **off** in `balanced` so busy servers do not false-positive.\r\n\r\n| Option | Default | What it does |\r\n| --- | --- | --- |\r\n| `replies` | **on**, 6 / 8s | Too many Discord **replies** in a short window (reply ping / bump spam). |\r\n| `blank` | **on** | Empty or whitespace-only messages (no files/stickers/embeds). |\r\n| `embeds` | **on**, 6 | Too many embeds in one message. |\r\n| `raid` | **off** (`strict`: on, 6 users / 5s) | Too many **distinct users** posting in the **same channel** at once. |\r\n| `files.allowedExtensions` | `[]` | If set, only those extensions are allowed (after the blocklist). |\r\n| `emojis.maxInWindow` | `0` (off) | Cap emojis+stickers across several messages. |\r\n| `ignoreNsfw` | `false` | Skip NSFW channels. |\r\n| `graceMessages` | `0` | First N messages from a user only run file/secret/link/word (say hi without flood). |\r\n| `userOverrides` / `setUserConfig` | `{}` | Per-user config overlay (after roles, before channel). |\r\n\r\n```js\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"balanced\",\r\n  replies: { maxReplies: 5, windowMs: 8_000 },\r\n  blank: { enabled: true },\r\n  embeds: { maxEmbeds: 6 },\r\n  raid: { enabled: true, maxUsers: 8, windowMs: 6_000 },\r\n  files: { allowedExtensions: [\"png\", \"jpg\", \"webp\", \"gif\"] },\r\n  graceMessages: 2,\r\n  ignoreNsfw: true,\r\n  emojis: { maxInWindow: 30, windowMs: 10_000 },\r\n});\r\n\r\nantispam.setUserConfig(\"KNOWN_SPAMMER_ID\", { flood: { maxMessages: 2 } });\r\n```\r\n\r\nTurn `raid` on only if you want a busy general chat to trip when many people post at once (join raids). For a meme channel, leave it off or raise `maxUsers`.\r\n\r\n## What's new in 1.4.0\r\n\r\nSecret scanning, cross-channel copy-paste, invisible unicode, attachment floods, and cleaner logs. Kick/ban stay **off**.\r\n\r\n| Option | Default | What it does |\r\n| --- | --- | --- |\r\n| `secrets` | **on** | Flags Discord **bot tokens** and **webhook URLs** pasted in chat (also embeds). The incident does **not** store the secret. |\r\n| `echo` | **on**, 3 channels / 12s | Same text pasted across several channels (raid copypasta). Distinct from `hop` (any messages). |\r\n| `invisible` | **on**, 8 | Zero-width / bidi characters used to dodge filters. |\r\n| `attach` | **on**, 8 files / 10s | Too many attachments across a short window, not just dangerous extensions. |\r\n| `duplicates.minLength` | `8` | Short stuff like `ok` / `lol` is no longer duplicate-spam. |\r\n| `mentions.maxInWindow` | `0` (off) | Cap mentions across several messages, not only one. |\r\n| `accounts.onlyWithLinks` | `false` (`strict`: true) | New accounts are flagged only if the message has a URL. |\r\n| `punishment.purgeCount` | `0` | After a hit, bulk-delete N of that user's recent messages in the channel. |\r\n| `punishment.logWebhookUrl` | `null` | Log embeds to a Discord webhook (no extra log channel needed). |\r\n| `ignorePinned` | `false` | Skip pinned messages. |\r\n| `ignoreOlderThanMs` | `0` | Skip old messages (useful on reconnect replay). |\r\n| `setEnabled(false)` | — | Turn the whole filter off without `stop()`. |\r\n\r\n```js\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"balanced\",\r\n  secrets: { enabled: true, botTokens: true, webhooks: true, extraPatterns: [\"ghp_[A-Za-z0-9]{20,}\"] },\r\n  echo: { maxChannels: 3, windowMs: 12_000, minLength: 12 },\r\n  invisible: { maxInvisible: 8 },\r\n  attach: { maxAttachments: 8, windowMs: 10_000 },\r\n  duplicates: { minLength: 8 },\r\n  accounts: { enabled: true, minAgeDays: 2, onlyWithLinks: true },\r\n  ignorePinned: true,\r\n  punishment: {\r\n    purgeCount: 5,\r\n    logWebhookUrl: process.env.DISGUARD_LOG_WEBHOOK || null,\r\n  },\r\n});\r\n\r\nantispam.setEnabled(false); // raid party / event\r\nantispam.setEnabled(true);\r\n```\r\n\r\n`secrets` is meant to **delete leaks**, not to harvest credentials. Reasons only say “token/webhook”, never the value.\r\n\r\n## What's new in 1.3.0\r\n\r\n| Option | Default | What it does |\r\n| --- | --- | --- |\r\n| `words` | **off**, empty list | Block a word list and/or regex. `ignoreCase` and `matchWholeWord` are both `true` by default. |\r\n| `hop` | **on**, 5 channels / 8s | Same user posting across too many unique channels in a window (raid / hop). |\r\n| `punctuation` | **on**, 10 | `aaaaaa` / `!!!!!!` runs longer than `maxRepeated`. |\r\n| `spoilers` | **on**, 8 pairs | Too many `\\|\\|spoiler\\|\\|` pairs in one message. |\r\n| `ghostPing` | **off** (`strict`: on) | Punish deleting a message that mentioned people (`messageDelete`). |\r\n| `checkDeletes` | `true` | Listen for `messageDelete`. Set `false` if you do not want ghost-ping checks. |\r\n| `ignored.prefixes` | `[]` | Skip messages that start with `!`, `/`, `.`, etc. |\r\n| `ignoreThreads` | `false` | Skip thread messages. |\r\n| `ignoreSystem` | `true` | Skip Discord system messages. |\r\n| `roleOverrides` / `setRoleConfig` | `{}` | Per-role config overlay (before channel overrides). |\r\n| `links.blockOauth` | `true` | Flag non-allowlisted URLs whose path looks like `/oauth`, `/authorize`, `/login`. Official `discord.com` stays allowed. |\r\n| `files.maxBytes` | `0` | Max attachment size in bytes. `0` = unlimited. |\r\n| `accounts.minGuildAgeDays` | `0` | Flag members who joined the guild too recently. `0` = off. |\r\n| `punishment.addRoleIds` / `removeRoleIds` | `[]` | Add/remove roles on punish (mute role). Needs **Manage Roles**. |\r\n| `punishment.minStrikesForRoles` | `1` | Minimum strikes before those role actions run. |\r\n| `onCooldown` | — | Callback when extra spam is deleted quietly during the punishment cooldown. |\r\n| `pause()` / `resume()` | — | Freeze enforcement without removing listeners. |\r\n\r\n```js\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"balanced\",\r\n  locale: \"es\",\r\n  ignored: { prefixes: [\"!\", \"/\", \".\"], roles: [\"STAFF_ROLE_ID\"] },\r\n  words: { enabled: true, list: [\"raid-now\"], regex: [\"n[i1]tro\\\\s+free\"] },\r\n  hop: { maxChannels: 4, windowMs: 6_000 },\r\n  ghostPing: { enabled: true, minMentions: 1, maxAgeMs: 15_000 },\r\n  links: { blockOauth: true },\r\n  files: { maxBytes: 8 * 1024 * 1024 },\r\n  roleOverrides: {\r\n    \"VIP_ROLE_ID\": { flood: { maxMessages: 10 } },\r\n  },\r\n  punishment: {\r\n    addRoleIds: [\"MUTE_ROLE_ID\"],\r\n    minStrikesForRoles: 2,\r\n  },\r\n  onCooldown(message) {\r\n    console.log(\"quiet delete\", message.id);\r\n  },\r\n});\r\n\r\nantispam.setRoleConfig(\"VIP_ROLE_ID\", { emojis: { maxEmojis: 40 } });\r\nantispam.pause();  // raid over? antispam.resume();\r\n```\r\n\r\nGhost pings need the message in cache (or `Partials.Message`) so `messageDelete` still has mentions. Kick/ban stay **off**. Role add/remove is opt-in via empty arrays.\r\n\r\n## What's new in 1.2.0\r\n\r\nMore knobs, per-channel rules, and bilingual user-facing text.\r\n\r\n| Option | What it does |\r\n| --- | --- |\r\n| `locale` | `\"en\"` or `\"es\"` for warn + log labels. Empty `warnMessage` uses the locale template. |\r\n| `ignorePermissions` | Skip members with those Discord permission names (`ManageMessages`, …). |\r\n| `setChannelConfig(guildId, channelId, patch)` | Rules for one channel (on top of guild + global). |\r\n| `channelOverrides` | Same thing in static config: `{ \"CHANNEL_ID\": { flood: { maxMessages: 10 } } }`. |\r\n| `disabledDetectors` | Turn off detectors by name without touching each block. |\r\n| `detectorOrder` | Custom detector run order. |\r\n| `flood.sameChannelOnly` / `duplicates.sameChannelOnly` | Count only the current channel. |\r\n| `links.maxLinks` / `links.scanEmbeds` | Cap URLs per message; toggle embed scanning. |\r\n| `images.skipContentTypes` / `images.maxAttachments` | Ignore GIFs, limit images per message. |\r\n| `mentions.maxRepeatsOfSame` | Same user pinged over and over in one message. |\r\n| `accounts` | Flag brand-new accounts (`minAgeDays`) or default avatars. Off by default. |\r\n| `length` | Flag walls of text. Off by default. |\r\n| `punishment.timeout.scale` | `\"none\"` \\| `\"linear\"` \\| `\"exponential\"` with `maxDurationMs`. |\r\n| `punishment.warnAsEmbed` | Warning as an embed. |\r\n\r\n```js\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"balanced\",\r\n  locale: \"es\",\r\n  ignorePermissions: [\"ManageMessages\", \"ModerateMembers\"],\r\n  flood: { sameChannelOnly: true },\r\n  links: { maxLinks: 3, scanEmbeds: true },\r\n  accounts: { enabled: true, minAgeDays: 2 },\r\n  punishment: {\r\n    warnAsEmbed: true,\r\n    timeout: { enabled: true, durationMs: 60_000, scale: \"linear\", maxDurationMs: 10 * 60_000 },\r\n  },\r\n  disabledDetectors: [\"caps\"],\r\n  channelOverrides: {\r\n    \"MEMES_CHANNEL_ID\": { images: { maxRepeats: 8 }, emojis: { maxEmojis: 30 } },\r\n  },\r\n});\r\n\r\nantispam.setChannelConfig(guildId, channelId, { flood: { maxMessages: 12 } });\r\n```\r\n\r\n## What's new in 1.1.0\r\n\r\n- **Punishment cooldown** — after a strike, Disguard waits `punishment.cooldownMs` (8s by default) before warning / timeout / kick / ban again. Extra spam in that window is deleted quietly. This stops the “4 timeouts in one flood” you saw in 1.0.0.\r\n- **Per-guild config** — `setGuildConfig(guildId, patch)`, `getGuildConfig`, `clearGuildConfig`.\r\n- **Custom detectors** — `antispam.use({ type, inspect })`.\r\n- **Stats** — `getStats()` / `resetStats()`.\r\n- **New detectors:** dangerous files (`.exe`, `.bat`, …), zalgo / stacked diacritics, newline walls.\r\n- **Links in embeds** — phishing checks also read embed title, description, fields and footer.\r\n- `isCoolingDown(guildId, userId)`.\r\n\r\n```js\r\nantispam.setGuildConfig(\"123456789\", {\r\n  flood: { maxMessages: 3 },\r\n  links: { blockInvites: true },\r\n});\r\n\r\nantispam.use({\r\n  type: \"flood\",\r\n  inspect({ message, snapshot }) {\r\n    if (message.content.includes(\"raid-now\")) {\r\n      return {\r\n        type: \"flood\",\r\n        severity: \"critical\",\r\n        userId: message.author.id,\r\n        guildId: message.guild.id,\r\n        channelId: snapshot.channelId,\r\n        messageId: snapshot.id,\r\n        reason: \"Custom raid keyword\",\r\n        details: {},\r\n        recommendedActions: [\"delete\", \"timeout\"],\r\n        timestamp: snapshot.timestamp,\r\n      };\r\n    }\r\n    return null;\r\n  },\r\n});\r\n\r\nconsole.log(antispam.getStats());\r\n```\r\n\r\n## Table of contents\r\n\r\n- [What's new in 1.5.0](#whats-new-in-150)\r\n- [What's new in 1.4.0](#whats-new-in-140)\r\n- [What's new in 1.3.0](#whats-new-in-130)\r\n- [What's new in 1.2.0](#whats-new-in-120)\r\n- [What's new in 1.1.0](#whats-new-in-110)\r\n- [Features](#features)\r\n- [Requirements](#requirements)\r\n- [Install](#install)\r\n- [Quick start](#quick-start)\r\n- [Intents and permissions](#intents-and-permissions)\r\n- [How it works](#how-it-works)\r\n- [Presets](#presets)\r\n- [Full configuration](#full-configuration)\r\n- [Callbacks](#callbacks)\r\n- [API](#api)\r\n- [Recipes](#recipes)\r\n- [Local testing](#local-testing)\r\n- [What to send when testing](#what-to-send-when-testing)\r\n- [FAQ](#faq)\r\n- [Development](#development)\r\n- [License](#license)\r\n\r\n## Features\r\n\r\n| Detector | What it catches |\r\n| --- | --- |\r\n| **flood** | Too many messages in a short sliding window |\r\n| **duplicate** | Same (or very similar) text sent repeatedly |\r\n| **link** | Shorteners, raw IPs, punycode, brand lookalikes (`dlscord`, `steamcommunnity`), phishing keyword + URL, custom blocklists |\r\n| **image** | Repeated images, stickers, and embed media |\r\n| **mention** | `@everyone`, `@here`, too many unique mentions |\r\n| **caps** | Messages that are mostly uppercase |\r\n| **emoji** | Too many emojis or stickers in one message |\r\n| **file** | Dangerous attachments (`.exe`, `.bat`, `.dll`, `.msi`, …) |\r\n| **zalgo** | Obfuscated / zalgo text (stacked combining marks) |\r\n| **newline** | Message walls made of empty lines |\r\n| **account** | Brand-new Discord accounts / default avatar / new guild members (off by default) |\r\n| **length** | Over-long messages (off by default) |\r\n| **word** | Custom blocked words and regex (off until you fill `list` / `regex`) |\r\n| **hop** | Same user jumping across too many channels |\r\n| **punctuation** | Long runs of the same character (`!!!!`, `aaaa`) |\r\n| **spoiler** | Spoiler-tag walls |\r\n| **ghost** | Deleted messages that mentioned users (off by default) |\r\n| **secret** | Accidental Discord bot tokens / webhook URLs in chat |\r\n| **echo** | Same text pasted in several channels |\r\n| **invisible** | Zero-width / bidi obfuscation |\r\n| **attach** | Attachment floods over a sliding window |\r\n| **reply** | Too many replies in a short window |\r\n| **blank** | Empty / whitespace-only messages |\r\n| **embed** | Too many embeds in one message |\r\n| **raid** | Many distinct users posting in one channel (off by default) |\r\n\r\nAlso included:\r\n\r\n- Three presets: `lenient`, `balanced`, `strict`\r\n- Ignore lists for users, roles, channels, categories, guilds, and command prefixes\r\n- Per-guild, per-channel, and per-role overlays\r\n- Strike system with decay\r\n- Optional delete / warn / timeout / kick / ban / addRole / removeRole (kick and ban are **off** by default)\r\n- `dryRun` to tune rules without punishing anyone\r\n- Edit scanning (links, mentions, and words)\r\n- Zero extra runtime dependencies (peer: `discord.js`)\r\n- In-memory store only — no database required\r\n- Full TypeScript types\r\n\r\n## Requirements\r\n\r\n- Node.js **18+**\r\n- discord.js **^14**\r\n- Privileged intents: **Message Content Intent** (and **Server Members Intent** if you use timeouts)\r\n\r\n## Install\r\n\r\n```bash\r\nnpm install @amatiscorp/disguard discord.js\r\n```\r\n\r\n## Quick start\r\n\r\n### CommonJS\r\n\r\n```js\r\nconst { Client, GatewayIntentBits, Partials } = require(\"discord.js\");\r\nconst { AntiSpam } = require(\"@amatiscorp/disguard\");\r\n\r\nconst client = new Client({\r\n  intents: [\r\n    GatewayIntentBits.Guilds,\r\n    GatewayIntentBits.GuildMessages,\r\n    GatewayIntentBits.MessageContent,\r\n    GatewayIntentBits.GuildMembers,\r\n  ],\r\n  partials: [Partials.Message],\r\n});\r\n\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"balanced\",\r\n  ignored: {\r\n    roles: [\"STAFF_ROLE_ID\"],\r\n    channels: [\"BOT_COMMANDS_CHANNEL_ID\"],\r\n  },\r\n  punishment: {\r\n    deleteMessage: true,\r\n    warnUser: true,\r\n    timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },\r\n    logChannelId: \"MOD_LOG_CHANNEL_ID\",\r\n  },\r\n});\r\n\r\nclient.once(\"ready\", () => {\r\n  antispam.start();\r\n  console.log(`Ready as ${client.user.tag}`);\r\n});\r\n\r\nclient.login(process.env.DISCORD_TOKEN);\r\n\r\nprocess.on(\"SIGINT\", () => {\r\n  antispam.stop();\r\n  client.destroy();\r\n});\r\n```\r\n\r\n### TypeScript / ESM\r\n\r\n```ts\r\nimport { Client, GatewayIntentBits, Partials } from \"discord.js\";\r\nimport { AntiSpam } from \"@amatiscorp/disguard\";\r\n\r\nconst client = new Client({\r\n  intents: [\r\n    GatewayIntentBits.Guilds,\r\n    GatewayIntentBits.GuildMessages,\r\n    GatewayIntentBits.MessageContent,\r\n    GatewayIntentBits.GuildMembers,\r\n  ],\r\n  partials: [Partials.Message],\r\n});\r\n\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"strict\",\r\n  dryRun: false,\r\n  onDetect(incident, message) {\r\n    console.log(incident.type, incident.reason, message.id);\r\n  },\r\n});\r\n\r\nclient.once(\"ready\", () => antispam.start());\r\nawait client.login(process.env.DISCORD_TOKEN);\r\n```\r\n\r\nYou can also use the factory:\r\n\r\n```js\r\nconst { createAntiSpam } = require(\"@amatiscorp/disguard\");\r\nconst antispam = createAntiSpam(client, { preset: \"balanced\" });\r\n```\r\n\r\nA full runnable example lives in [`examples/basic.js`](examples/basic.js). From this repo:\r\n\r\n```bash\r\ncopy .env.example .env\r\n# put your bot token in .env\r\nnpm run dev\r\n```\r\n\r\n## Intents and permissions\r\n\r\n| Goal | Intent / permission |\r\n| --- | --- |\r\n| Read message text | `MessageContent` + `GuildMessages` |\r\n| Timeouts | `GuildMembers` + **Moderate Members** |\r\n| Delete messages | **Manage Messages** |\r\n| Warn in the channel | **Send Messages** |\r\n| Log embeds | **Embed Links** |\r\n| Kick / ban (opt-in) | **Kick Members** / **Ban Members** |\r\n| Mute / add / remove roles (opt-in) | **Manage Roles** |\r\n\r\nThe bot will not sanction the guild owner or anyone above it in the role hierarchy. Those actions are skipped and reported in `onAction`.\r\n\r\nEnable **Message Content Intent** in the [Discord Developer Portal](https://discord.com/developers/applications) → your app → Bot → Privileged Gateway Intents.\r\n\r\n## How it works\r\n\r\n1. Ignores bots, webhooks, system messages, the owner, administrators, command prefixes, and anything in your ignore lists.\r\n2. Keeps a short **in-memory** history per user and guild (no database).\r\n3. Runs detectors in this order: files → secrets → words → flood → hop → echo → duplicates → attachments → raid → replies → blank → embeds → links → images → mentions → zalgo → newlines → punctuation → spoilers → invisible → accounts → length → caps → emojis. The first match wins.\r\n4. Adds one strike (with optional decay) and applies the configured punishment.\r\n5. Message edits re-check **links**, **mentions**, **words**, **secrets**, and **invisible**.\r\n6. Deletes can trigger **ghost** pings if `ghostPing.enabled` is true.\r\n\r\nCall `antispam.stop()` on shutdown, hot reload, or plugin unload.\r\n\r\n## Presets\r\n\r\nPass `preset` and then override only what you care about.\r\n\r\n| Preset | When to use |\r\n| --- | --- |\r\n| `lenient` | Busy community. Higher limits, no automatic timeout, shorteners allowed. |\r\n| `balanced` | Default. Reasonable coverage with few false positives. |\r\n| `strict` | Small servers or raid-prone ones. Blocks invites, lower thresholds, faster timeouts. |\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  preset: \"strict\",\r\n  flood: { maxMessages: 4 }, // overrides only this field\r\n});\r\n```\r\n\r\n| Setting | `lenient` | `balanced` | `strict` |\r\n| --- | --- | --- | --- |\r\n| Flood | 8 / 4s | 5 / 4s | 3 / 4s |\r\n| Duplicate repeats | 4 | 3 | 2 |\r\n| Duplicate similarity | 0.95 | 0.90 | 0.85 |\r\n| Image repeats | 4 | 3 | 2 |\r\n| Mentions | 10 | 6 | 3 |\r\n| Block invites | no | no | yes |\r\n| Block shorteners | no | yes | yes |\r\n| Channel hop | off | 5 / 8s | 3 / 6s |\r\n| Ghost pings | off | off | on |\r\n| Auto timeout | off | 60s from 2 strikes | 5 min from 1 strike |\r\n\r\n## Full configuration\r\n\r\nEvery option is optional. Missing fields fall back to the preset (or `balanced` if you omit `preset`).\r\n\r\n### Global\r\n\r\n| Option | Type | Default | Description |\r\n| --- | --- | --- | --- |\r\n| `enabled` | `boolean` | `true` | Master switch. |\r\n| `dryRun` | `boolean` | `false` | Detect and fire callbacks **without** deleting or punishing. Use this to tune. |\r\n| `ignoreBots` | `boolean` | `true` | Skip other bots. |\r\n| `ignoreWebhooks` | `boolean` | `true` | Skip webhooks. |\r\n| `ignoreOwner` | `boolean` | `true` | Skip the guild owner. |\r\n| `ignoreAdministrators` | `boolean` | `true` | Skip members with Administrator. |\r\n| `checkEdits` | `boolean` | `true` | Re-scan edits for links, mentions, and words. |\r\n| `checkDeletes` | `boolean` | `true` | Listen for `messageDelete` (ghost pings). |\r\n| `ignoreThreads` | `boolean` | `false` | Skip messages in threads. |\r\n| `ignoreSystem` | `boolean` | `true` | Skip Discord system messages. |\r\n| `cleanupIntervalMs` | `number` | `60000` | How often the memory store is pruned. |\r\n| `locale` | `\"en\"` \\| `\"es\"` | `\"en\"` | Language for warnings and log embeds. |\r\n| `ignorePermissions` | `string[]` | `[]` | Permission names that bypass checks. |\r\n| `disabledDetectors` | `DetectorType[]` | `[]` | Detectors to skip. |\r\n| `detectorOrder` | `DetectorType[]` | `[]` | Custom order. Empty = default. |\r\n| `channelOverrides` | `object` | `{}` | Per-channel patches keyed by channel id. |\r\n| `roleOverrides` | `object` | `{}` | Per-role patches keyed by role id. |\r\n\r\n### Ignore lists\r\n\r\n```js\r\nignored: {\r\n  users: [\"123\"],\r\n  roles: [\"456\"],\r\n  channels: [\"789\"],\r\n  categories: [\"101\"],\r\n  guilds: [\"202\"],\r\n  prefixes: [\"!\", \"/\", \".\"],\r\n}\r\n```\r\n\r\nIDs are snowflakes as strings. A staff role in `ignored.roles` bypasses every detector. `prefixes` skip command messages (`!help` is ignored if `\"!\"` is listed).\r\n\r\n### Flood\r\n\r\n```js\r\nflood: {\r\n  enabled: true,\r\n  maxMessages: 5, // the 5th message inside the window triggers\r\n  windowMs: 4000,\r\n  severity: \"medium\",\r\n}\r\n```\r\n\r\nUses a sliding window, not a fixed clock. Five different messages in 4 seconds count as flood.\r\n\r\n### Duplicates\r\n\r\nText is normalized first: lowercase, markdown stripped, URLs removed, whitespace collapsed.\r\n\r\n`similarity` is `0–1`. `1` means exact match only. `0.9` also catches `hello!!!` vs `hello!`.\r\n\r\n```js\r\nduplicates: {\r\n  enabled: true,\r\n  maxRepeats: 3,\r\n  windowMs: 12_000,\r\n  similarity: 0.9,\r\n  severity: \"medium\",\r\n}\r\n```\r\n\r\n### Links and phishing\r\n\r\n```js\r\nlinks: {\r\n  enabled: true,\r\n  blockInvites: false,\r\n  blockShorteners: true,\r\n  blockIpLinks: true,\r\n  blockPunycode: true,\r\n  blockBrandLookalikes: true,\r\n  detectPhishingKeywords: true,\r\n  allowList: [\"youtube.com\", \"github.com\"],\r\n  blockList: [\"bad-domain.test\"],\r\n  suspiciousTlds: [],          // e.g. [\"tk\", \"gq\"]\r\n  customPatterns: [],          // regex against the whole message\r\n  extraPhishingKeywords: [\"fake giveaway\"],\r\n  maxLinks: 0,\r\n  scanEmbeds: true,\r\n  blockOauth: true,\r\n  severity: \"high\",\r\n}\r\n```\r\n\r\n`allowList` wins over heuristics. Official Discord, YouTube, GitHub, Spotify, and a few others are already allowed.\r\n\r\nBuilt-in checks (each can be turned off):\r\n\r\n- URL shorteners (`bit.ly`, `t.co`, `tinyurl.com`, …)\r\n- Literal IPs (`http://1.2.3.4`)\r\n- Punycode / homographs (`xn--...`)\r\n- Brand clones of Discord, Steam, GitHub, PayPal, Roblox, and others\r\n- Scam phrases plus any link (`free nitro`, `steam gift`, `verifica tu cuenta`, …)\r\n- Discord invite links if `blockInvites` is `true`\r\n- Markdown-masked links: `[click](https://evil.test)`\r\n- Login / OAuth-looking paths on non-allowlisted hosts if `blockOauth` is `true`\r\n\r\n```js\r\n// Block foreign server invites\r\nlinks: { blockInvites: true }\r\n\r\n// Allow one shortener you actually use\r\nlinks: {\r\n  blockShorteners: true,\r\n  allowList: [\"youtube.com\", \"youtu.be\", \"bit.ly\"],\r\n}\r\n\r\n// Extra regex + extra scam words\r\nlinks: {\r\n  customPatterns: [\"steamcommunity\\\\.ru\"],\r\n  extraPhishingKeywords: [\"wallet drain\", \"airdrop now\"],\r\n}\r\n```\r\n\r\n### Images\r\n\r\n```js\r\nimages: {\r\n  enabled: true,\r\n  maxRepeats: 3,\r\n  windowMs: 20_000,\r\n  hashMode: \"meta\",        // or \"content\"\r\n  maxDownloadBytes: 2_097_152,\r\n  includeStickers: true,\r\n  includeEmbeds: true,\r\n  crossUserThreshold: 0,   // e.g. 4 = same image from 4 different users\r\n  severity: \"medium\",\r\n}\r\n```\r\n\r\n- `meta` (recommended): hash of size + MIME type + filename. No download.\r\n- `content`: downloads the file and SHA-256s it. Better when the same bytes are re-uploaded under another name. Slower and uses bandwidth.\r\n\r\n`crossUserThreshold: 4` is useful against copypasta / raid image floods.\r\n\r\n### Mentions, caps, emojis\r\n\r\n```js\r\nmentions: {\r\n  enabled: true,\r\n  maxMentions: 6,\r\n  blockEveryone: true,\r\n  blockHere: true,\r\n  severity: \"high\",\r\n}\r\n\r\ncaps: {\r\n  enabled: true,\r\n  minLength: 16,     // ignore short shouts\r\n  maxPercent: 75,    // 0–100\r\n  severity: \"low\",\r\n}\r\n\r\nemojis: {\r\n  enabled: true,\r\n  maxEmojis: 12,\r\n  maxStickers: 3,\r\n  severity: \"low\",\r\n}\r\n```\r\n\r\n### Accounts and length\r\n\r\n```js\r\naccounts: {\r\n  enabled: false,       // turn on to flag new users\r\n  minAgeDays: 3,\r\n  blockDefaultAvatar: false,\r\n  minGuildAgeDays: 0,   // 0 = off; e.g. 1 = joined this guild today\r\n  severity: \"medium\",\r\n}\r\n\r\nlength: {\r\n  enabled: false,\r\n  maxCharacters: 1000,\r\n  severity: \"low\",\r\n}\r\n```\r\n\r\n### Files, zalgo, newlines\r\n\r\n```js\r\nfiles: {\r\n  enabled: true,\r\n  blockedExtensions: [\"exe\", \"bat\", \"cmd\", \"com\", \"scr\", \"dll\", \"msi\", \"vbs\", \"ps1\", \"jar\", \"apk\"],\r\n  maxBytes: 0,           // 0 = unlimited\r\n  severity: \"critical\",\r\n}\r\n\r\nzalgo: {\r\n  enabled: true,\r\n  maxCombining: 20,\r\n  severity: \"medium\",\r\n}\r\n\r\nnewlines: {\r\n  enabled: true,\r\n  maxNewlines: 15,\r\n  severity: \"low\",\r\n}\r\n```\r\n\r\n### Words, channel hop, punctuation, spoilers\r\n\r\n```js\r\nwords: {\r\n  enabled: false,          // stays off until you add a list or regex\r\n  list: [\"raid-now\"],\r\n  regex: [\"n[i1]tro\\\\s+free\"],\r\n  ignoreCase: true,\r\n  matchWholeWord: true,\r\n  severity: \"high\",\r\n}\r\n\r\nhop: {\r\n  enabled: true,\r\n  maxChannels: 5,          // 5th unique channel in the window triggers\r\n  windowMs: 8_000,\r\n  severity: \"high\",\r\n}\r\n\r\npunctuation: {\r\n  enabled: true,\r\n  maxRepeated: 10,         // 11+ of the same character in a row\r\n  severity: \"low\",\r\n}\r\n\r\nspoilers: {\r\n  enabled: true,\r\n  maxSpoilers: 8,\r\n  severity: \"low\",\r\n}\r\n```\r\n\r\n`lenient` turns hop and punctuation off. `strict` tightens hop to 3 channels / 6s.\r\n\r\n### Ghost pings\r\n\r\nOff in `balanced` / `lenient`. On in `strict`. Needs `checkDeletes: true` (default) and the deleted message in cache (`Partials.Message` in your Client).\r\n\r\n```js\r\nghostPing: {\r\n  enabled: false,\r\n  minMentions: 1,\r\n  maxAgeMs: 15_000,        // 0 = any age\r\n  severity: \"high\",\r\n}\r\n\r\ncheckDeletes: true\r\n```\r\n\r\nDisable with `ghostPing: { enabled: false }` or `disabledDetectors: [\"ghost\"]`.\r\n\r\n### Punishment\r\n\r\nKick and ban stay **disabled** on purpose. Turn them on only if you really want that.\r\n\r\n```js\r\npunishment: {\r\n  deleteMessage: true,\r\n  warnUser: true,\r\n  dmUser: false,\r\n  warnMessage: \"{user}, your message was blocked: {reason}. Strikes: {strikes}.\",\r\n  timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },\r\n  kick: { enabled: false, minStrikes: 5 },\r\n  ban: { enabled: false, minStrikes: 8 },\r\n  escalate: true,\r\n  logChannelId: \"CHANNEL_ID_OR_NULL\",\r\n  strikeDecayMs: 15 * 60_000, // strikes expire after 15 minutes\r\n  cooldownMs: 8_000,          // no second timeout/warn during this window\r\n  deleteDuringCooldown: true, // still delete extra spam quietly\r\n  warnAsEmbed: false,\r\n  addRoleIds: [],             // e.g. [\"MUTE_ROLE_ID\"] — needs Manage Roles\r\n  removeRoleIds: [],\r\n  minStrikesForRoles: 1,\r\n}\r\n\r\n// timeout.scale: \"none\" | \"linear\" (base * strikes) | \"exponential\" (base * 2^n)\r\n// timeout.maxDurationMs caps the result (Discord max = 28 days)\r\n\r\n```\r\n\r\n`warnMessage` placeholders: `{user}` `{reason}` `{type}` `{strikes}`.\r\n\r\nWith `escalate: true`, Disguard applies **one** hard action (timeout, or kick, or ban — whichever threshold you hit). Delete and warn can still run together. `addRole` / `removeRole` run if those ID lists are non-empty and strikes ≥ `minStrikesForRoles`.\r\n\r\nIf the bot lacks a permission, or the target is higher in the hierarchy, that action is skipped and listed in `result.skipped`.\r\n\r\n## Callbacks\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  dryRun: true,\r\n  onDetect(incident, message) {\r\n    // Fired after a detector matches, before punishment.\r\n  },\r\n  onAction(result) {\r\n    // applied / skipped / dryRun / error\r\n  },\r\n  onError(error, context) {\r\n    console.error(\"[disguard]\", context, error);\r\n  },\r\n  onCooldown(message) {\r\n    // Extra spam during punishment.cooldownMs (already deleted if deleteDuringCooldown).\r\n  },\r\n});\r\n```\r\n\r\n### `Incident`\r\n\r\n```ts\r\n{\r\n  type: \"flood\" | \"duplicate\" | \"link\" | \"image\" | \"mention\" | \"caps\" | \"emoji\"\r\n    | \"file\" | \"zalgo\" | \"newline\" | \"account\" | \"length\"\r\n    | \"word\" | \"hop\" | \"punctuation\" | \"spoiler\" | \"ghost\"\r\n    | \"secret\" | \"echo\" | \"invisible\" | \"attach\" | \"reply\" | \"blank\" | \"embed\" | \"raid\",\r\n  severity: \"low\" | \"medium\" | \"high\" | \"critical\",\r\n  userId: string,\r\n  guildId: string,\r\n  channelId: string,\r\n  messageId: string,\r\n  reason: string,\r\n  details: Record<string, unknown>,\r\n  recommendedActions: Array<\"delete\" | \"warn\" | \"timeout\" | \"kick\" | \"ban\" | \"addRole\" | \"removeRole\" | \"purge\">,\r\n  timestamp: number,\r\n}\r\n```\r\n\r\n### `ActionResult`\r\n\r\n```ts\r\n{\r\n  incident: Incident,\r\n  dryRun: boolean,\r\n  applied: ActionType[],\r\n  skipped: Array<{ action: ActionType; reason: string }>,\r\n  error?: string,\r\n}\r\n```\r\n\r\n## API\r\n\r\n```ts\r\nimport { AntiSpam, createAntiSpam, resolveConfig, DEFAULT_CONFIG } from \"@amatiscorp/disguard\";\r\n\r\nconst antispam = new AntiSpam(client, options);\r\n// same as createAntiSpam(client, options)\r\n\r\nantispam.start();\r\nantispam.stop();\r\nantispam.pause();\r\nantispam.resume();\r\nantispam.isPaused();\r\nantispam.setEnabled(false);\r\nantispam.isEnabled();\r\n\r\nconst config = antispam.getConfig();\r\nantispam.setConfig({ flood: { maxMessages: 8 } }); // deep merge, other keys stay\r\n\r\nantispam.getStrikes(guildId, userId);\r\nantispam.resetUser(guildId, userId);\r\nantispam.isCoolingDown(guildId, userId);\r\n\r\nantispam.setGuildConfig(guildId, { flood: { maxMessages: 3 } });\r\nantispam.getGuildConfig(guildId);\r\nantispam.clearGuildConfig(guildId);\r\nantispam.setChannelConfig(guildId, channelId, { images: { maxRepeats: 8 } });\r\nantispam.getChannelConfig(guildId, channelId);\r\nantispam.clearChannelConfig(guildId, channelId);\r\nantispam.setRoleConfig(roleId, { flood: { maxMessages: 12 } });\r\nantispam.setUserConfig(userId, { flood: { maxMessages: 2 } });\r\nantispam.clearUserConfig(userId);\r\nantispam.clearRoleConfig(roleId);\r\n\r\nantispam.use(customDetector);\r\nantispam.getStats();\r\nantispam.resetStats();\r\n\r\n// Analyze only — no punishment, no callbacks\r\nconst incident = await antispam.analyze(message);\r\nconst edited = await antispam.analyze(message, { isEdit: true });\r\n\r\nantispam.shouldIgnore(message); // boolean\r\n```\r\n\r\nHelpers you can import for your own tools:\r\n\r\n```ts\r\nimport {\r\n  extractUrls,\r\n  normalizeText,\r\n  similarity,\r\n  DEFAULT_PHISHING_KEYWORDS,\r\n  DEFAULT_SHORTENERS,\r\n  OFFICIAL_BRANDS,\r\n  resolveConfig,\r\n  DEFAULT_CONFIG,\r\n} from \"@amatiscorp/disguard\";\r\n\r\nconst urls = extractUrls(\"see [x](https://evil.test) and discord.gg/abc\");\r\nconst near = similarity(\"hello world\", \"hello world!\");\r\nconst config = resolveConfig(\"strict\", { flood: { maxMessages: 2 } });\r\n```\r\n\r\n## Recipes\r\n\r\n**Detect only — you handle sanctions**\r\n\r\n```js\r\nconst antispam = new AntiSpam(client, {\r\n  punishment: {\r\n    deleteMessage: false,\r\n    warnUser: false,\r\n    timeout: { enabled: false, durationMs: 0, minStrikes: 99 },\r\n  },\r\n  onDetect(incident, message) {\r\n    // tickets, database, your own Automod pipeline...\r\n  },\r\n});\r\n```\r\n\r\n**Tune without touching anyone**\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  dryRun: true,\r\n  onAction(result) {\r\n    console.log(result.incident.type, result.applied);\r\n  },\r\n});\r\n```\r\n\r\n**Per-guild rules at runtime**\r\n\r\n```js\r\nclient.on(\"interactionCreate\", async (interaction) => {\r\n  if (!interaction.isChatInputCommand()) return;\r\n  if (interaction.commandName === \"antispam-strict\") {\r\n    antispam.setConfig({ preset: undefined, flood: { maxMessages: 3 } });\r\n    await interaction.reply(\"Flood limit set to 3.\");\r\n  }\r\n});\r\n```\r\n\r\n`setConfig` deep-merges. It does not re-apply a preset unless you build one with `resolveConfig` yourself:\r\n\r\n```js\r\nconst { resolveConfig } = require(\"@amatiscorp/disguard\");\r\nantispam.setConfig(resolveConfig(\"strict\", { ignored: antispam.getConfig().ignored }));\r\n```\r\n\r\n**Real image hashing**\r\n\r\n```js\r\nimages: { hashMode: \"content\", maxDownloadBytes: 1_000_000 }\r\n```\r\n\r\n**Mute role instead of timeout**\r\n\r\n```js\r\npunishment: {\r\n  timeout: { enabled: false, durationMs: 60_000, minStrikes: 99 },\r\n  addRoleIds: [\"MUTE_ROLE_ID\"],\r\n  minStrikesForRoles: 1,\r\n}\r\n```\r\n\r\nThe bot role must sit **above** the mute role. Needs **Manage Roles**.\r\n\r\n**Reset a user after a false positive**\r\n\r\n```js\r\nantispam.resetUser(guildId, userId);\r\n```\r\n\r\n## Local testing\r\n\r\nThis repository includes a demo bot.\r\n\r\n1. Create an application in the [Developer Portal](https://discord.com/developers/applications).\r\n2. Enable **Message Content Intent** and **Server Members Intent**.\r\n3. Invite the bot with Manage Messages + Moderate Members + Send Messages.\r\n4. Copy the env template and put the **bot** token (not a user token):\r\n\r\n```powershell\r\ncopy .env.example .env\r\n```\r\n\r\n```\r\nDISCORD_TOKEN=your_bot_token_here\r\n```\r\n\r\n5. Run:\r\n\r\n```powershell\r\nnpm run dev\r\n```\r\n\r\nNode does not load `.env` by itself. The example reads it via `examples/load-env.js`.\r\n\r\nTest with an account that is **not** the server owner and **not** an Administrator, or Disguard will ignore you.\r\n\r\n## What to send when testing\r\n\r\nWait a few seconds between categories so flood does not eat the next test.\r\n\r\n| Test | What to send |\r\n| --- | --- |\r\n| Flood | 5 messages in under 4 seconds |\r\n| Duplicate | The same line 3 times, or `hello` then `hello!!!` |\r\n| Shortener | `https://bit.ly/abc123` |\r\n| Raw IP | `http://1.2.3.4/login` |\r\n| Brand clone | `https://dlscord.com/nitro` or `https://steamcommunnity.com/gift` |\r\n| Phishing combo | `Free Nitro https://totally-legit.gift/claim` |\r\n| Custom blocklist | `https://malicioso.ejemplo/x` (demo `blockList`) |\r\n| Allowed | `https://youtube.com` and `https://github.com` should pass |\r\n| Images | Same photo or sticker 3 times |\r\n| Mentions | `@everyone`, `@here`, or 7 different users |\r\n| Caps | `THIS IS A MESSAGE IN ALL CAPS` (16+ letters) |\r\n| Emojis | 13+ emojis, or 4 stickers |\r\n| File | Upload a dummy `.exe` or `.bat` |\r\n| Zalgo | Text with many stacked combining marks |\r\n| Newlines | A message with more than 15 line breaks |\r\n| Channel hop | Same user posts in 5 different channels within 8 seconds |\r\n| Punctuation | `aaaaaaaaaaa` or `!!!!!!!!!!!` |\r\n| Spoilers | More than 8 `\\|\\|spoiler\\|\\|` pairs |\r\n| Blocked word | Enable `words` first, then send a listed word |\r\n| Ghost ping | Mention someone and delete the message within 15s (`ghostPing.enabled: true`) |\r\n| Secret | Paste a dummy webhook URL `https://discord.com/api/webhooks/123/abc` |\r\n| Echo | Same long sentence in 3 channels within 12s |\r\n| Invisible | A message padded with many zero-width spaces |\r\n| Attachments | 8+ files in 10 seconds |\r\n| Replies | Reply to someone 6 times in 8s |\r\n| Blank | A message with only spaces |\r\n| Embeds | A webhook-style dump with 7+ embeds |\r\n| Raid | Enable `raid` and have 8 people post in the same channel in 6s |\r\n| Edit | Send `hello`, then edit it to `Free Nitro https://bit.ly/test` |\r\n\r\nWatch the terminal: `[detect] flood|duplicate|link|image|mention|caps|emoji|file|zalgo|newline|word|hop|punctuation|spoiler|ghost|secret|echo|invisible|attach`.\r\n\r\nThe second strike applies a 1 minute timeout with the default example config. Strikes decay after 15 minutes, or call `resetUser`.\r\n\r\n## FAQ\r\n\r\n**Why does nothing happen when I spam?**  \r\nYou 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.\r\n\r\n**`TokenInvalid` on `npm run dev`?**  \r\nThe 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 `.\r\n\r\n**Can I use this without deleting messages?**  \r\nYes. Set `punishment.deleteMessage: false` and handle `onDetect` yourself.\r\n\r\n**Does it work in DMs?**  \r\nNo. Guild messages only.\r\n\r\n**Is there a database?**  \r\nNo. History lives in memory and is dropped on restart.\r\n\r\n**Will it ban people by default?**  \r\nNo. Ban and kick are opt-in.\r\n\r\n## Development\r\n\r\n```bash\r\nnpm install\r\nnpm test\r\nnpm run build\r\nnpm run dev\r\n```\r\n\r\n## License\r\n\r\nMIT\r\n\r\n---\r\n\r\n# Español\r\n\r\n## Novedades de 1.5.0\r\n\r\nRaids de canal, spam de replies, mensajes vacíos, paredes de embeds, allowlist de archivos y config por usuario. Kick/ban siguen **apagados**. `raid` va **apagado** en `balanced`.\r\n\r\n| Opción | Default | Qué hace |\r\n| --- | --- | --- |\r\n| `replies` | **on**, 6 / 8s | Demasiadas **respuestas** seguidas. |\r\n| `blank` | **on** | Mensajes vacíos o solo espacios. |\r\n| `embeds` | **on**, 6 | Demasiados embeds en un mensaje. |\r\n| `raid` | **off** (`strict`: on) | Demasiados **usuarios distintos** escribiendo en el **mismo canal**. |\r\n| `files.allowedExtensions` | `[]` | Si lo rellenas, solo esas extensiones pasan. |\r\n| `emojis.maxInWindow` | `0` (off) | Tope de emojis+stickers entre varios mensajes. |\r\n| `ignoreNsfw` | `false` | Ignora canales NSFW. |\r\n| `graceMessages` | `0` | Los primeros N mensajes solo miran file/secret/link/word. |\r\n| `setUserConfig` | — | Overlay por usuario. |\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  raid: { enabled: true, maxUsers: 8 },\r\n  files: { allowedExtensions: [\"png\", \"jpg\", \"webp\"] },\r\n  graceMessages: 2,\r\n  ignoreNsfw: true,\r\n});\r\nantispam.setUserConfig(\"ID_USER\", { flood: { maxMessages: 2 } });\r\n```\r\n\r\n## Novedades de 1.4.0\r\n\r\nEscaneo de secretos, copypasta entre canales, unicode invisible, flood de adjuntos y logs por webhook. Kick/ban siguen **apagados**.\r\n\r\n| Opción | Default | Qué hace |\r\n| --- | --- | --- |\r\n| `secrets` | **on** | Caza **tokens de bot** y **webhooks** pegados en el chat. El incidente **no** guarda el secreto. |\r\n| `echo` | **on**, 3 canales / 12s | El mismo texto en varios canales (raid). No es lo mismo que `hop`. |\r\n| `invisible` | **on**, 8 | Caracteres de ancho cero / bidi para saltarse filtros. |\r\n| `attach` | **on**, 8 / 10s | Demasiados adjuntos en una ventana, no solo `.exe`. |\r\n| `duplicates.minLength` | `8` | `ok` / `lol` ya no cuentan como duplicado. |\r\n| `mentions.maxInWindow` | `0` (off) | Tope de menciones entre varios mensajes. |\r\n| `accounts.onlyWithLinks` | `false` (`strict`: true) | Cuentas nuevas solo si el mensaje trae URL. |\r\n| `punishment.purgeCount` | `0` | Tras un hit, borra N mensajes recientes de ese user en el canal. |\r\n| `punishment.logWebhookUrl` | `null` | Logs a un webhook de Discord. |\r\n| `ignorePinned` | `false` | Ignora pineados. |\r\n| `ignoreOlderThanMs` | `0` | Ignora mensajes viejos (replay al reconectar). |\r\n| `setEnabled(false)` | — | Apaga el filtro sin `stop()`. |\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  secrets: { enabled: true, extraPatterns: [\"ghp_[A-Za-z0-9]{20,}\"] },\r\n  echo: { maxChannels: 3 },\r\n  accounts: { enabled: true, onlyWithLinks: true },\r\n  punishment: { purgeCount: 5, logWebhookUrl: process.env.DISGUARD_LOG_WEBHOOK || null },\r\n});\r\n```\r\n\r\n## Novedades de 1.3.0\r\n\r\nMás detectores, overlays por rol, mute por rol y más ignores. Todo es opcional.\r\n\r\n| Opción | Default | Qué hace |\r\n| --- | --- | --- |\r\n| `words` | **apagado**, lista vacía | Palabras y/o regex bloqueados. `ignoreCase` y `matchWholeWord` en `true`. |\r\n| `hop` | **on**, 5 canales / 8s | El mismo usuario escribe en demasiados canales (raid / hop). |\r\n| `punctuation` | **on**, 10 | Rachas de `aaaaaa` / `!!!!!!` más largas que `maxRepeated`. |\r\n| `spoilers` | **on**, 8 pares | Demasiados `\\|\\|spoiler\\|\\|` en un mensaje. |\r\n| `ghostPing` | **off** (`strict`: on) | Castiga borrar un mensaje que mencionaba gente. |\r\n| `checkDeletes` | `true` | Escucha `messageDelete`. |\r\n| `ignored.prefixes` | `[]` | Ignora mensajes que empiezan por `!`, `/`, `.`. |\r\n| `ignoreThreads` | `false` | Ignora hilos. |\r\n| `ignoreSystem` | `true` | Ignora mensajes de sistema. |\r\n| `roleOverrides` / `setRoleConfig` | `{}` | Config por rol (antes de la de canal). |\r\n| `links.blockOauth` | `true` | URLs de login/OAuth fuera de la allowList. `discord.com` sigue permitido. |\r\n| `files.maxBytes` | `0` | Tamaño máximo de adjunto. `0` = sin límite. |\r\n| `accounts.minGuildAgeDays` | `0` | Miembros demasiado nuevos en **este** servidor. `0` = off. |\r\n| `punishment.addRoleIds` / `removeRoleIds` | `[]` | Añadir/quitar roles al castigar (mute). Hace falta **Manage Roles**. |\r\n| `onCooldown` | — | Callback cuando el cooldown borra spam extra en silencio. |\r\n| `pause()` / `resume()` | — | Congela el antispam sin quitar listeners. |\r\n\r\n```js\r\nconst antispam = new AntiSpam(client, {\r\n  locale: \"es\",\r\n  ignored: { prefixes: [\"!\", \"/\", \".\"], roles: [\"ID_STAFF\"] },\r\n  words: { enabled: true, list: [\"raid-now\"] },\r\n  hop: { maxChannels: 4, windowMs: 6_000 },\r\n  ghostPing: { enabled: true, maxAgeMs: 15_000 },\r\n  punishment: { addRoleIds: [\"ID_MUTE\"], minStrikesForRoles: 2 },\r\n});\r\n\r\nantispam.setRoleConfig(\"ID_VIP\", { flood: { maxMessages: 10 } });\r\nantispam.pause();\r\n```\r\n\r\nKick y ban siguen **apagados**. Ghost ping necesita el mensaje en caché (`Partials.Message`).\r\n\r\n## Novedades de 1.2.0\r\n\r\nMás opciones, reglas por canal y textos al usuario en inglés o español.\r\n\r\n| Opción | Qué hace |\r\n| --- | --- |\r\n| `locale` | `\"en\"` o `\"es\"` para avisos y logs. Si `warnMessage` está vacío, usa la plantilla del idioma. |\r\n| `ignorePermissions` | Ignora quien tenga esos permisos (`ManageMessages`, …). |\r\n| `setChannelConfig` | Config de un canal (encima de guild + global). |\r\n| `channelOverrides` | Lo mismo en la config estática. |\r\n| `disabledDetectors` | Apaga detectores por nombre. |\r\n| `detectorOrder` | Orden de ejecución. |\r\n| `flood.sameChannelOnly` / `duplicates.sameChannelOnly` | Solo cuenta el canal actual. |\r\n| `links.maxLinks` / `links.scanEmbeds` | Tope de URLs; escanear embeds. |\r\n| `images.skipContentTypes` / `images.maxAttachments` | Ignorar gifs, limitar adjuntos. |\r\n| `mentions.maxRepeatsOfSame` | El mismo ping repetido. |\r\n| `accounts` | Cuentas nuevas (`minAgeDays`) o avatar por defecto. Apagado por defecto. |\r\n| `length` | Mensajes kilométricos. Apagado por defecto. |\r\n| `punishment.timeout.scale` | `\"none\"` \\| `\"linear\"` \\| `\"exponential\"`. |\r\n| `punishment.warnAsEmbed` | Aviso en embed. |\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  locale: \"es\",\r\n  ignorePermissions: [\"ManageMessages\"],\r\n  flood: { sameChannelOnly: true },\r\n  links: { maxLinks: 3 },\r\n  accounts: { enabled: true, minAgeDays: 2 },\r\n  punishment: {\r\n    warnAsEmbed: true,\r\n    timeout: { durationMs: 60_000, scale: \"linear\", maxDurationMs: 600_000 },\r\n  },\r\n  channelOverrides: {\r\n    \"ID_CANAL_MEMES\": { emojis: { maxEmojis: 30 } },\r\n  },\r\n});\r\n```\r\n\r\n## Novedades de 1.1.0\r\n\r\n- **Cooldown de castigo** — tras un strike espera `punishment.cooldownMs` (8s por defecto) antes de volver a avisar o timeout. El spam extra de esa ventana se borra en silencio. Así no salen 4 timeouts en el mismo flood.\r\n- **Config por servidor** — `setGuildConfig`, `getGuildConfig`, `clearGuildConfig`.\r\n- **Detectores propios** — `antispam.use({ type, inspect })`.\r\n- **Estadísticas** — `getStats()` / `resetStats()`.\r\n- **Nuevos detectores:** archivos peligrosos, zalgo, paredes de saltos de línea.\r\n- **Enlaces en embeds** — también se revisan título, descripción, fields y footer.\r\n- `isCoolingDown(guildId, userId)`.\r\n\r\n## Tabla de contenidos\r\n\r\n- [Novedades de 1.5.0](#novedades-de-150)\r\n- [Novedades de 1.4.0](#novedades-de-140)\r\n- [Novedades de 1.3.0](#novedades-de-130)\r\n- [Novedades de 1.2.0](#novedades-de-120)\r\n- [Novedades de 1.1.0](#novedades-de-110)\r\n- [Qué es](#qué-es)\r\n- [Requisitos](#requisitos)\r\n- [Instalación](#instalación)\r\n- [Inicio rápido](#inicio-rápido)\r\n- [Intents y permisos](#intents-y-permisos)\r\n- [Cómo funciona](#cómo-funciona)\r\n- [Presets](#presets-1)\r\n- [Configuración completa](#configuración-completa)\r\n- [Callbacks](#callbacks-1)\r\n- [API](#api-1)\r\n- [Recetas](#recetas)\r\n- [Probar en local](#probar-en-local)\r\n- [Qué enviar para testear](#qué-enviar-para-testear)\r\n- [Preguntas frecuentes](#preguntas-frecuentes)\r\n- [Desarrollo](#desarrollo-1)\r\n- [Licencia](#licencia)\r\n\r\n## Qué es\r\n\r\n**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.\r\n\r\nDetecta flood, salto de canales, palabras bloqueadas, phishing / enlaces no deseados (también en embeds), imágenes repetidas, spam de menciones, ghost pings, mayúsculas, emojis, archivos peligrosos, zalgo y paredes de líneas.\r\n\r\n## Requisitos\r\n\r\n- Node.js **18+**\r\n- discord.js **^14**\r\n- Intent de **contenido de mensaje** (y **miembros del servidor** si usas timeouts)\r\n\r\n## Instalación\r\n\r\n```bash\r\nnpm install @amatiscorp/disguard discord.js\r\n```\r\n\r\n## Inicio rápido\r\n\r\n### CommonJS\r\n\r\n```js\r\nconst { Client, GatewayIntentBits, Partials } = require(\"discord.js\");\r\nconst { AntiSpam } = require(\"@amatiscorp/disguard\");\r\n\r\nconst client = new Client({\r\n  intents: [\r\n    GatewayIntentBits.Guilds,\r\n    GatewayIntentBits.GuildMessages,\r\n    GatewayIntentBits.MessageContent,\r\n    GatewayIntentBits.GuildMembers,\r\n  ],\r\n  partials: [Partials.Message],\r\n});\r\n\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"balanced\",\r\n  ignored: {\r\n    roles: [\"ID_ROL_STAFF\"],\r\n    channels: [\"ID_CANAL_BOTS\"],\r\n  },\r\n  punishment: {\r\n    deleteMessage: true,\r\n    warnUser: true,\r\n    timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },\r\n    logChannelId: \"ID_CANAL_LOGS\",\r\n  },\r\n});\r\n\r\nclient.once(\"ready\", () => {\r\n  antispam.start();\r\n  console.log(`Listo como ${client.user.tag}`);\r\n});\r\n\r\nclient.login(process.env.DISCORD_TOKEN);\r\n```\r\n\r\n### TypeScript\r\n\r\n```ts\r\nimport { AntiSpam } from \"@amatiscorp/disguard\";\r\n\r\nconst antispam = new AntiSpam(client, {\r\n  preset: \"strict\",\r\n  onDetect(incident) {\r\n    console.log(incident.type, incident.reason);\r\n  },\r\n});\r\n\r\nantispam.start();\r\n```\r\n\r\nEjemplo completo en [`examples/basic.js`](examples/basic.js):\r\n\r\n```powershell\r\ncopy .env.example .env\r\nnpm run dev\r\n```\r\n\r\n## Intents y permisos\r\n\r\n| Qué quieres | Intent / permiso |\r\n| --- | --- |\r\n| Leer el texto | `MessageContent` + `GuildMessages` |\r\n| Timeouts | `GuildMembers` + **Moderate Members** |\r\n| Borrar mensajes | **Manage Messages** |\r\n| Avisar en el canal | **Send Messages** |\r\n| Embed de logs | **Embed Links** |\r\n| Kick / ban (opt-in) | **Kick Members** / **Ban Members** |\r\n| Mute / add / remove roles (opt-in) | **Manage Roles** |\r\n\r\nNo puede sancionar al dueño del servidor ni a quien esté por encima del bot en la jerarquía.\r\n\r\nActiva **Message Content Intent** en el [Portal de Discord](https://discord.com/developers/applications) → tu app → Bot.\r\n\r\n## Cómo funciona\r\n\r\n1. Ignora bots, webhooks, mensajes de sistema, el dueño, administradores, prefijos de comando y tus listas de ignore.\r\n2. Guarda en **memoria** un historial corto por usuario y servidor. Sin base de datos.\r\n3. Pasa el mensaje por: archivos → secretos → palabras → flood → hop → echo → duplicados → adjuntos → raid → replies → vacíos → embeds → enlaces → imágenes → menciones → zalgo → líneas → puntuación → spoilers → invisibles → cuentas → longitud → caps → emojis. El primero que dispare gana.\r\n4. Suma un strike (con caducidad) y aplica el castigo configurado.\r\n5. Las ediciones revisan **enlaces**, **menciones** y **palabras**.\r\n6. Los borrados pueden disparar **ghost** ping si `ghostPing.enabled` es true.\r\n\r\nLlama `antispam.stop()` al apagar el proceso.\r\n\r\n## Presets\r\n\r\n| Preset | Uso |\r\n| --- | --- |\r\n| `lenient` | Comunidad activa. Más holgura, sin timeout automático. |\r\n| `balanced` | Por defecto. Equilibrio entre cobertura y falsos positivos. |\r\n| `strict` | Servidores pequeños o con raids. Bloquea invitaciones y aprieta umbrales. |\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  preset: \"strict\",\r\n  flood: { maxMessages: 4 }, // solo pisa esto\r\n});\r\n```\r\n\r\n## Configuración completa\r\n\r\nTodo es opcional. Lo que no pongas usa el preset.\r\n\r\n### Global\r\n\r\n| Opción | Tipo | Default | Qué hace |\r\n| --- | --- | --- | --- |\r\n| `enabled` | `boolean` | `true` | Interruptor maestro. |\r\n| `dryRun` | `boolean` | `false` | Detecta y dispara callbacks **sin** borrar ni sancionar. |\r\n| `ignoreBots` | `boolean` | `true` | Ignora otros bots. |\r\n| `ignoreWebhooks` | `boolean` | `true` | Ignora webhooks. |\r\n| `ignoreOwner` | `boolean` | `true` | Ignora al dueño. |\r\n| `ignoreAdministrators` | `boolean` | `true` | Ignora quien tenga Administrator. |\r\n| `checkEdits` | `boolean` | `true` | Revisa ediciones (enlaces, menciones y palabras). |\r\n| `checkDeletes` | `boolean` | `true` | Escucha `messageDelete` (ghost pings). |\r\n| `ignoreThreads` | `boolean` | `false` | Ignora hilos. |\r\n| `ignoreSystem` | `boolean` | `true` | Ignora mensajes de sistema. |\r\n| `cleanupIntervalMs` | `number` | `60000` | Limpieza de memoria. |\r\n| `roleOverrides` | `object` | `{}` | Parches por rol. |\r\n\r\n### Listas de ignore\r\n\r\n```js\r\nignored: {\r\n  users: [\"id\"],\r\n  roles: [\"id\"],\r\n  channels: [\"id\"],\r\n  categories: [\"id\"],\r\n  guilds: [\"id\"],\r\n  prefixes: [\"!\", \"/\", \".\"],\r\n}\r\n```\r\n\r\n### Flood\r\n\r\n```js\r\nflood: {\r\n  enabled: true,\r\n  maxMessages: 5, // el 5º mensaje dentro de la ventana dispara\r\n  windowMs: 4000,\r\n  severity: \"medium\",\r\n}\r\n```\r\n\r\n### Duplicados\r\n\r\nEl texto se normaliza (minúsculas, sin markdown, sin URLs). `similarity` de `0.9` caza `hola!!!` ≈ `hola!`. `1` exige igualdad exacta.\r\n\r\n```js\r\nduplicates: {\r\n  enabled: true,\r\n  maxRepeats: 3,\r\n  windowMs: 12_000,\r\n  similarity: 0.9,\r\n  severity: \"medium\",\r\n}\r\n```\r\n\r\n### Enlaces y phishing\r\n\r\n```js\r\nlinks: {\r\n  enabled: true,\r\n  blockInvites: false,\r\n  blockShorteners: true,\r\n  blockIpLinks: true,\r\n  blockPunycode: true,\r\n  blockBrandLookalikes: true,\r\n  detectPhishingKeywords: true,\r\n  allowList: [\"youtube.com\", \"github.com\"],\r\n  blockList: [\"dominio-malo.test\"],\r\n  suspiciousTlds: [],\r\n  customPatterns: [\"steamcommunity\\\\.ru\"],\r\n  extraPhishingKeywords: [\"sorteo falso\"],\r\n  maxLinks: 0,\r\n  scanEmbeds: true,\r\n  blockOauth: true,\r\n  severity: \"high\",\r\n}\r\n```\r\n\r\nLa `allowList` gana a las heurísticas. `discord.com` y `youtube.com` ya vienen permitidos.\r\n\r\nHeurísticas incluidas (todas se pueden apagar): acortadores, IPs, punycode, clones de marcas, palabras de estafa + enlace, invitaciones si `blockInvites` es `true`, enlaces enmascarados `[texto](url)`, y rutas de login/OAuth si `blockOauth` es `true`.\r\n\r\n### Imágenes\r\n\r\n```js\r\nimages: {\r\n  enabled: true,\r\n  maxRepeats: 3,\r\n  windowMs: 20_000,\r\n  hashMode: \"meta\",          // o \"content\"\r\n  maxDownloadBytes: 2_097_152,\r\n  includeStickers: true,\r\n  includeEmbeds: true,\r\n  crossUserThreshold: 0,     // p.ej. 4 = misma imagen por 4 usuarios\r\n  severity: \"medium\",\r\n}\r\n```\r\n\r\n- `meta` (recomendado): tamaño + tipo + nombre. No descarga nada.\r\n- `content`: descarga el archivo y hace SHA-256. Más preciso y más lento.\r\n\r\n### Menciones, mayúsculas, emojis\r\n\r\n```js\r\nmentions: { enabled: true, maxMentions: 6, blockEveryone: true, blockHere: true, severity: \"high\" },\r\ncaps: { enabled: true, minLength: 16, maxPercent: 75, severity: \"low\" },\r\nemojis: { enabled: true, maxEmojis: 12, maxStickers: 3, severity: \"low\" },\r\n```\r\n\r\n### Archivos, zalgo, saltos de línea\r\n\r\n```js\r\nfiles: { enabled: true, blockedExtensions: [\"exe\", \"bat\", \"cmd\", \"dll\", \"msi\"], maxBytes: 0, severity: \"critical\" },\r\nzalgo: { enabled: true, maxCombining: 20, severity: \"medium\" },\r\nnewlines: { enabled: true, maxNewlines: 15, severity: \"low\" },\r\n```\r\n\r\n### Palabras, hop, puntuación, spoilers, ghost ping\r\n\r\n```js\r\nwords: { enabled: false, list: [\"raid-now\"], regex: [], ignoreCase: true, matchWholeWord: true, severity: \"high\" },\r\nhop: { enabled: true, maxChannels: 5, windowMs: 8_000, severity: \"high\" },\r\npunctuation: { enabled: true, maxRepeated: 10, severity: \"low\" },\r\nspoilers: { enabled: true, maxSpoilers: 8, severity: \"low\" },\r\nghostPing: { enabled: false, minMentions: 1, maxAgeMs: 15_000, severity: \"high\" },\r\n```\r\n\r\n`lenient` apaga hop y puntuación. `strict` enciende ghost ping y aprieta hop a 3 canales / 6s.\r\n\r\n### Castigos\r\n\r\nKick y ban van **apagados**. Actívalos solo si lo tienes claro.\r\n\r\n```js\r\npunishment: {\r\n  deleteMessage: true,\r\n  warnUser: true,\r\n  dmUser: false,\r\n  warnMessage: \"{user}, tu mensaje se ha bloqueado: {reason}. Strikes: {strikes}.\",\r\n  timeout: { enabled: true, durationMs: 60_000, minStrikes: 2 },\r\n  kick: { enabled: false, minStrikes: 5 },\r\n  ban: { enabled: false, minStrikes: 8 },\r\n  escalate: true,\r\n  logChannelId: \"ID_O_NULL\",\r\n  strikeDecayMs: 15 * 60_000,\r\n  cooldownMs: 8_000,\r\n  deleteDuringCooldown: true,\r\n  addRoleIds: [],\r\n  removeRoleIds: [],\r\n  minStrikesForRoles: 1,\r\n}\r\n```\r\n\r\nPlaceholders: `{user}` `{reason}` `{type}` `{strikes}`.\r\n\r\nCon `escalate: true` se aplica **un** castigo fuerte (timeout, kick o ban). Aviso y borrado se pueden sumar. `addRole` / `removeRole` corren si las listas no están vacías.\r\n\r\n## Callbacks\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  dryRun: true,\r\n  onDetect(incident, message) {},\r\n  onAction(result) {},\r\n  onError(error, context) {\r\n    console.error(context, error);\r\n  },\r\n  onCooldown(message) {},\r\n});\r\n```\r\n\r\n`Incident` y `ActionResult` tienen la misma forma que en la sección en inglés.\r\n\r\n## API\r\n\r\n```ts\r\nconst antispam = new AntiSpam(client, options);\r\n\r\nantispam.start();\r\nantispam.stop();\r\nantispam.pause();\r\nantispam.resume();\r\nantispam.setEnabled(false);\r\nantispam.getConfig();\r\nantispam.setConfig({ flood: { maxMessages: 8 } });\r\nantispam.getStrikes(guildId, userId);\r\nantispam.resetUser(guildId, userId);\r\nantispam.setGuildConfig(guildId, { flood: { maxMessages: 3 } });\r\nantispam.setChannelConfig(guildId, channelId, { flood: { maxMessages: 12 } });\r\nantispam.setRoleConfig(roleId, { emojis: { maxEmojis: 40 } });\r\nantispam.setUserConfig(userId, { flood: { maxMessages: 2 } });\r\nantispam.getStats();\r\n\r\nconst incident = await antispam.analyze(message);\r\nconst editado = await antispam.analyze(message, { isEdit: true });\r\n```\r\n\r\nTambién: `createAntiSpam`, `resolveConfig`, `DEFAULT_CONFIG`, `extractUrls`, `normalizeText`, `similarity`.\r\n\r\n## Recetas\r\n\r\n**Solo detectar**\r\n\r\n```js\r\nnew AntiSpam(client, {\r\n  punishment: {\r\n    deleteMessage: false,\r\n    warnUser: false,\r\n    timeout: { enabled: false, durationMs: 0, minStrikes: 99 },\r\n  },\r\n  onDetect(incident, message) {\r\n    // tu lógica\r\n  },\r\n});\r\n```\r\n\r\n**Probar sin tocar a nadie:** `dryRun: true`.\r\n\r\n**Hash real de imágenes:** `images: { hashMode: \"content\" }`.\r\n\r\n**Quitar strikes:** `antispam.resetUser(guildId, userId)`.\r\n\r\n**Mute por rol:** `punishment: { addRoleIds: [\"ID_MUTE\"], timeout: { enabled: false, durationMs: 0, minStrikes: 99 } }`. El rol del bot debe estar por encima del mute.\r\n\r\n**Cambiar a strict en caliente**\r\n\r\n```js\r\nconst { resolveConfig } = require(\"@amatiscorp/disguard\");\r\nantispam.setConfig(resolveConfig(\"strict\", { ignored: antispam.getConfig().ignored }));\r\n```\r\n\r\n## Probar en local\r\n\r\n1. Crea el bot en el portal y activa Message Content + Server Members.\r\n2. Invítalo con Manage Messages, Moderate Members y Send Messages.\r\n3. Copia `.env.example` a `.env` y pon el token del **bot**.\r\n4. `npm run dev`.\r\n\r\nNode no carga el `.env` solo; el ejemplo sí lo lee. Prueba con una cuenta que **no** sea owner ni admin.\r\n\r\n## Qué enviar para testear\r\n\r\nEspera unos segundos entre categorías.\r\n\r\n| Test | Qué mandar |\r\n| --- | --- |\r\n| Flood | 5 mensajes en menos de 4 segundos |\r\n| Duplicado | La misma línea 3 veces, o `hola` y `hola!!!` |\r\n| Acortador | `https://bit.ly/abc123` |\r\n| IP | `http://1.2.3.4/login` |\r\n| Clon | `https://dlscord.com/nitro` |\r\n| Phishing | `Free Nitro https://totally-legit.gift/claim` |\r\n| Blocklist | `https://malicioso.ejemplo/x` |\r\n| Permitidos | `https://youtube.com` y `https://github.com` no deben saltar |\r\n| Imágenes | La misma foto o sticker 3 veces |\r\n| Menciones | `@everyone`, `@here`, o 7 usuarios |\r\n| Caps | `ESTO ES UN MENSAJE TODO EN MAYUSCULAS` |\r\n| Emojis | Más de 12 emojis, o 4 stickers |\r\n| Archivo | Sube un `.exe` o `.bat` (aunque sea de mentira) |\r\n| Zalgo | Texto con muchos diacríticos apilados |\r\n| Líneas | Un mensaje con más de 15 enters |\r\n| Hop | El mismo usuario en 5 canales distintos en 8s |\r\n| Puntuación | `aaaaaaaaaaa` o `!!!!!!!!!!!` |\r\n| Spoilers | Más de 8 pares `\\|\\|spoiler\\|\\|` |\r\n| Palabra | Activa `words` y manda una de la lista |\r\n| Ghost ping | Menciona a alguien y borra el mensaje en menos de 15s (`ghostPing.enabled: true`) |\r\n| Secreto | Pega un webhook de mentira `https://discord.com/api/webhooks/123/abc` |\r\n| Echo | La misma frase larga en 3 canales en 12s |\r\n| Invisible | Un mensaje con muchos espacios de ancho cero |\r\n| Adjuntos | 8+ archivos en 10 segundos |\r\n| Replies | Responder 6 veces en 8s |\r\n| Vacío | Un mensaje solo con espacios |\r\n| Embeds | 7+ embeds en un mensaje |\r\n| Raid | Activa `raid` y que 8 personas escriban en el mismo canal |\r\n| Edición | Manda `hola` y edítalo a `Free Nitro https://bit.ly/test` |\r\n\r\nEn la terminal: `[detect] flood|duplicate|link|image|mention|caps|emoji|file|zalgo|newline|word|hop|punctuation|spoiler|ghost|secret|echo|invisible|attach`.\r\n\r\nAl segundo strike el ejemplo mete timeout de 1 minuto. Los strikes caducan a los 15 minutos, o usa `resetUser`.\r\n\r\n## Preguntas frecuentes\r\n\r\n**No pasa nada cuando spameo.**  \r\nSeguramente eres el dueño o tienes Administrator. Usa otra cuenta, o pon `ignoreOwner: false` / `ignoreAdministrators: false` mientras pruebas.\r\n\r\n**`TokenInvalid`.**  \r\nEl `.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 `.\r\n\r\n**¿Funciona en DMs?**  \r\nNo. Solo servidores.\r\n\r\n**¿Hay base de datos?**  \r\nNo. Todo vive en memoria y se pierde al reiniciar.\r\n\r\n**¿Banea por defecto?**  \r\nNo. Kick y ban son opt-in.\r\n\r\n## Desarrollo\r\n\r\n```bash\r\nnpm install\r\nnpm test\r\nnpm run build\r\nnpm run dev\r\n```\r\n\r\n## Licencia\r\n\r\nMIT\r\n","readmeFilename":"README.md"}