Skip to content
This repository was archived by the owner on Apr 29, 2025. It is now read-only.

Commit 28144c3

Browse files
committed
feat: implement risk scoring and user scanning
1 parent 74170aa commit 28144c3

22 files changed

+798
-268
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ build
33
.idea
44
.env
55
prisma/database.db
6-
prisma/database.db-journal
6+
prisma/database.db-journal
7+
*.sql

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"build": "pnpm tsc",
88
"start": "pnpm node build/index.js",
99
"start:production": "pnpm build && pnpm start",
10-
"dev": "pnpm nodemon -L"
10+
"dev": "pnpm nodemon -L",
11+
"postinstall": "prisma generate"
1112
},
1213
"devDependencies": {
1314
"@types/node": "^20.14.12",

prisma/schema.prisma

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ model Warn {
1717
}
1818

1919
model User {
20-
id String @id
21-
warns Int @default(0)
22-
timeouts Int @default(0)
23-
warnings Warn[]
20+
id String @id
21+
warns Int @default(0)
22+
timeouts Int @default(0)
23+
warnings Warn[]
24+
messageCount Int @default(0)
25+
lastMessageAt DateTime?
26+
joinedAt DateTime @default(now())
27+
riskScore Int @default(0)
28+
lastScan DateTime?
2429
}
2530

2631
model Ban {
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import {
2+
SlashCommandBuilder,
3+
PermissionFlagsBits,
4+
ChatInputCommandInteraction,
5+
GuildMember,
6+
Guild,
7+
TextChannel,
8+
EmbedBuilder,
9+
ChannelType,
10+
} from 'discord.js';
11+
import { PrismaClient, Prisma } from '@prisma/client';
12+
import { Command } from '../../interfaces/command';
13+
import logger from '../../utils/logger';
14+
import { assessAndWarnHighRiskUser } from '../../utils/riskScoring';
15+
import Config from '../../config';
16+
17+
const prisma = new PrismaClient();
18+
const SUSPICIOUS_PATTERNS = [/\d{4}$/, /^[a-zA-Z]\d{7}$/, /(.)\1{4,}/, /^[a-zA-Z0-9]+(bot|spam)$/i];
19+
20+
const ScanUsers: Command = {
21+
data: new SlashCommandBuilder()
22+
.setName('scanusers')
23+
.setDescription('Scan server members for potential spam accounts')
24+
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
25+
.addStringOption((option) =>
26+
option
27+
.setName('filter')
28+
.setDescription('Scan filter')
29+
.setRequired(true)
30+
.addChoices(
31+
{ name: 'New Joins (24h)', value: 'new' },
32+
{ name: 'No Messages', value: 'silent' },
33+
{ name: 'Suspicious Names', value: 'names' },
34+
{ name: 'High Risk', value: 'risk' }
35+
)
36+
)
37+
.addIntegerOption((option) =>
38+
option.setName('limit').setDescription('Maximum number of users to scan (default 1000)').setRequired(false)
39+
),
40+
41+
async run(interaction: ChatInputCommandInteraction) {
42+
if (!interaction.guild) {
43+
await interaction.reply({ content: 'This command can only be used in a guild.', ephemeral: true });
44+
return;
45+
}
46+
47+
await interaction.deferReply({ ephemeral: true });
48+
49+
try {
50+
const filter = interaction.options.getString('filter', true);
51+
const limit = Math.min(interaction.options.getInteger('limit') || 1000, 5000);
52+
const logChannel = interaction.guild.channels.cache.get(Config.LOG_CHANNEL) as TextChannel;
53+
54+
if (!logChannel || logChannel.type !== ChannelType.GuildText) {
55+
await interaction.editReply('Unable to find the configured log channel.');
56+
return;
57+
}
58+
59+
if (filter === 'risk') {
60+
await interaction.editReply('Starting risk assessment scan. This may take a while...');
61+
}
62+
63+
const scanStartTime = Date.now();
64+
const results = await scanUsers(interaction.guild, filter, limit, interaction);
65+
66+
if (results.length === 0) {
67+
await interaction.editReply(`No suspicious users found using filter: ${filter}`);
68+
return;
69+
}
70+
71+
const summaryEmbed = createSummaryEmbed(results, filter, scanStartTime);
72+
73+
await sendDetailedResults(logChannel, results, filter, interaction.user.tag);
74+
await interaction.editReply({
75+
content: `Scan complete! Detailed results have been sent to ${logChannel}`,
76+
embeds: [summaryEmbed],
77+
});
78+
} catch (error) {
79+
logger.error('Error in scanusers command:', error);
80+
await interaction.editReply('An error occurred while scanning users. Please try again later.');
81+
}
82+
},
83+
};
84+
85+
function createSummaryEmbed(
86+
results: Array<{ member: GuildMember; risk: number; reason: string }>,
87+
filter: string,
88+
startTime: number
89+
): EmbedBuilder {
90+
const executionTime = ((Date.now() - startTime) / 1000).toFixed(2);
91+
92+
const reasonCounts = results.reduce(
93+
(acc, { reason }) => {
94+
acc[reason] = (acc[reason] || 0) + 1;
95+
return acc;
96+
},
97+
{} as Record<string, number>
98+
);
99+
100+
const embed = new EmbedBuilder()
101+
.setTitle('Scan Summary')
102+
.setColor('#ff9900')
103+
.setDescription(`Filter: ${filter}\nTotal suspicious users: ${results.length}`)
104+
.addFields(
105+
{ name: 'Execution Time', value: `${executionTime} seconds`, inline: true },
106+
{
107+
name: 'Average Risk Score',
108+
value: (results.reduce((acc, r) => acc + r.risk, 0) / results.length).toFixed(2),
109+
inline: true,
110+
}
111+
)
112+
.setTimestamp();
113+
114+
for (const [reason, count] of Object.entries(reasonCounts)) {
115+
embed.addFields({ name: reason, value: count.toString(), inline: true });
116+
}
117+
118+
return embed;
119+
}
120+
121+
async function sendDetailedResults(
122+
channel: TextChannel,
123+
results: Array<{ member: GuildMember; risk: number; reason: string }>,
124+
filter: string,
125+
initiator: string
126+
) {
127+
await channel.send({
128+
embeds: [new EmbedBuilder()
129+
.setTitle('Detailed Scan Results')
130+
.setDescription(`Filter: ${filter}\nInitiated by: ${initiator}\nTotal results: ${results.length}`)
131+
.setColor('#ff9900')
132+
.setTimestamp()]
133+
});
134+
135+
const USERS_PER_EMBED = 10;
136+
const totalEmbeds = Math.ceil(results.length / USERS_PER_EMBED);
137+
138+
for (let i = 0; i < results.length; i += USERS_PER_EMBED) {
139+
const batch = results.slice(i, i + USERS_PER_EMBED);
140+
const embedNumber = Math.floor(i / USERS_PER_EMBED) + 1;
141+
142+
const batchEmbed = new EmbedBuilder()
143+
.setTitle(`Results (Page ${embedNumber}/${totalEmbeds})`)
144+
.setDescription(`Users ${i + 1}-${Math.min(i + USERS_PER_EMBED, results.length)} of ${results.length}`)
145+
.setColor('#ff9900');
146+
147+
batch.forEach((result, index) => {
148+
batchEmbed.addFields({
149+
name: `${i + index + 1}. ${result.member.user.tag}`,
150+
value: [
151+
`ID: ${result.member.id}`,
152+
`Risk Score: ${result.risk}`,
153+
`Reason: ${result.reason}`,
154+
`Joined: ${result.member.joinedAt?.toLocaleString() || 'Unknown'}`,
155+
`Account Created: ${result.member.user.createdAt.toLocaleString()}`
156+
].join('\n')
157+
});
158+
});
159+
160+
await channel.send({ embeds: [batchEmbed] });
161+
162+
if (embedNumber < totalEmbeds) {
163+
await new Promise(resolve => setTimeout(resolve, 1000));
164+
}
165+
}
166+
167+
await channel.send({
168+
embeds: [new EmbedBuilder()
169+
.setTitle('End of Results')
170+
.setDescription(`Completed sending ${results.length} results across ${totalEmbeds} pages.`)
171+
.setColor('#ff9900')
172+
.setTimestamp()]
173+
});
174+
}
175+
176+
async function getOrCreateUser(userId: string): Promise<{ messageCount: number; riskScore: number } | null> {
177+
try {
178+
let user = await prisma.user.findUnique({
179+
where: { id: userId },
180+
select: { messageCount: true, riskScore: true },
181+
});
182+
183+
if (!user) {
184+
try {
185+
user = await prisma.user.create({
186+
data: {
187+
id: userId,
188+
warns: 0,
189+
timeouts: 0,
190+
messageCount: 0,
191+
riskScore: 0,
192+
},
193+
select: { messageCount: true, riskScore: true },
194+
});
195+
} catch (createError) {
196+
if (createError instanceof Prisma.PrismaClientKnownRequestError) {
197+
// P2002 is for unique constraint violations
198+
if (createError.code === 'P2002') {
199+
user = await prisma.user.findUnique({
200+
where: { id: userId },
201+
select: { messageCount: true, riskScore: true },
202+
});
203+
}
204+
}
205+
if (!user) {
206+
logger.error(`Failed to create/fetch user ${userId}:`, createError);
207+
return null;
208+
}
209+
}
210+
}
211+
212+
return user;
213+
} catch (error) {
214+
logger.error(`Database error for user ${userId}:`, error);
215+
return null;
216+
}
217+
}
218+
219+
async function scanUsers(
220+
guild: Guild,
221+
filter: string,
222+
limit: number,
223+
interaction?: ChatInputCommandInteraction
224+
): Promise<Array<{ member: GuildMember; risk: number; reason: string }>> {
225+
const now = Date.now();
226+
const suspiciousUsers: Array<{ member: GuildMember; risk: number; reason: string }> = [];
227+
228+
try {
229+
await interaction?.editReply('Fetching members...');
230+
const members = await guild.members.fetch({ limit });
231+
const memberArray = Array.from(members.values());
232+
const totalMembers = memberArray.length;
233+
234+
logger.info(`Starting scan with filter: ${filter}, total members: ${totalMembers}`);
235+
await interaction?.editReply(`Found ${totalMembers} members to scan. Starting scan...`);
236+
237+
for (let i = 0; i < memberArray.length; i++) {
238+
if (suspiciousUsers.length >= limit) break;
239+
240+
const member = memberArray[i];
241+
if (member.user.bot) continue;
242+
243+
if (interaction && i % 5 === 0) {
244+
const progress = Math.round((i / totalMembers) * 100);
245+
await interaction
246+
.editReply(
247+
`Scanning users: ${progress}% complete (${i}/${totalMembers})\nSuspicious users found: ${suspiciousUsers.length}`
248+
)
249+
.catch((error) => logger.error('Failed to update progress:', error));
250+
}
251+
252+
try {
253+
switch (filter) {
254+
case 'new':
255+
const joinTime = member.joinedTimestamp || 0;
256+
const hoursSinceJoin = (now - joinTime) / (1000 * 60 * 60);
257+
if (hoursSinceJoin <= 24) {
258+
suspiciousUsers.push({ member, risk: 1, reason: 'New join' });
259+
logger.debug(`Found new join: ${member.user.tag}`);
260+
}
261+
break;
262+
263+
case 'silent':
264+
const userData = await getOrCreateUser(member.id);
265+
if (userData?.messageCount === 0) {
266+
suspiciousUsers.push({ member, risk: 2, reason: 'No messages' });
267+
logger.debug(`Found silent user: ${member.user.tag}`);
268+
}
269+
break;
270+
271+
case 'names':
272+
if (SUSPICIOUS_PATTERNS.some((pattern) => pattern.test(member.user.username))) {
273+
suspiciousUsers.push({ member, risk: 2, reason: 'Suspicious username' });
274+
logger.debug(`Found suspicious username: ${member.user.tag}`);
275+
}
276+
break;
277+
278+
case 'risk':
279+
logger.debug(`Assessing risk for user: ${member.user.tag}`);
280+
await assessAndWarnHighRiskUser(member, guild);
281+
282+
const userRisk = await getOrCreateUser(member.id);
283+
if (userRisk && userRisk.riskScore > 3) {
284+
suspiciousUsers.push({
285+
member,
286+
risk: userRisk.riskScore,
287+
reason: 'High risk score',
288+
});
289+
logger.debug(`Found high risk user: ${member.user.tag} (Score: ${userRisk.riskScore})`);
290+
}
291+
292+
await new Promise((resolve) => setTimeout(resolve, 500));
293+
break;
294+
}
295+
} catch (memberError) {
296+
logger.error(`Error processing member ${member.id} with filter ${filter}:`, memberError);
297+
continue;
298+
}
299+
}
300+
301+
logger.info(`Scan complete. Found ${suspiciousUsers.length} suspicious users`);
302+
await interaction?.editReply('Scan complete! Preparing results...');
303+
304+
return suspiciousUsers.sort((a, b) => b.risk - a.risk);
305+
} catch (error) {
306+
logger.error('Error during scan:', error);
307+
throw error;
308+
}
309+
}
310+
311+
export default ScanUsers;

0 commit comments

Comments
 (0)