Skip to content

Commit b7f9c65

Browse files
authored
fix: rate limit behaviour for multi-channel spam (#86)
1 parent 11d05b9 commit b7f9c65

7 files changed

Lines changed: 243 additions & 89 deletions

File tree

.github/workflows/lint.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Linting
1+
name: Linting and Testing
22

33
on:
44
pull_request:
@@ -13,8 +13,8 @@ concurrency:
1313
cancel-in-progress: true
1414

1515
jobs:
16-
lint:
17-
name: Lint
16+
lint_and_test:
17+
name: Lint and test
1818
runs-on: ubuntu-latest
1919
steps:
2020
- name: Checkout
@@ -37,3 +37,6 @@ jobs:
3737

3838
- name: Check
3939
run: pnpm check
40+
41+
- name: Run tests
42+
run: pnpm test:ci

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"db:dev": "./pocketbase/dev.sh",
88
"start": "node src/index.ts",
99
"fix": "prettier --write . && biome check --write",
10-
"check": "prettier --check . && tsc --noEmit && biome check && knip"
10+
"check": "prettier --check . && tsc --noEmit && biome check && knip",
11+
"test": "node --test --watch",
12+
"test:ci": "node --test"
1113
},
1214
"license": "MIT",
1315
"dependencies": {

src/events/on_message/_spam_filter.ts

Lines changed: 119 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { userMention, type Message } from 'discord.js';
22
import { mod_forward, mod_log } from '../../utils/mod_logs.ts';
33
import { has_any_role_or_id } from '../../utils/snowflake.ts';
44
import { RateLimitStore } from '../../utils/ratelimit.ts';
5-
import { timeout, ban, kick } from '../../utils/member_actions.ts';
5+
import { ban, kick, timeout } from '../../utils/member_actions.ts';
66
import { has_link, STOP } from './_common.ts';
77
import {
88
SPAM_FILTER_MULTI_CHANNEL_ACTION,
@@ -23,84 +23,138 @@ function debug<T>(val: T): T {
2323
return val;
2424
}
2525

26-
export default async function spam_filter(message: Message) {
27-
const posts_many_links_within_a_channel =
28-
message.inGuild() &&
29-
!message.thread &&
30-
has_link(message) &&
31-
single_channel_limit.is_limited(
32-
message.author.id,
33-
message.channelId,
34-
true,
35-
);
36-
37-
const posts_many_messages_across_channels =
38-
message.inGuild() &&
39-
!message.thread &&
40-
multi_channel_limit.is_limited(
41-
message.author.id,
42-
message.channelId,
43-
true,
44-
);
26+
const SpamAction = Object.freeze({
27+
LOG: 'log',
28+
TIMEOUT: 'timeout',
29+
KICK: 'kick',
30+
BAN: 'ban',
31+
});
32+
type SpamActionValues = (typeof SpamAction)[keyof typeof SpamAction];
4533

46-
const posts_in_honeypot =
47-
message.inGuild() &&
48-
message.channelId === HONEYPOT_CHANNEL &&
49-
// Message by non-admin
50-
!has_any_role_or_id(message.member, MODERATOR_IDS);
34+
type SpamOptions = {
35+
/**
36+
* Reason for kick/ban/timeout
37+
* e.g. `User was kicked for ${log_reason}`
38+
*/
39+
log_reason: string;
40+
};
5141

52-
const is_likely_spam =
53-
posts_many_links_within_a_channel ||
54-
posts_many_messages_across_channels ||
55-
posts_in_honeypot;
42+
type SpamFilter = {
43+
name: string;
44+
condition: (message: Message) => boolean;
45+
/** All actions log by default. */
46+
action: SpamActionValues;
47+
options?: SpamOptions;
48+
};
49+
const spam_filters: SpamFilter[] = [
50+
{
51+
name: 'Posts many links within a channel',
52+
condition: (message) => {
53+
return (
54+
message.inGuild() &&
55+
!message.thread &&
56+
has_link(message) &&
57+
single_channel_limit.is_limited(
58+
message.author.id,
59+
message.channelId,
60+
)
61+
);
62+
},
63+
action: 'ban',
64+
},
65+
{
66+
name: 'Posts many messages across channels',
67+
condition: (message) => {
68+
return (
69+
message.inGuild() &&
70+
!message.thread &&
71+
multi_channel_limit.is_limited(
72+
message.author.id,
73+
message.channelId,
74+
)
75+
);
76+
},
77+
get action(): SpamActionValues {
78+
return SPAM_FILTER_MULTI_CHANNEL_ACTION ?? 'log';
79+
},
80+
options: {
81+
log_reason: 'posting messages across many channels',
82+
},
83+
},
84+
{
85+
name: 'Posts in honeypot',
86+
condition: (message) => {
87+
return (
88+
message.inGuild() &&
89+
message.channelId === HONEYPOT_CHANNEL &&
90+
// Message by non-admin
91+
!has_any_role_or_id(message.member, MODERATOR_IDS)
92+
);
93+
},
94+
action: 'kick',
95+
options: {
96+
log_reason: 'posting in honeypot',
97+
},
98+
},
99+
];
56100

57-
if (!is_likely_spam) return;
101+
export default async function spam_filter(message: Message) {
102+
const spam_detected = spam_filters.find((filter) => {
103+
return filter.condition(message);
104+
});
58105

106+
if (!spam_detected) return;
59107
console.log(`User ID: ${message.author.id} tripped spam filter`);
60108

61-
const member = debug(await message.guild.members.fetch(message.author.id));
109+
const member = debug(await message.guild?.members.fetch(message.author.id));
62110
const is_threadlord = has_any_role_or_id(member, THREAD_ADMIN_IDS);
63111

64112
if (DEV_MODE) {
65113
await message.reply('Oi, stop spamming you troglodyte.');
66114
// Unlikely to be spam from trusted members
67-
} else if (!debug(is_threadlord)) {
115+
} else if (!debug(is_threadlord) && spam_detected && member) {
116+
// Forward last message
68117
await mod_forward(message);
118+
const log_reason = spam_detected.options?.log_reason;
69119

70-
if (
71-
posts_many_links_within_a_channel ||
72-
(posts_many_messages_across_channels &&
73-
SPAM_FILTER_MULTI_CHANNEL_ACTION === 'ban')
74-
) {
75-
// Ban
76-
await Promise.allSettled([
77-
ban(member, 3),
78-
member.send(
79-
'You were banned from the Svelte discord server for spamming. If you believe this was a mistake you can appeal the ban at <https://github.com/pngwn/svelte-bot/issues/38>',
80-
),
81-
mod_log(
82-
message.client,
83-
`User ${userMention(message.author.id)} was suspected of spamming and was banned.`,
84-
),
85-
]);
86-
} else if (posts_in_honeypot) {
87-
// Kick
88-
await Promise.allSettled([
89-
kick(member, 'Posting in honeypot'),
90-
mod_log(
91-
message.client,
92-
`User ${userMention(message.author.id)} was kicked for posting in honeypot.`,
93-
),
94-
]);
95-
} else {
96-
// Timeout
97-
await Promise.allSettled([
98-
timeout(member, 43_200_000, 'Multi-channel spam'),
99-
mod_log(
120+
switch (spam_detected.action) {
121+
// TODO timeout case
122+
case SpamAction.BAN:
123+
await Promise.allSettled([
124+
ban(member, 3),
125+
member.send(
126+
'You were banned from the Svelte discord server for spamming. If you believe this was a mistake you can appeal the ban at <https://github.com/pngwn/svelte-bot/issues/38>',
127+
),
128+
mod_log(
129+
message.client,
130+
`User ${userMention(message.author.id)} was suspected of spamming and was banned.`,
131+
),
132+
]);
133+
break;
134+
case SpamAction.KICK:
135+
await Promise.allSettled([
136+
kick(member, log_reason),
137+
mod_log(
138+
message.client,
139+
`User ${userMention(message.author.id)} was kicked${log_reason ? ` for ${log_reason}` : ''}.`,
140+
),
141+
]);
142+
break;
143+
case SpamAction.TIMEOUT:
144+
await Promise.allSettled([
145+
timeout(member, { reason: log_reason }),
146+
mod_log(
147+
message.client,
148+
`User ${userMention(message.author.id)} was timed out${log_reason ? ` for ${log_reason}` : ''}.`,
149+
),
150+
]);
151+
break;
152+
default:
153+
await mod_log(
100154
message.client,
101-
`User ${userMention(message.author.id)} was suspected of spamming and was timed out.`,
102-
),
103-
]);
155+
`Log note for user ${userMention(message.author.id)}: ${log_reason ?? 'no reason provided'}.`,
156+
);
157+
break;
104158
}
105159
}
106160

src/utils/member_actions.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, it, mock } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { timeout } from './member_actions.ts';
4+
5+
describe('timeout', () => {
6+
it('times out member', () => {
7+
const member = {
8+
timeout: mock.fn(),
9+
};
10+
11+
// @ts-expect-error
12+
timeout(member);
13+
assert.deepStrictEqual(member.timeout.mock.calls[0].arguments, [
14+
43_200_000,
15+
'Bot action',
16+
]);
17+
});
18+
19+
it('times out member with custom reason', () => {
20+
const member = {
21+
timeout: mock.fn(),
22+
};
23+
24+
// @ts-expect-error
25+
timeout(member, { reason: 'just because' });
26+
assert.deepStrictEqual(member.timeout.mock.calls[0].arguments, [
27+
43_200_000,
28+
'just because',
29+
]);
30+
});
31+
});

src/utils/member_actions.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,37 @@ import { setTimeout } from 'node:timers/promises';
44
/** 24 hours in seconds. */
55
const TWENTY_FOUR_HOURS = 86_400;
66

7+
/** 12 hours in ms */
8+
const TWELVE_HOURS_MS = 43_200_000;
9+
710
/** Two hours in seconds */
811
const TWO_HOURS = 7_200;
912

13+
type TimeoutOptions = {
14+
timeout_length?: number;
15+
reason?: string;
16+
retries?: number;
17+
};
18+
1019
/**
1120
* Time out member.
1221
* @param member
13-
* @param timeout_length Timeout period in ms
14-
* @param reason Timeout reason
15-
* @param retries Timeout action retries
22+
* @param options
23+
* @property options.timeout_length Timeout period in ms
24+
* @property options.reason Timeout reason
25+
* @property options.retries Timeout action retries
1626
*/
17-
export async function timeout(
18-
member: GuildMember,
19-
timeout_length = 43_200_000, // 12 hours
20-
reason = 'Bot action',
21-
/** @default 3 */
22-
retries = 3,
23-
) {
27+
export async function timeout(member: GuildMember, options?: TimeoutOptions) {
28+
const { reason, retries, timeout_length } = Object.assign(
29+
{},
30+
{
31+
timeout_length: TWELVE_HOURS_MS,
32+
reason: 'Bot action',
33+
retries: 3,
34+
},
35+
options,
36+
);
37+
2438
let retries_remaining = retries;
2539

2640
while (--retries_remaining && member.timeout) {

src/utils/ratelimit.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { afterEach, describe, it } from 'node:test';
2+
import assert from 'node:assert';
3+
import { RateLimitStore } from './ratelimit.ts';
4+
5+
describe('RateLimitStore', () => {
6+
afterEach(() => {
7+
RateLimitStore.clear_timers();
8+
});
9+
10+
it('detects single channel spam', () => {
11+
// 3 messages within a 5 second period
12+
const single_channel_limit = new RateLimitStore(3, 5_000, 1);
13+
single_channel_limit.is_limited('abc', 'one');
14+
single_channel_limit.is_limited('abc', 'one');
15+
single_channel_limit.is_limited('abc', 'one');
16+
17+
assert.strictEqual(single_channel_limit.is_limited('abc', 'one'), true);
18+
});
19+
20+
it('detects multi channel spam', () => {
21+
// 3 messages across 3 channels within a 10 second period
22+
const multi_channel_limit = new RateLimitStore(3, 10_000, 3);
23+
multi_channel_limit.is_limited('abc', 'one');
24+
multi_channel_limit.is_limited('abc', 'two');
25+
multi_channel_limit.is_limited('abc', 'three');
26+
27+
assert.strictEqual(multi_channel_limit.is_limited('abc', 'four'), true);
28+
});
29+
30+
it('does not interpret single channel spam as multi channel spam', () => {
31+
// 3 messages across 3 channels within a 10 second period
32+
const multi_channel_limit = new RateLimitStore(3, 10_000, 3);
33+
multi_channel_limit.is_limited('abc', 'one');
34+
multi_channel_limit.is_limited('abc', 'one');
35+
multi_channel_limit.is_limited('abc', 'one');
36+
37+
assert.strictEqual(multi_channel_limit.is_limited('abc', 'one'), false);
38+
});
39+
});

0 commit comments

Comments
 (0)