• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 23579979815

26 Mar 2026 06:01AM UTC coverage: 90.633% (+0.6%) from 90.058%
23579979815

push

github

web-flow
Merge pull request #385 from VolvoxLLC/fix/nightly-ci-20260326

6552 of 7663 branches covered (85.5%)

Branch coverage included in aggregate %.

11145 of 11863 relevant lines covered (93.95%)

223.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.29
/src/modules/aiAutoMod.js
1
/**
2
 * AI Auto-Moderation Module
3
 * Uses Claude SDK to analyze messages for toxicity, spam, and harassment.
4
 * Supports configurable thresholds, per-guild settings, and multiple actions:
5
 * warn, timeout, kick, ban, or flag for review.
6
 */
7

8
import { EmbedBuilder } from 'discord.js';
9
import { info, error as logError, warn } from '../logger.js';
10
import { _setAnthropicClient, getAnthropicClient } from '../utils/anthropicClient.js';
11
import { fetchChannelCached } from '../utils/discordCache.js';
12
import { isExempt } from '../utils/modExempt.js';
13
import { safeSend } from '../utils/safeSend.js';
14
import { createCase } from './moderation.js';
15

16
/** Default config when none is provided */
17
const DEFAULTS = {
8✔
18
  enabled: false,
19
  model: 'claude-haiku-4-5',
20
  thresholds: {
21
    toxicity: 0.7,
22
    spam: 0.8,
23
    harassment: 0.7,
24
  },
25
  actions: {
26
    toxicity: 'flag',
27
    spam: 'delete',
28
    harassment: 'warn',
29
  },
30
  timeoutDurationMs: 5 * 60 * 1000,
31
  flagChannelId: null,
32
  autoDelete: true,
33
  exemptRoleIds: [],
34
};
35

36
/**
37
 * Get the shared Anthropic client.
38
 * @returns {import('@anthropic-ai/sdk').default}
39
 */
40
function getClient() {
41
  return getAnthropicClient();
16✔
42
}
43

44
/**
45
 * Reset the Anthropic client (for testing).
46
 */
47
export function resetClient() {
48
  _setAnthropicClient(null);
23✔
49
}
50

51
/**
52
 * Get the merged AI auto-mod config for a guild.
53
 * @param {Object} config - Guild config
54
 * @returns {Object} Merged AI auto-mod config
55
 */
56
export function getAiAutoModConfig(config) {
57
  const raw = config?.aiAutoMod ?? {};
49✔
58
  return {
49✔
59
    ...DEFAULTS,
60
    ...raw,
61
    thresholds: { ...DEFAULTS.thresholds, ...(raw.thresholds ?? {}) },
89✔
62
    actions: { ...DEFAULTS.actions, ...(raw.actions ?? {}) },
88✔
63
  };
64
}
65

66
/**
67
 * Analyze a message using Claude AI.
68
 * Returns scores and recommendations for moderation actions.
69
 *
70
 * @param {string} content - Message content to analyze
71
 * @param {Object} autoModConfig - AI auto-mod config
72
 * @returns {Promise<{flagged: boolean, scores: Object, categories: string[], reason: string, action: string}>}
73
 */
74
export async function analyzeMessage(content, autoModConfig) {
75
  const mergedConfig = autoModConfig ?? DEFAULTS;
17!
76

77
  if (!content || content.trim().length < 3) {
17✔
78
    return {
1✔
79
      flagged: false,
80
      scores: { toxicity: 0, spam: 0, harassment: 0 },
81
      categories: [],
82
      reason: 'Message too short',
83
      action: 'none',
84
    };
85
  }
86

87
  const client = getClient();
16✔
88

89
  const prompt = `You are a content moderation assistant. Analyze the following Discord message and rate it on three dimensions.
16✔
90

91
Message to analyze:
92
<message>
93
${content.slice(0, 2000)}
94
</message>
95

96
Rate the message on a scale of 0.0 to 1.0 for each category:
97
- toxicity: Hateful language, slurs, extreme negativity targeting groups or individuals
98
- spam: Repetitive content, advertisements, scam links, flooding
99
- harassment: Targeted attacks on specific individuals, threats, bullying, doxxing
100

101
Respond ONLY with valid JSON in this exact format:
102
{
103
  "toxicity": 0.0,
104
  "spam": 0.0,
105
  "harassment": 0.0,
106
  "reason": "brief explanation of main concern or 'clean' if none"
107
}`;
108

109
  const response = await client.messages.create({
16✔
110
    model: mergedConfig.model ?? DEFAULTS.model,
16!
111
    max_tokens: 256,
112
    messages: [{ role: 'user', content: prompt }],
113
  });
114

115
  const text = response.content[0]?.text ?? '{}';
14!
116

117
  let parsed;
118
  try {
17✔
119
    const jsonMatch = text.match(/\{[\s\S]*\}/);
17✔
120
    parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
17✔
121
  } catch {
122
    logError('AI auto-mod: failed to parse Claude response', { text });
×
123
    return {
×
124
      flagged: false,
125
      scores: { toxicity: 0, spam: 0, harassment: 0 },
126
      categories: [],
127
      reason: 'Parse error',
128
      action: 'none',
129
    };
130
  }
131

132
  const scores = {
14✔
133
    toxicity: Math.min(1, Math.max(0, Number(parsed.toxicity) || 0)),
15✔
134
    spam: Math.min(1, Math.max(0, Number(parsed.spam) || 0)),
18✔
135
    harassment: Math.min(1, Math.max(0, Number(parsed.harassment) || 0)),
18✔
136
  };
137

138
  const thresholds = mergedConfig.thresholds;
17✔
139
  const triggeredCategories = [];
17✔
140

141
  if (scores.toxicity >= thresholds.toxicity) triggeredCategories.push('toxicity');
17✔
142
  if (scores.spam >= thresholds.spam) triggeredCategories.push('spam');
14✔
143
  if (scores.harassment >= thresholds.harassment) triggeredCategories.push('harassment');
14✔
144

145
  const flagged = triggeredCategories.length > 0;
14✔
146

147
  const actionPriority = { ban: 5, kick: 4, timeout: 3, warn: 2, delete: 2, flag: 1, none: -1 };
14✔
148
  let action = 'none';
14✔
149
  for (const categoryName of triggeredCategories) {
14✔
150
    const categoryAction = mergedConfig.actions[categoryName] ?? 'flag';
17!
151
    if ((actionPriority[categoryAction] ?? 0) > (actionPriority[action] ?? -1)) {
17!
152
      action = categoryAction;
15✔
153
    }
154
  }
155

156
  return {
14✔
157
    flagged,
158
    scores,
159
    categories: triggeredCategories,
160
    reason: parsed.reason ?? 'No reason provided',
15✔
161
    action,
162
  };
163
}
164

165
/**
166
 * Send a flag embed to the moderation review channel.
167
 *
168
 * @param {import('discord.js').Message} message - The flagged Discord message
169
 * @param {import('discord.js').Client} client - Discord client
170
 * @param {Object} result - Analysis result
171
 * @param {Object} autoModConfig - AI auto-mod config
172
 */
173
async function sendFlagEmbed(message, client, result, autoModConfig) {
174
  const channelId = autoModConfig.flagChannelId;
7✔
175
  if (!channelId) return;
7!
176

177
  const flagChannel = await fetchChannelCached(client, channelId, message.guild?.id).catch(
×
178
    () => null,
×
179
  );
180
  if (!flagChannel) return;
×
181

182
  const scoreBar = (score) => {
×
183
    const filled = Math.round(score * 10);
×
184
    return `${'█'.repeat(filled)}${'░'.repeat(10 - filled)} ${Math.round(score * 100)}%`;
×
185
  };
186

187
  const embed = new EmbedBuilder()
×
188
    .setColor(0xff6b6b)
189
    .setTitle('🤖 AI Auto-Mod Flag')
190
    .setDescription(`**Message flagged for review**\nAction taken: \`${result.action}\``)
191
    .addFields(
192
      { name: 'Author', value: `<@${message.author.id}> (${message.author.tag})`, inline: true },
193
      { name: 'Channel', value: `<#${message.channel.id}>`, inline: true },
194
      { name: 'Categories', value: result.categories.join(', ') || 'none', inline: true },
×
195
      { name: 'Message', value: (message.content || '*[no text]*').slice(0, 1024) },
7!
196
      {
197
        name: 'AI Scores',
198
        value: [
199
          `Toxicity:   ${scoreBar(result.scores.toxicity)}`,
200
          `Spam:       ${scoreBar(result.scores.spam)}`,
201
          `Harassment: ${scoreBar(result.scores.harassment)}`,
202
        ].join('\n'),
203
      },
204
      { name: 'Reason', value: result.reason.slice(0, 512) },
205
      { name: 'Jump Link', value: `[View Message](${message.url})` },
206
    )
207
    .setFooter({ text: `Message ID: ${message.id}` })
208
    .setTimestamp();
209

210
  await safeSend(flagChannel, { embeds: [embed] });
7✔
211
}
212

213
/**
214
 * Execute the moderation action on the offending message/member.
215
 *
216
 * @param {import('discord.js').Message} message - The flagged message
217
 * @param {import('discord.js').Client} client - Discord client
218
 * @param {Object} result - Analysis result
219
 * @param {Object} autoModConfig - AI auto-mod config
220
 * @param {Object} guildConfig - Full guild config
221
 */
222
async function executeAction(message, client, result, autoModConfig, _guildConfig) {
223
  const { member, guild } = message;
7✔
224

225
  const reason = `AI Auto-Mod: ${result.categories.join(', ')} — ${result.reason}`;
7✔
226
  const botId = client.user?.id ?? 'bot';
7!
227
  const botTag = client.user?.tag ?? 'Bot#0000';
7!
228

229
  if (autoModConfig.autoDelete) {
7✔
230
    await message.delete().catch(() => {});
1✔
231
  }
232

233
  switch (result.action) {
7✔
234
    case 'warn':
235
      if (!member || !guild) break;
1!
236
      await createCase(guild.id, {
1✔
237
        action: 'warn',
238
        targetId: member.user.id,
239
        targetTag: member.user.tag,
240
        moderatorId: botId,
241
        moderatorTag: botTag,
242
        reason,
243
      }).catch((err) => logError('AI auto-mod: createCase (warn) failed', { error: err?.message }));
×
244
      break;
1✔
245

246
    case 'timeout': {
247
      if (!member || !guild) break;
1!
248
      const durationMs = autoModConfig.timeoutDurationMs ?? DEFAULTS.timeoutDurationMs;
1!
249
      await member
1✔
250
        .timeout(durationMs, reason)
251
        .catch((err) =>
252
          logError('AI auto-mod: timeout failed', { userId: member.user.id, error: err?.message }),
×
253
        );
254
      await createCase(guild.id, {
1✔
255
        action: 'timeout',
256
        targetId: member.user.id,
257
        targetTag: member.user.tag,
258
        moderatorId: botId,
259
        moderatorTag: botTag,
260
        reason,
261
        duration: `${durationMs}ms`,
262
      }).catch((err) =>
263
        logError('AI auto-mod: createCase (timeout) failed', { error: err?.message }),
×
264
      );
265
      break;
1✔
266
    }
267

268
    case 'kick':
269
      if (!member || !guild) break;
1!
270
      await member
1✔
271
        .kick(reason)
272
        .catch((err) =>
273
          logError('AI auto-mod: kick failed', { userId: member.user.id, error: err?.message }),
×
274
        );
275
      await createCase(guild.id, {
1✔
276
        action: 'kick',
277
        targetId: member.user.id,
278
        targetTag: member.user.tag,
279
        moderatorId: botId,
280
        moderatorTag: botTag,
281
        reason,
282
      }).catch((err) => logError('AI auto-mod: createCase (kick) failed', { error: err?.message }));
×
283
      break;
1✔
284

285
    case 'ban':
286
      if (!member || !guild) break;
1!
287
      await guild.members
1✔
288
        .ban(member.user.id, { reason, deleteMessageSeconds: 0 })
289
        .catch((err) =>
290
          logError('AI auto-mod: ban failed', { userId: member.user.id, error: err?.message }),
×
291
        );
292
      await createCase(guild.id, {
1✔
293
        action: 'ban',
294
        targetId: member.user.id,
295
        targetTag: member.user.tag,
296
        moderatorId: botId,
297
        moderatorTag: botTag,
298
        reason,
299
      }).catch((err) => logError('AI auto-mod: createCase (ban) failed', { error: err?.message }));
×
300
      break;
1✔
301

302
    case 'delete':
303
      await message.delete().catch(() => {});
2✔
304
      break;
2✔
305

306
    default:
307
      break;
1✔
308
  }
309

310
  await sendFlagEmbed(message, client, result, autoModConfig).catch((err) =>
7✔
311
    logError('AI auto-mod: sendFlagEmbed failed', { error: err?.message }),
×
312
  );
313
}
314

315
/**
316
 * Check a Discord message with AI auto-moderation.
317
 * Returns early (no action) for bots, exempt users, or disabled config.
318
 *
319
 * @param {import('discord.js').Message} message - Incoming Discord message
320
 * @param {import('discord.js').Client} client - Discord client
321
 * @param {Object} guildConfig - Merged guild config
322
 * @returns {Promise<{flagged: boolean, action?: string, categories?: string[]}>}
323
 */
324
export async function checkAiAutoMod(message, client, guildConfig) {
325
  const autoModConfig = getAiAutoModConfig(guildConfig);
39✔
326

327
  if (!autoModConfig.enabled) {
39✔
328
    return { flagged: false };
27✔
329
  }
330

331
  if (message.author.bot) {
12✔
332
    return { flagged: false };
1✔
333
  }
334

335
  if (isExempt(message, guildConfig)) {
11✔
336
    return { flagged: false };
1✔
337
  }
338

339
  const exemptRoleIds = autoModConfig.exemptRoleIds ?? [];
10!
340
  if (exemptRoleIds.length > 0 && message.member) {
39✔
341
    const hasExemptRole = message.member.roles.cache.some((memberRole) =>
1✔
342
      exemptRoleIds.includes(memberRole.id),
×
343
    );
344
    if (hasExemptRole) return { flagged: false };
1!
345
  }
346

347
  if (!message.content || message.content.trim().length === 0) {
9✔
348
    return { flagged: false };
1✔
349
  }
350

351
  try {
8✔
352
    const result = await analyzeMessage(message.content, autoModConfig);
8✔
353

354
    if (!result.flagged) {
7!
355
      return { flagged: false };
×
356
    }
357

358
    warn('AI auto-mod: flagged message', {
7✔
359
      userId: message.author.id,
360
      guildId: message.guild?.id,
361
      categories: result.categories,
362
      action: result.action,
363
      scores: result.scores,
364
    });
365

366
    info('AI auto-mod: executing action', {
39✔
367
      action: result.action,
368
      userId: message.author.id,
369
    });
370

371
    await executeAction(message, client, result, autoModConfig, guildConfig);
39✔
372

373
    return { flagged: true, action: result.action, categories: result.categories };
7✔
374
  } catch (err) {
375
    logError('AI auto-mod: analysis failed', {
1✔
376
      channelId: message.channel.id,
377
      userId: message.author.id,
378
      error: err?.message,
379
    });
380
    return { flagged: false };
1✔
381
  }
382
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc