discord-guardian 1.0.1
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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/package.json +45 -0
- package/src/core/EventBus.js +24 -0
- package/src/core/Guardian.js +104 -0
- package/src/index.js +12 -0
- package/src/modules/antiBan.js +69 -0
- package/src/modules/antiBotAdd.js +71 -0
- package/src/modules/antiChannelDelete.js +70 -0
- package/src/modules/antiKick.js +70 -0
- package/src/utils/idParser.js +16 -0
- package/src/utils/interactionHandler.js +74 -0
- package/src/utils/punishments.js +31 -0
- package/src/utils/rateLimiter.js +47 -0
- package/src/utils/safeReply.js +15 -0
- package/src/utils/threatMessage.js +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 discord-guardian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# discord-guardian
|
|
2
|
+
|
|
3
|
+
Test your Discord bot against simulated nukes without breaking anything.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/discord-guardian)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { guardian } from "discord-guardian"
|
|
12
|
+
|
|
13
|
+
guardian(client).auto()
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Simulation
|
|
17
|
+
|
|
18
|
+
Run detection logic and UI without real actions.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// simulate bans to trigger detection
|
|
22
|
+
guardian(client).simulateAttack({ type: "ban", count: 3 })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Output
|
|
26
|
+
```text
|
|
27
|
+
[guardian] simulation start (antiBan)
|
|
28
|
+
[guardian] step 1/3
|
|
29
|
+
[guardian] step 2/3
|
|
30
|
+
[guardian] antiBan -> count=3/3
|
|
31
|
+
[guardian] antiBan -> threshold reached
|
|
32
|
+
[guardian] simulation complete
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Logging
|
|
36
|
+
|
|
37
|
+
Logs threshold progress and triggers.
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
[guardian] antiKick -> count=2/3
|
|
41
|
+
[guardian] antiKick -> threshold reached
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- simulate attacks without real actions
|
|
47
|
+
- zero config setup via `.auto()`
|
|
48
|
+
- minimal, structured logs
|
|
49
|
+
- clear simulation indicators in Discord
|
|
50
|
+
|
|
51
|
+
## Modules
|
|
52
|
+
|
|
53
|
+
| module | trigger |
|
|
54
|
+
|--------|---------|
|
|
55
|
+
| `antiBan` | mass member bans |
|
|
56
|
+
| `antiKick` | mass member kicks |
|
|
57
|
+
| `antiBotAdd` | unauthorized bot adds |
|
|
58
|
+
| `antiChannelDelete` | mass channel removals |
|
|
59
|
+
|
|
60
|
+
## Permissions
|
|
61
|
+
|
|
62
|
+
- View Audit Log
|
|
63
|
+
- Ban Members
|
|
64
|
+
- Kick Members
|
|
65
|
+
- Manage Roles
|
|
66
|
+
- Send Messages
|
|
67
|
+
|
|
68
|
+
[MIT](./LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "discord-guardian",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Test your Discord bot against simulated nukes and protect it with minimal setup.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node test/smoke.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"discord",
|
|
15
|
+
"discordjs",
|
|
16
|
+
"discord-bot",
|
|
17
|
+
"antinuke",
|
|
18
|
+
"raid-protection",
|
|
19
|
+
"security",
|
|
20
|
+
"testing",
|
|
21
|
+
"sdk",
|
|
22
|
+
"nodejs"
|
|
23
|
+
],
|
|
24
|
+
"author": "demondev_",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/demondevx/discord-guardian.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/demondevx/discord-guardian/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/demondevx/discord-guardian#readme",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"discord.js": "^14.26.2"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src/**/*",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { EventEmitter } from "events"
|
|
2
|
+
|
|
3
|
+
export class EventBus extends EventEmitter {
|
|
4
|
+
/**
|
|
5
|
+
* Emit an event.
|
|
6
|
+
*/
|
|
7
|
+
emit(event, ...args) {
|
|
8
|
+
return super.emit(event, ...args)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Listen for an event.
|
|
13
|
+
*/
|
|
14
|
+
on(event, listener) {
|
|
15
|
+
return super.on(event, listener)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Remove a listener.
|
|
20
|
+
*/
|
|
21
|
+
off(event, listener) {
|
|
22
|
+
return super.removeListener(event, listener)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { EventBus } from "./EventBus.js"
|
|
2
|
+
import { setupInteractionHandler } from "../utils/interactionHandler.js"
|
|
3
|
+
|
|
4
|
+
const MODULE_REGISTRY = {
|
|
5
|
+
antiBan: () => import("../modules/antiBan.js").then((m) => m.antiBan),
|
|
6
|
+
antiKick: () => import("../modules/antiKick.js").then((m) => m.antiKick),
|
|
7
|
+
antiBotAdd: () => import("../modules/antiBotAdd.js").then((m) => m.antiBotAdd),
|
|
8
|
+
antiChannelDelete: () => import("../modules/antiChannelDelete.js").then((m) => m.antiChannelDelete),
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Guardian {
|
|
12
|
+
/**
|
|
13
|
+
* @param {import("discord.js").Client} client
|
|
14
|
+
*/
|
|
15
|
+
constructor(client) {
|
|
16
|
+
if (!client) throw new Error("[guardian] client is required")
|
|
17
|
+
|
|
18
|
+
this.client = client
|
|
19
|
+
this.eventBus = new EventBus()
|
|
20
|
+
this._activeModules = new Map()
|
|
21
|
+
|
|
22
|
+
setupInteractionHandler(this.client, this.eventBus)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initialize with default protections.
|
|
27
|
+
*/
|
|
28
|
+
auto() {
|
|
29
|
+
return this.protectAll()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Enable all built-in modules.
|
|
34
|
+
*/
|
|
35
|
+
protectAll() {
|
|
36
|
+
for (const name of Object.keys(MODULE_REGISTRY)) {
|
|
37
|
+
this.use(name)
|
|
38
|
+
}
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enable a specific protection module.
|
|
44
|
+
* @param {string} moduleName
|
|
45
|
+
* @param {object} [config]
|
|
46
|
+
*/
|
|
47
|
+
use(moduleName, config = {}) {
|
|
48
|
+
if (!MODULE_REGISTRY[moduleName]) return this
|
|
49
|
+
if (this._activeModules.has(moduleName)) return this
|
|
50
|
+
|
|
51
|
+
this._activeModules.set(moduleName, config)
|
|
52
|
+
|
|
53
|
+
MODULE_REGISTRY[moduleName]().then((initFn) => {
|
|
54
|
+
initFn(this.client, this.eventBus, config)
|
|
55
|
+
}).catch((err) => {
|
|
56
|
+
console.error(`[guardian] failed to load ${moduleName}:`, err)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return this
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Simulate an attack for testing detection logic.
|
|
64
|
+
* @param {object} options
|
|
65
|
+
*/
|
|
66
|
+
async simulateAttack({ type, count = 3 }) {
|
|
67
|
+
const moduleName = `anti${String(type).charAt(0).toUpperCase() + String(type).slice(1)}`
|
|
68
|
+
|
|
69
|
+
this._log(`simulation start (${moduleName})`)
|
|
70
|
+
|
|
71
|
+
for (let i = 1; i <= count; i++) {
|
|
72
|
+
await new Promise(r => setTimeout(r, 100))
|
|
73
|
+
this._log(`step ${i}/${count}`)
|
|
74
|
+
|
|
75
|
+
this.eventBus.emit(`simulate:${moduleName}`, {
|
|
76
|
+
executorId: "000000000000000000",
|
|
77
|
+
guild: {
|
|
78
|
+
id: "000000000000000000",
|
|
79
|
+
name: "simulation server",
|
|
80
|
+
members: this.client.users,
|
|
81
|
+
channels: { fetch: async () => null },
|
|
82
|
+
fetchAuditLogs: async () => ({ entries: new Map() })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._log("simulation complete")
|
|
88
|
+
return this
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Listen for guardian events.
|
|
93
|
+
* @param {string} event
|
|
94
|
+
* @param {Function} callback
|
|
95
|
+
*/
|
|
96
|
+
on(event, callback) {
|
|
97
|
+
this.eventBus.on(event, callback)
|
|
98
|
+
return this
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_log(message) {
|
|
102
|
+
console.log(`[guardian] ${message}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Guardian } from "./core/Guardian.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Initialize a new Guardian instance.
|
|
5
|
+
* @param {import("discord.js").Client} client
|
|
6
|
+
*/
|
|
7
|
+
export function guardian(client) {
|
|
8
|
+
return new Guardian(client)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { Guardian }
|
|
12
|
+
export { EventBus } from "./core/EventBus.js"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AuditLogEvent } from "discord.js"
|
|
2
|
+
import { RateLimiter } from "../utils/rateLimiter.js"
|
|
3
|
+
import { executePunishment } from "../utils/punishments.js"
|
|
4
|
+
import { createThreatMessage } from "../utils/threatMessage.js"
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
maxBans: 3,
|
|
8
|
+
windowMs: 15_000,
|
|
9
|
+
punishment: "ban",
|
|
10
|
+
logChannelId: null,
|
|
11
|
+
whitelist: [],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Anti-ban protection module.
|
|
16
|
+
* @param {import("discord.js").Client} client
|
|
17
|
+
* @param {import("../core/EventBus.js").EventBus} eventBus
|
|
18
|
+
* @param {object} [config]
|
|
19
|
+
*/
|
|
20
|
+
export function antiBan(client, eventBus, config = {}) {
|
|
21
|
+
const opts = { ...DEFAULTS, ...config }
|
|
22
|
+
const limiter = new RateLimiter(opts.maxBans, opts.windowMs)
|
|
23
|
+
|
|
24
|
+
const handle = async (executorId, guild, isSimulated = false) => {
|
|
25
|
+
if (opts.whitelist.includes(executorId)) return
|
|
26
|
+
|
|
27
|
+
if (!limiter.hit(executorId) && !isSimulated) return
|
|
28
|
+
|
|
29
|
+
if (limiter.count(executorId) >= opts.maxBans || isSimulated) {
|
|
30
|
+
if (limiter.count(executorId) === opts.maxBans || isSimulated) {
|
|
31
|
+
console.log(`[guardian] antiBan -> count=${limiter.count(executorId)}/${opts.maxBans}`)
|
|
32
|
+
console.log(`[guardian] antiBan -> threshold reached`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
eventBus.emit("threatDetected", {
|
|
36
|
+
type: "antiBan",
|
|
37
|
+
executorId,
|
|
38
|
+
guild,
|
|
39
|
+
isSimulated
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const member = await guild.members.fetch(executorId).catch(() => null)
|
|
43
|
+
if (member) {
|
|
44
|
+
await executePunishment(guild, member, opts.punishment, "antiBan threshold exceeded", isSimulated)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const logChannelId = opts.logChannelId
|
|
48
|
+
if (logChannelId) {
|
|
49
|
+
const channel = await guild.channels.fetch(logChannelId).catch(() => null)
|
|
50
|
+
if (channel) {
|
|
51
|
+
await channel.send(createThreatMessage({
|
|
52
|
+
userId: executorId,
|
|
53
|
+
threatType: "antiBan",
|
|
54
|
+
isSimulated
|
|
55
|
+
})).catch(() => {})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
client.on("guildBanAdd", async (ban) => {
|
|
62
|
+
const logs = await ban.guild.fetchAuditLogs({ type: AuditLogEvent.MemberBanAdd, limit: 1 }).catch(() => null)
|
|
63
|
+
const entry = logs?.entries.first()
|
|
64
|
+
if (!entry || entry.executor.id === client.user.id) return
|
|
65
|
+
handle(entry.executor.id, ban.guild)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
eventBus.on("simulate:antiBan", (data) => handle(data.executorId, data.guild, true))
|
|
69
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AuditLogEvent } from "discord.js"
|
|
2
|
+
import { RateLimiter } from "../utils/rateLimiter.js"
|
|
3
|
+
import { executePunishment } from "../utils/punishments.js"
|
|
4
|
+
import { createThreatMessage } from "../utils/threatMessage.js"
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
maxBots: 2,
|
|
8
|
+
windowMs: 30_000,
|
|
9
|
+
punishment: "ban",
|
|
10
|
+
logChannelId: null,
|
|
11
|
+
whitelist: [],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Anti-bot protection module.
|
|
16
|
+
* @param {import("discord.js").Client} client
|
|
17
|
+
* @param {import("../core/EventBus.js").EventBus} eventBus
|
|
18
|
+
* @param {object} [config]
|
|
19
|
+
*/
|
|
20
|
+
export function antiBotAdd(client, eventBus, config = {}) {
|
|
21
|
+
const opts = { ...DEFAULTS, ...config }
|
|
22
|
+
const limiter = new RateLimiter(opts.maxBots, opts.windowMs)
|
|
23
|
+
|
|
24
|
+
const handle = async (executorId, guild, isSimulated = false) => {
|
|
25
|
+
if (opts.whitelist.includes(executorId)) return
|
|
26
|
+
|
|
27
|
+
if (!limiter.hit(executorId) && !isSimulated) return
|
|
28
|
+
|
|
29
|
+
if (limiter.count(executorId) >= opts.maxBots || isSimulated) {
|
|
30
|
+
if (limiter.count(executorId) === opts.maxBots || isSimulated) {
|
|
31
|
+
console.log(`[guardian] antiBotAdd -> count=${limiter.count(executorId)}/${opts.maxBots}`)
|
|
32
|
+
console.log(`[guardian] antiBotAdd -> threshold reached`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
eventBus.emit("threatDetected", {
|
|
36
|
+
type: "antiBotAdd",
|
|
37
|
+
executorId,
|
|
38
|
+
guild,
|
|
39
|
+
isSimulated
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const member = await guild.members.fetch(executorId).catch(() => null)
|
|
43
|
+
if (member) {
|
|
44
|
+
await executePunishment(guild, member, opts.punishment, "antiBotAdd threshold exceeded", isSimulated)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const logChannelId = opts.logChannelId
|
|
48
|
+
if (logChannelId) {
|
|
49
|
+
const channel = await guild.channels.fetch(logChannelId).catch(() => null)
|
|
50
|
+
if (channel) {
|
|
51
|
+
await channel.send(createThreatMessage({
|
|
52
|
+
userId: executorId,
|
|
53
|
+
threatType: "antiBotAdd",
|
|
54
|
+
isSimulated
|
|
55
|
+
})).catch(() => {})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
client.on("guildMemberAdd", async (member) => {
|
|
62
|
+
if (!member.user.bot) return
|
|
63
|
+
const logs = await member.guild.fetchAuditLogs({ type: AuditLogEvent.BotAdd, limit: 1 }).catch(() => null)
|
|
64
|
+
const entry = logs?.entries.first()
|
|
65
|
+
if (!entry || (Date.now() - entry.createdTimestamp > 10000)) return
|
|
66
|
+
if (entry.target?.id !== member.id || entry.executor.id === client.user.id) return
|
|
67
|
+
handle(entry.executor.id, member.guild)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
eventBus.on("simulate:antiBotAdd", (data) => handle(data.executorId, data.guild, true))
|
|
71
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AuditLogEvent } from "discord.js"
|
|
2
|
+
import { RateLimiter } from "../utils/rateLimiter.js"
|
|
3
|
+
import { executePunishment } from "../utils/punishments.js"
|
|
4
|
+
import { createThreatMessage } from "../utils/threatMessage.js"
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
maxDeletes: 3,
|
|
8
|
+
windowMs: 15_000,
|
|
9
|
+
punishment: "ban",
|
|
10
|
+
logChannelId: null,
|
|
11
|
+
whitelist: [],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Anti-channel-delete protection module.
|
|
16
|
+
* @param {import("discord.js").Client} client
|
|
17
|
+
* @param {import("../core/EventBus.js").EventBus} eventBus
|
|
18
|
+
* @param {object} [config]
|
|
19
|
+
*/
|
|
20
|
+
export function antiChannelDelete(client, eventBus, config = {}) {
|
|
21
|
+
const opts = { ...DEFAULTS, ...config }
|
|
22
|
+
const limiter = new RateLimiter(opts.maxDeletes, opts.windowMs)
|
|
23
|
+
|
|
24
|
+
const handle = async (executorId, guild, isSimulated = false) => {
|
|
25
|
+
if (opts.whitelist.includes(executorId)) return
|
|
26
|
+
|
|
27
|
+
if (!limiter.hit(executorId) && !isSimulated) return
|
|
28
|
+
|
|
29
|
+
if (limiter.count(executorId) >= opts.maxDeletes || isSimulated) {
|
|
30
|
+
if (limiter.count(executorId) === opts.maxDeletes || isSimulated) {
|
|
31
|
+
console.log(`[guardian] antiChannelDelete -> count=${limiter.count(executorId)}/${opts.maxDeletes}`)
|
|
32
|
+
console.log(`[guardian] antiChannelDelete -> threshold reached`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
eventBus.emit("threatDetected", {
|
|
36
|
+
type: "antiChannelDelete",
|
|
37
|
+
executorId,
|
|
38
|
+
guild,
|
|
39
|
+
isSimulated
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const member = await guild.members.fetch(executorId).catch(() => null)
|
|
43
|
+
if (member) {
|
|
44
|
+
await executePunishment(guild, member, opts.punishment, "antiChannelDelete threshold exceeded", isSimulated)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const logChannelId = opts.logChannelId
|
|
48
|
+
if (logChannelId) {
|
|
49
|
+
const channel = await guild.channels.fetch(logChannelId).catch(() => null)
|
|
50
|
+
if (channel) {
|
|
51
|
+
await channel.send(createThreatMessage({
|
|
52
|
+
userId: executorId,
|
|
53
|
+
threatType: "antiChannelDelete",
|
|
54
|
+
isSimulated
|
|
55
|
+
})).catch(() => {})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
client.on("channelDelete", async (channel) => {
|
|
62
|
+
if (!channel.guild) return
|
|
63
|
+
const logs = await channel.guild.fetchAuditLogs({ type: AuditLogEvent.ChannelDelete, limit: 1 }).catch(() => null)
|
|
64
|
+
const entry = logs?.entries.first()
|
|
65
|
+
if (!entry || (Date.now() - entry.createdTimestamp > 5000) || entry.executor.id === client.user.id) return
|
|
66
|
+
handle(entry.executor.id, channel.guild)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
eventBus.on("simulate:antiChannelDelete", (data) => handle(data.executorId, data.guild, true))
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AuditLogEvent } from "discord.js"
|
|
2
|
+
import { RateLimiter } from "../utils/rateLimiter.js"
|
|
3
|
+
import { executePunishment } from "../utils/punishments.js"
|
|
4
|
+
import { createThreatMessage } from "../utils/threatMessage.js"
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
maxKicks: 3,
|
|
8
|
+
windowMs: 15_000,
|
|
9
|
+
punishment: "ban",
|
|
10
|
+
logChannelId: null,
|
|
11
|
+
whitelist: [],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Anti-kick protection module.
|
|
16
|
+
* @param {import("discord.js").Client} client
|
|
17
|
+
* @param {import("../core/EventBus.js").EventBus} eventBus
|
|
18
|
+
* @param {object} [config]
|
|
19
|
+
*/
|
|
20
|
+
export function antiKick(client, eventBus, config = {}) {
|
|
21
|
+
const opts = { ...DEFAULTS, ...config }
|
|
22
|
+
const limiter = new RateLimiter(opts.maxKicks, opts.windowMs)
|
|
23
|
+
|
|
24
|
+
const handle = async (executorId, guild, isSimulated = false) => {
|
|
25
|
+
if (opts.whitelist.includes(executorId)) return
|
|
26
|
+
|
|
27
|
+
if (!limiter.hit(executorId) && !isSimulated) return
|
|
28
|
+
|
|
29
|
+
if (limiter.count(executorId) >= opts.maxKicks || isSimulated) {
|
|
30
|
+
if (limiter.count(executorId) === opts.maxKicks || isSimulated) {
|
|
31
|
+
console.log(`[guardian] antiKick -> count=${limiter.count(executorId)}/${opts.maxKicks}`)
|
|
32
|
+
console.log(`[guardian] antiKick -> threshold reached`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
eventBus.emit("threatDetected", {
|
|
36
|
+
type: "antiKick",
|
|
37
|
+
executorId,
|
|
38
|
+
guild,
|
|
39
|
+
isSimulated
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const member = await guild.members.fetch(executorId).catch(() => null)
|
|
43
|
+
if (member) {
|
|
44
|
+
await executePunishment(guild, member, opts.punishment, "antiKick threshold exceeded", isSimulated)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const logChannelId = opts.logChannelId
|
|
48
|
+
if (logChannelId) {
|
|
49
|
+
const channel = await guild.channels.fetch(logChannelId).catch(() => null)
|
|
50
|
+
if (channel) {
|
|
51
|
+
await channel.send(createThreatMessage({
|
|
52
|
+
userId: executorId,
|
|
53
|
+
threatType: "antiKick",
|
|
54
|
+
isSimulated
|
|
55
|
+
})).catch(() => {})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
client.on("guildMemberRemove", async (member) => {
|
|
62
|
+
const logs = await member.guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick, limit: 1 }).catch(() => null)
|
|
63
|
+
const entry = logs?.entries.first()
|
|
64
|
+
if (!entry || (Date.now() - entry.createdTimestamp > 5000)) return
|
|
65
|
+
if (entry.target?.id !== member.id || entry.executor.id === client.user.id) return
|
|
66
|
+
handle(entry.executor.id, member.guild)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
eventBus.on("simulate:antiKick", (data) => handle(data.executorId, data.guild, true))
|
|
70
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a Discord snowflake from varying input formats.
|
|
3
|
+
* @param {string} input
|
|
4
|
+
* @returns {string|null}
|
|
5
|
+
*/
|
|
6
|
+
export function parseId(input) {
|
|
7
|
+
if (!input || typeof input !== "string") return null
|
|
8
|
+
|
|
9
|
+
const match = input.match(/^<[@#][!&]?(\d+)>$/)
|
|
10
|
+
if (match) return match[1]
|
|
11
|
+
|
|
12
|
+
const raw = input.trim()
|
|
13
|
+
if (/^\d{17,20}$/.test(raw)) return raw
|
|
14
|
+
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { safeReply } from "./safeReply.js"
|
|
2
|
+
import { executePunishment } from "./punishments.js"
|
|
3
|
+
|
|
4
|
+
export function setupInteractionHandler(client, eventBus) {
|
|
5
|
+
client.on("interactionCreate", async (interaction) => {
|
|
6
|
+
try {
|
|
7
|
+
if (interaction.isButton()) {
|
|
8
|
+
const [scope, action, targetId] = interaction.customId.split(":")
|
|
9
|
+
if (scope !== "guardian") return
|
|
10
|
+
|
|
11
|
+
await interaction.deferReply({ ephemeral: true })
|
|
12
|
+
|
|
13
|
+
if (action === "punish") {
|
|
14
|
+
const guild = interaction.guild
|
|
15
|
+
if (!guild) return await safeReply(interaction, { content: "❌ Not in a guild." })
|
|
16
|
+
|
|
17
|
+
const member = await guild.members.fetch(targetId).catch(() => null)
|
|
18
|
+
if (!member) return await safeReply(interaction, { content: "❌ Member not found." })
|
|
19
|
+
|
|
20
|
+
await executePunishment(guild, member, "ban")
|
|
21
|
+
eventBus.emit("punishmentExecuted", {
|
|
22
|
+
type: "ban",
|
|
23
|
+
targetId,
|
|
24
|
+
executorId: interaction.user.id,
|
|
25
|
+
guild,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await safeReply(interaction, { content: `✅ **${member.user.tag}** banned.` })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (action === "ignore") {
|
|
32
|
+
eventBus.emit("threatIgnored", {
|
|
33
|
+
targetId,
|
|
34
|
+
executorId: interaction.user.id,
|
|
35
|
+
guild: interaction.guild,
|
|
36
|
+
})
|
|
37
|
+
await safeReply(interaction, { content: "✅ Ignored." })
|
|
38
|
+
}
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (interaction.isStringSelectMenu()) {
|
|
43
|
+
const [scope, action, targetId] = interaction.customId.split(":")
|
|
44
|
+
if (scope !== "guardian") return
|
|
45
|
+
|
|
46
|
+
await interaction.deferReply({ ephemeral: true })
|
|
47
|
+
|
|
48
|
+
const type = interaction.values[0]
|
|
49
|
+
const guild = interaction.guild
|
|
50
|
+
if (!guild) return await safeReply(interaction, { content: "❌ Not in a guild." })
|
|
51
|
+
|
|
52
|
+
const member = await guild.members.fetch(targetId).catch(() => null)
|
|
53
|
+
if (!member) return await safeReply(interaction, { content: "❌ Member not found." })
|
|
54
|
+
|
|
55
|
+
await executePunishment(guild, member, type)
|
|
56
|
+
eventBus.emit("punishmentExecuted", {
|
|
57
|
+
type,
|
|
58
|
+
targetId,
|
|
59
|
+
executorId: interaction.user.id,
|
|
60
|
+
guild,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const labels = { ban: "banned", kick: "kicked", roles: "roles removed" }
|
|
64
|
+
await safeReply(interaction, { content: `✅ **${member.user.tag}** ${labels[type] || type}.` })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error("[Guardian] Interaction error:", err)
|
|
69
|
+
if (!interaction.replied && !interaction.deferred) {
|
|
70
|
+
await interaction.reply({ content: "❌ Internal error.", ephemeral: true }).catch(() => {})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute a punishment on a member.
|
|
3
|
+
* @param {import("discord.js").Guild} guild
|
|
4
|
+
* @param {import("discord.js").GuildMember} member
|
|
5
|
+
* @param {"ban"|"kick"|"roles"} type
|
|
6
|
+
* @param {string} [reason]
|
|
7
|
+
* @param {boolean} [isSimulated]
|
|
8
|
+
*/
|
|
9
|
+
export async function executePunishment(
|
|
10
|
+
guild,
|
|
11
|
+
member,
|
|
12
|
+
type,
|
|
13
|
+
reason = "Guardian Action",
|
|
14
|
+
isSimulated = false
|
|
15
|
+
) {
|
|
16
|
+
if (isSimulated) return
|
|
17
|
+
|
|
18
|
+
switch (type) {
|
|
19
|
+
case "ban":
|
|
20
|
+
await guild.members.ban(member.id, { reason, deleteMessageSeconds: 0 })
|
|
21
|
+
break
|
|
22
|
+
case "kick":
|
|
23
|
+
await member.kick(reason)
|
|
24
|
+
break
|
|
25
|
+
case "roles":
|
|
26
|
+
await member.roles.set([], reason)
|
|
27
|
+
break
|
|
28
|
+
default:
|
|
29
|
+
console.warn(`[guardian] unknown punishment: ${type}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
/**
|
|
3
|
+
* @param {number} limit
|
|
4
|
+
* @param {number} windowMs
|
|
5
|
+
*/
|
|
6
|
+
constructor(limit, windowMs) {
|
|
7
|
+
this.limit = limit
|
|
8
|
+
this.windowMs = windowMs
|
|
9
|
+
this.hits = new Map()
|
|
10
|
+
this.intervals = new Map()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register a hit and return true if limit is reached.
|
|
15
|
+
* @param {string} key
|
|
16
|
+
*/
|
|
17
|
+
hit(key) {
|
|
18
|
+
const now = Date.now()
|
|
19
|
+
let userHits = this.hits.get(key) || []
|
|
20
|
+
|
|
21
|
+
userHits = userHits.filter(timestamp => now - timestamp < this.windowMs)
|
|
22
|
+
userHits.push(now)
|
|
23
|
+
this.hits.set(key, userHits)
|
|
24
|
+
|
|
25
|
+
return userHits.length >= this.limit
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get current hit count for a key.
|
|
30
|
+
* @param {string} key
|
|
31
|
+
*/
|
|
32
|
+
count(key) {
|
|
33
|
+
return this.hits.get(key)?.length || 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
reset(key) {
|
|
37
|
+
this.hits.delete(key)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
destroy() {
|
|
41
|
+
this.hits.clear()
|
|
42
|
+
for (const interval of this.intervals.values()) {
|
|
43
|
+
clearInterval(interval)
|
|
44
|
+
}
|
|
45
|
+
this.intervals.clear()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safely reply to a discord.js interaction.
|
|
3
|
+
* @param {import("discord.js").Interaction} interaction
|
|
4
|
+
* @param {import("discord.js").InteractionReplyOptions} options
|
|
5
|
+
*/
|
|
6
|
+
export async function safeReply(interaction, options) {
|
|
7
|
+
try {
|
|
8
|
+
if (interaction.replied || interaction.deferred) {
|
|
9
|
+
return await interaction.editReply(options)
|
|
10
|
+
}
|
|
11
|
+
return await interaction.reply(options)
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error("[guardian] safeReply error:", err.message)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a threat-alert message.
|
|
3
|
+
* @param {object} opts
|
|
4
|
+
*/
|
|
5
|
+
export function createThreatMessage({ userId, threatType, details, isSimulated }) {
|
|
6
|
+
const embed = new EmbedBuilder()
|
|
7
|
+
.setColor(0xff3b3b)
|
|
8
|
+
.setTitle(isSimulated ? "⚠️ Simulation Mode" : "Guardian — Threat Detected")
|
|
9
|
+
.setDescription(isSimulated ? "⚠️ Simulation Mode — no real actions will be taken" : "Suspicious activity filtered and blocked.")
|
|
10
|
+
.addFields(
|
|
11
|
+
{ name: "User", value: `<@${userId}>`, inline: true },
|
|
12
|
+
{ name: "Module", value: `\`${threatType}\``, inline: true },
|
|
13
|
+
)
|
|
14
|
+
.setTimestamp()
|
|
15
|
+
|
|
16
|
+
if (details) embed.addFields({ name: "Details", value: details })
|
|
17
|
+
|
|
18
|
+
const buttons = new ActionRowBuilder().addComponents(
|
|
19
|
+
new ButtonBuilder()
|
|
20
|
+
.setCustomId(`guardian:punish:${userId}`)
|
|
21
|
+
.setLabel(isSimulated ? "Simulate Punish" : "Punish User")
|
|
22
|
+
.setStyle(ButtonStyle.Danger),
|
|
23
|
+
new ButtonBuilder()
|
|
24
|
+
.setCustomId(`guardian:ignore:${userId}`)
|
|
25
|
+
.setLabel("Ignore")
|
|
26
|
+
.setStyle(ButtonStyle.Secondary),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const menu = new ActionRowBuilder().addComponents(
|
|
30
|
+
new StringSelectMenuBuilder()
|
|
31
|
+
.setCustomId(`guardian:select:${userId}`)
|
|
32
|
+
.setPlaceholder("Select punishment...")
|
|
33
|
+
.addOptions([
|
|
34
|
+
{ label: "Ban", value: "ban" },
|
|
35
|
+
{ label: "Kick", value: "kick" },
|
|
36
|
+
{ label: "Remove Roles", value: "roles" },
|
|
37
|
+
]),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
embeds: [embed],
|
|
42
|
+
components: [buttons, menu],
|
|
43
|
+
}
|
|
44
|
+
}
|