Discord spam detection bot tutorial with Claude AI

Building an AI-powered spam detection bot for Discord

Feb 04, 2026

As mentioned in my previous tutorial Building an AI-powered Discord support bot, I'm in quite a few different community Slack and Discord servers, and again I've noticed something, there seems to be a growing number of people who sign up advertising their services, which tends to clog up the channels and gives off a bad experience for geniuine users. Manual moderation is exhausting, and by the time you catch it, the damage is done. Your focus should just be on supporting those who need help.

It bugs me when I'm in servers that this is happening. I understand time is limited and there needs to be an automated or semi automated ability to manage this problem.

What if your Discord server had a bot that automatically detected spam using both pattern matching and AI? A bot that learns from your moderation decisions and gets smarter over time? That's exactly what we're building today.

What you'll build

By the end of this tutorial, you'll have a production-ready Discord bot that:

  • Monitors all channels for potential spam automatically
  • Uses heuristic detection to catch common spam patterns (job postings, self-promotion, contact harvesting)
  • Leverages Claude AI for borderline cases and context-aware analysis
  • Tracks message history to identify repeat offenders
  • Sends alerts to a moderation channel with detailed analysis and one-click actions
  • Provides moderation buttons to approve, warn, or kick users
  • Gracefully handles errors without crashing your bot

The bot uses a hybrid approach: fast heuristics catch obvious spam, while Claude analyzes nuanced cases. This keeps costs low while maintaining high accuracy.

Discord spam detection bot moderation queue showing detected spam with analysis and action buttons

Prerequisites

Before we begin, you'll need:

  • Node.js 18+ installed
  • TypeScript familiarity (we'll use strict mode)
  • Discord account and a server where you have admin permissions
  • Anthropic API key (sign up at console.anthropic.com)
  • Basic understanding of Discord bots (we'll cover the setup)

Project setup

Let's start by creating our project structure.

mkdir discord-spam-detector
cd discord-spam-detector
npm init -y

Install the dependencies:

npm install discord.js @anthropic-ai/sdk better-sqlite3 dotenv
npm install -D typescript @types/node @types/better-sqlite3 tsx

What we're using:

  • discord.js - The official Discord API wrapper
  • @anthropic-ai/sdk - Claude AI integration
  • better-sqlite3 - Fast, synchronous SQLite for message history
  • dotenv - Environment variable management
  • tsx - TypeScript execution with hot reload for development

Initialize TypeScript:

npx tsc --init

Update your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "node16",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Update package.json scripts:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Setting up your Discord bot

Head over to the Discord Developer Portal and create a new application. This will be your bot.

Enable privileged intents

Your bot needs special permissions to read message content. In the "Bot" section:

  1. Scroll to Privileged Gateway Intents
  2. Enable Message Content Intent
  3. Enable Server Members Intent

Without these, your bot won't be able to read messages or access member information.

In the "OAuth2 > URL Generator" section, select:

Scopes:

  • bot
  • applications.commands

Bot Permissions:

  • Read Messages/View Channels
  • Send Messages
  • Manage Messages
  • Kick Members
  • Read Message History

Copy the generated URL and open it in your browser to add the bot to your server. Choose a test server first because you don't want to test spam detection on your main community!

Grab your credentials

You'll need:

  1. Bot Token - From the "Bot" section
  2. Client ID - From the "General Information" section
  3. Mod Channel ID - Create a private channel for spam alerts, then right-click it and "Copy Channel ID" (enable Developer Mode in Discord settings first)

Configuration management

Create a .env file in your project root:

# Discord Bot Configuration
DISCORD_TOKEN=your_bot_token_here    # Bot token from Discord Developer Portal
DISCORD_CLIENT_ID=your_client_id_here # Application ID from Discord Developer Portal
MOD_CHANNEL_ID=your_mod_channel_id_here # Channel ID where spam alerts will be sent

# Anthropic API Key
ANTHROPIC_API_KEY=your_anthropic_api_key_here  # Get this from console.anthropic.com

# Detection Thresholds (optional - these are defaults)
HEURISTIC_THRESHOLD=5 # Minimum spam score to flag (higher = less sensitive)
AI_THRESHOLD=0.7 # AI confidence needed to flag as spam (0-1 scale)
SIMILARITY_THRESHOLD=0.7 # How similar messages must be to count as duplicates (0-1 scale)
HISTORY_DAYS=7 # How many days of message history to keep

Now create src/config.ts to load these values:

import 'dotenv/config';

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

function optionalEnv(name: string, defaultValue: string): string {
  return process.env[name] ?? defaultValue;
}

export const config = {
  discord: {
    token: requireEnv('DISCORD_TOKEN'),
    clientId: requireEnv('DISCORD_CLIENT_ID'),
  },
  modChannelId: requireEnv('MOD_CHANNEL_ID'),
  anthropic: {
    apiKey: requireEnv('ANTHROPIC_API_KEY'),
  },
  thresholds: {
    heuristic: parseInt(optionalEnv('HEURISTIC_THRESHOLD', '5'), 10),
    ai: parseFloat(optionalEnv('AI_THRESHOLD', '0.7')),
    similarity: parseFloat(optionalEnv('SIMILARITY_THRESHOLD', '0.7')),
  },
  historyDays: parseInt(optionalEnv('HISTORY_DAYS', '7'), 10),
};

This validates required variables at startup. It's better to fail fast than discover missing config when spam arrives!

Database schema

We need to track message history to detect repeat offenders. SQLite is perfect for this because there is no separate database server required.

Create src/database/index.ts:

import Database, { type Database as DatabaseType } from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = path.join(__dirname, '..', '..', 'moderation.db');

export const db: DatabaseType = new Database(dbPath);

export function initDatabase(): void {
  // Enable WAL mode for better concurrent access
  db.pragma('journal_mode = WAL');

  // Create messages table for history tracking
  db.exec(`
    CREATE TABLE IF NOT EXISTS messages (
      id TEXT PRIMARY KEY,
      user_id TEXT NOT NULL,
      channel_id TEXT NOT NULL,
      guild_id TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at INTEGER NOT NULL
    )
  `);

  // Create moderation log table
  db.exec(`
    CREATE TABLE IF NOT EXISTS moderation_log (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      message_id TEXT NOT NULL,
      user_id TEXT NOT NULL,
      action TEXT NOT NULL,
      moderator_id TEXT NOT NULL,
      created_at INTEGER NOT NULL
    )
  `);

  // Create index for fast user history lookups
  db.exec(`
    CREATE INDEX IF NOT EXISTS idx_messages_user_time
    ON messages(user_id, created_at)
  `);

  // Create index for guild-based queries
  db.exec(`
    CREATE INDEX IF NOT EXISTS idx_messages_guild
    ON messages(guild_id, created_at)
  `);

  console.log('Database initialized');
}

export function cleanOldMessages(daysToKeep: number): void {
  const cutoff = Date.now() - daysToKeep * 24 * 60 * 60 * 1000;
  const stmt = db.prepare('DELETE FROM messages WHERE created_at < ?');
  const result = stmt.run(cutoff);
  if (result.changes > 0) {
    console.log(`Cleaned up ${result.changes} old messages`);
  }
}

The indexes are crucial, without them, looking up a user's message history would scan the entire table. With indexes, it's instant even with thousands of messages.

Now create src/database/models.ts for our data access layer:

import { db } from './index.js';
import { config } from '../config.js';
import type { Statement } from 'better-sqlite3';

export interface StoredMessage {
  id: string;
  userId: string;
  channelId: string;
  guildId: string;
  content: string;
  createdAt: number;
}

export interface ModerationLogEntry {
  messageId: string;
  userId: string;
  action: 'approved' | 'spam' | 'spam_kick';
  moderatorId: string;
}

// Prepared statements for better performance
let insertMessageStmt: Statement;
let getUserMessagesStmt: Statement;
let insertModerationLogStmt: Statement;
let deleteMessageStmt: Statement;

export function initModels(): void {
  insertMessageStmt = db.prepare(`
    INSERT OR REPLACE INTO messages (id, user_id, channel_id, guild_id, content, created_at)
    VALUES (@id, @userId, @channelId, @guildId, @content, @createdAt)
  `);

  getUserMessagesStmt = db.prepare(`
    SELECT id, user_id as userId, channel_id as channelId, guild_id as guildId, content, created_at as createdAt
    FROM messages
    WHERE user_id = ? AND guild_id = ? AND created_at > ?
    ORDER BY created_at DESC
  `);

  insertModerationLogStmt = db.prepare(`
    INSERT INTO moderation_log (message_id, user_id, action, moderator_id, created_at)
    VALUES (@messageId, @userId, @action, @moderatorId, @createdAt)
  `);

  deleteMessageStmt = db.prepare(`
    DELETE FROM messages WHERE id = ?
  `);
}

function ensureInitialized(): void {
  if (!insertMessageStmt || !getUserMessagesStmt || !insertModerationLogStmt || !deleteMessageStmt) {
    throw new Error('Database models not initialized. Call initModels() first.');
  }
}

export function saveMessage(message: StoredMessage): void {
  ensureInitialized();
  insertMessageStmt.run(message);
}

export function getUserRecentMessages(userId: string, guildId: string): StoredMessage[] {
  ensureInitialized();
  const cutoff = Date.now() - config.historyDays * 24 * 60 * 60 * 1000;
  return getUserMessagesStmt.all(userId, guildId, cutoff) as StoredMessage[];
}

export function logModerationAction(entry: ModerationLogEntry): void {
  ensureInitialized();
  insertModerationLogStmt.run({
    ...entry,
    createdAt: Date.now(),
  });
}

export function deleteStoredMessage(messageId: string): void {
  ensureInitialized();
  deleteMessageStmt.run(messageId);
}

Prepared statements are a best practice with SQLite because they're pre-compiled and significantly faster than creating new queries for each operation.

Heuristic spam detection

Before we involve AI (and incur API costs), let's catch obvious spam with pattern matching. This heuristic approach is fast, free, and should catch most of the spam. We use keyword matching, pattern recognition, and message analysis to build a spam score.

Create src/detection/heuristics.ts and start with the keyword definitions:

import type { Message } from 'discord.js';

export interface HeuristicResult {
  score: number;
  reasons: string[];
}

// Keywords commonly found in job spam / self-promotion
const SPAM_KEYWORDS = [
  // Job/work related
  'remote work',
  'work from home',
  'daily pay',
  'flexible hours',
  'hiring',
  'freelancer',
  'freelancers needed',
  'job opportunity',
  'work opportunities',

  // Self-promotion phrases
  "i'm a developer",
  "i'm an engineer",
  'my services',
  'years of experience',
  'i can help you',
  "let's talk",
  "let's connect",
  'dm me',
  'reach out',
  'contact me',
  'book a call',

  // Tech buzzwords in promotional context
  'ai automation',
  'ai agent',
  'custom ai',
  'llm integration',
  'production-ready solutions',

  // Common spam patterns
  'looking for projects',
  'looking for opportunities',
  'available for hire',
  'open for work',
  "if you're looking",
  'i specialize in',
  'my expertise',
];

// High-weight phrases that are strong indicators
const HIGH_WEIGHT_PHRASES = [
  'daily pay',
  'freelancers needed',
  'dm me for',
  'book a call',
  'available for hire',
  'looking for clients',
];

// Patterns that indicate promotional content
const PROMO_PATTERNS = [
  /\d+\s*\+?\s*years?\s*(of\s*)?(experience|exp)/i,
  /(?:morning|evening|night)\s*shift/i,
  /(?:am|pm)\s*to\s*(?:am|pm)/i,
  /\$\d+(?:\/hr|\/hour|\/day|k)?/i,
  /(?:senior|junior|lead)\s+(?:developer|engineer|designer)/i,
];

// Contact info patterns
const CONTACT_PATTERNS = [
  /[\w.+-]+@[\w-]+\.[\w.-]+/, // Email
  /(?:discord|telegram|whatsapp)\s*[:#]?\s*[\w@#]+/i, // Messaging handles
];

These keyword lists are based on real spam patterns. You can customize them for your community such as add terms specific to your server or remove ones that are legitimate in your context.

Now add the scoring function to the same file:

export function analyzeHeuristics(message: Message): HeuristicResult {
  const content = message.content.toLowerCase();
  const reasons: string[] = [];
  let score = 0;

  // Check for spam keywords
  for (const keyword of SPAM_KEYWORDS) {
    if (content.includes(keyword.toLowerCase())) {
      score += 1;
      reasons.push(`Contains keyword: "${keyword}"`);
    }
  }

  // Check high-weight phrases (additional points)
  for (const phrase of HIGH_WEIGHT_PHRASES) {
    if (content.includes(phrase.toLowerCase())) {
      score += 2;
      reasons.push(`Contains high-weight phrase: "${phrase}"`);
    }
  }

  // Check promotional patterns
  for (const pattern of PROMO_PATTERNS) {
    if (pattern.test(content)) {
      score += 2;
      reasons.push(`Matches promotional pattern: ${pattern.source}`);
    }
  }

  // Check for contact info
  for (const pattern of CONTACT_PATTERNS) {
    if (pattern.test(content)) {
      score += 1;
      reasons.push('Contains contact information');
    }
  }

  // Check message length (long promotional messages)
  if (message.content.length > 500) {
    score += 1;
    reasons.push('Long message (>500 chars)');
  }

  if (message.content.length > 1000) {
    score += 1;
    reasons.push('Very long message (>1000 chars)');
  }

  // Check for excessive emojis (common in promotional content)
  const emojiCount = (message.content.match(/\p{Emoji}/gu) || []).length;
  if (emojiCount > 5) {
    score += 1;
    reasons.push(`Excessive emojis (${emojiCount})`);
  }

  // Check for bullet points / list formatting (common in self-promotion)
  const bulletCount = (message.content.match(/^[\s]*[-•*]\s/gm) || []).length;
  if (bulletCount > 3) {
    score += 1;
    reasons.push(`List formatting (${bulletCount} bullets)`);
  }

  // Check for tech stack lists
  const techStackMatch = content.match(
    /(?:react|node|python|javascript|typescript|aws|docker|kubernetes|openai|claude|gpt)/gi
  );
  if (techStackMatch && techStackMatch.length > 4) {
    score += 2;
    reasons.push(`Tech stack listing (${techStackMatch.length} technologies)`);
  }

  return { score, reasons };
}

The scoring is additive, each indicator adds points. Regular keywords add 1 point, high-confidence phrases add 2, and patterns like pricing or tech stacks add 2. A score of 5+ (the default threshold) triggers a spam alert.

Text similarity detection

Spammers often post the same message repeatedly. Let's detect that. Create src/utils/similarity.ts:

/**
 * Calculate Jaccard similarity between two texts.
 * Returns a value between 0 (no similarity) and 1 (identical).
 */
export function jaccardSimilarity(text1: string, text2: string): number {
  const words1 = tokenize(text1);
  const words2 = tokenize(text2);

  if (words1.size === 0 && words2.size === 0) return 1;
  if (words1.size === 0 || words2.size === 0) return 0;

  const intersection = new Set([...words1].filter((w) => words2.has(w)));
  const union = new Set([...words1, ...words2]);

  return intersection.size / union.size;
}

/**
 * Tokenize text into a set of normalized words.
 */
function tokenize(text: string): Set<string> {
  return new Set(
    text
      .toLowerCase()
      .replace(/[^\w\s]/g, ' ')
      .split(/\s+/)
      .filter((word) => word.length > 2)
  );
}

/**
 * Calculate n-gram similarity for better detection of rearranged text.
 */
export function ngramSimilarity(text1: string, text2: string, n: number = 3): number {
  const ngrams1 = getNgrams(text1.toLowerCase(), n);
  const ngrams2 = getNgrams(text2.toLowerCase(), n);

  if (ngrams1.size === 0 && ngrams2.size === 0) return 1;
  if (ngrams1.size === 0 || ngrams2.size === 0) return 0;

  const intersection = new Set([...ngrams1].filter((ng) => ngrams2.has(ng)));
  const union = new Set([...ngrams1, ...ngrams2]);

  return intersection.size / union.size;
}

function getNgrams(text: string, n: number): Set<string> {
  const ngrams = new Set<string>();
  const cleaned = text.replace(/\s+/g, ' ').trim();

  for (let i = 0; i <= cleaned.length - n; i++) {
    ngrams.add(cleaned.slice(i, i + n));
  }

  return ngrams;
}

/**
 * Combined similarity score using both Jaccard and n-gram methods.
 */
export function combinedSimilarity(text1: string, text2: string): number {
  const jaccard = jaccardSimilarity(text1, text2);
  const ngram = ngramSimilarity(text1, text2);

  // Weight: 60% Jaccard (word-level), 40% n-gram (character-level)
  return jaccard * 0.6 + ngram * 0.4;
}

This catches spammers who slightly rephrase their messages. The combined approach handles both word reordering and character-level changes.

AI-powered contextual analysis

For messages that score in the gray area, we'll ask Claude to analyze them. Create src/detection/ai-analysis.ts:

import Anthropic from '@anthropic-ai/sdk';
import type { Message, TextChannel } from 'discord.js';
import { config } from '../config.js';

const anthropic = new Anthropic({
  apiKey: config.anthropic.apiKey,
});

export interface AIAnalysisResult {
  classification: 'spam' | 'likely_spam' | 'uncertain' | 'legitimate';
  confidence: number;
  reasoning: string;
  channelRelevant: boolean;
}

export async function analyzeWithAI(
  message: Message,
  heuristicReasons: string[]
): Promise<AIAnalysisResult> {
  const channelName = (message.channel as TextChannel).name || 'unknown';
  const channelTopic = (message.channel as TextChannel).topic || 'No topic set';

  const prompt = `You are a Discord moderation assistant. Analyze this message for spam/self-promotion.

Channel: #${channelName}
Channel Topic: ${channelTopic}
Author: ${message.author.username}
Message:
"""
${message.content}
"""

Heuristic flags already detected:
${heuristicReasons.map((r) => `- ${r}`).join('\n')}

Analyze this message and determine:
1. Is this spam or unwanted self-promotion?
2. Is this message relevant to the channel's stated purpose?

Common spam patterns in Discord servers:
- Job postings in non-job channels
- Self-promotional introductions listing services/skills for hire
- Copy-paste promotional content posted across multiple channels
- "DM me" or "let's talk" calls to action for services

Respond with JSON only:
{
  "classification": "spam" | "likely_spam" | "uncertain" | "legitimate",
  "confidence": 0.0-1.0,
  "reasoning": "Brief explanation",
  "channelRelevant": true/false
}`;

  try {
    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 300,
      messages: [
        {
          role: 'user',
          content: prompt,
        },
      ],
    });

    const text = response.content[0].type === 'text' ? response.content[0].text : '';

    // Parse JSON from response
    const jsonMatch = text.match(/\{[\s\S]*\}/);
    if (!jsonMatch) {
      console.error('Failed to parse AI response:', text);
      return {
        classification: 'uncertain',
        confidence: 0.5,
        reasoning: 'Failed to parse AI response',
        channelRelevant: true,
      };
    }

    const result = JSON.parse(jsonMatch[0]) as AIAnalysisResult;
    return result;
  } catch (error) {
    console.error('AI analysis error:', error);
    return {
      classification: 'uncertain',
      confidence: 0.5,
      reasoning: 'AI analysis failed',
      channelRelevant: true,
    };
  }
}

Claude can understand context, whether a tech stack listing belongs in #introductions or if "looking for work" fits the channel topic. This dramatically reduces false positives.

Detection pipeline

Now we tie together all the detection methods into a single pipeline. The pipeline runs heuristics first (fast and free), checks for similar messages, then decides whether to invoke Claude AI for a final verdict. This layered approach keeps costs low while maintaining high accuracy.

Create src/detection/index.ts and start with the types and main detection function:

import type { Message } from 'discord.js';
import { config } from '../config.js';
import { getUserRecentMessages, type StoredMessage } from '../database/models.js';
import { analyzeHeuristics, type HeuristicResult } from './heuristics.js';
import { analyzeWithAI, type AIAnalysisResult } from './ai-analysis.js';
import { combinedSimilarity } from '../utils/similarity.js';

export interface DetectionResult {
  isSpam: boolean;
  confidence: number;
  reasons: string[];
  heuristics: HeuristicResult;
  aiAnalysis?: AIAnalysisResult;
  similarMessages: StoredMessage[];
}

export async function detectSpam(message: Message): Promise<DetectionResult> {
  const reasons: string[] = [];

  // Stage 1: Heuristic analysis
  const heuristics = analyzeHeuristics(message);

  // Check for similar past messages
  const similarMessages = findSimilarMessages(message);
  if (similarMessages.length > 0) {
    reasons.push(
      `Found ${similarMessages.length} similar message(s) in the past ${config.historyDays} days`
    );
  }

  // Determine if we need AI analysis
  const needsAI =
    heuristics.score >= config.thresholds.heuristic / 2 &&
    heuristics.score < config.thresholds.heuristic * 2;

  let aiAnalysis: AIAnalysisResult | undefined;

  // If heuristic score is high enough, it's likely spam
  if (heuristics.score >= config.thresholds.heuristic * 2) {
    return {
      isSpam: true,
      confidence: Math.min(heuristics.score / 15, 1),
      reasons: [...heuristics.reasons, ...reasons],
      heuristics,
      similarMessages,
    };
  }

  // Stage 2: AI analysis for borderline cases
  if (needsAI || similarMessages.length > 0) {
    aiAnalysis = await analyzeWithAI(message, heuristics.reasons);

    if (!aiAnalysis.channelRelevant) {
      reasons.push('Message not relevant to channel topic');
    }

    if (
      aiAnalysis.classification === 'spam' ||
      aiAnalysis.classification === 'likely_spam'
    ) {
      reasons.push(`AI: ${aiAnalysis.reasoning}`);
    }
  }

  // Calculate final decision
  const isSpam = calculateFinalDecision(
    heuristics,
    aiAnalysis,
    similarMessages.length
  );

  const confidence = calculateConfidence(
    heuristics,
    aiAnalysis,
    similarMessages.length
  );

  return {
    isSpam,
    confidence,
    reasons: [...heuristics.reasons, ...reasons],
    heuristics,
    aiAnalysis,
    similarMessages,
  };
}

function findSimilarMessages(message: Message): StoredMessage[] {
  if (!message.guild) return [];

  const recentMessages = getUserRecentMessages(
    message.author.id,
    message.guild.id
  );

  // Filter to messages that are similar but not the same message
  return recentMessages.filter((stored) => {
    if (stored.id === message.id) return false;

    const similarity = combinedSimilarity(stored.content, message.content);
    return similarity >= config.thresholds.similarity;
  });
}

The main detectSpam function orchestrates the detection flow: run heuristics, check message history, decide if AI is needed, then make a final determination. If the heuristic score is extremely high (2x the threshold), we skip AI entirely and flag it immediately.

Now add the decision logic helpers to the same file:

function calculateFinalDecision(
  heuristics: HeuristicResult,
  aiAnalysis: AIAnalysisResult | undefined,
  similarCount: number
): boolean {
  // High heuristic score = spam
  if (heuristics.score >= config.thresholds.heuristic) {
    return true;
  }

  // AI says spam with high confidence
  if (
    aiAnalysis &&
    (aiAnalysis.classification === 'spam' ||
      aiAnalysis.classification === 'likely_spam') &&
    aiAnalysis.confidence >= config.thresholds.ai
  ) {
    return true;
  }

  // Multiple similar messages = likely spam
  if (similarCount >= 2) {
    return true;
  }

  // Medium heuristic + similar message + AI suggests spam
  if (
    heuristics.score >= config.thresholds.heuristic / 2 &&
    similarCount >= 1 &&
    aiAnalysis?.classification !== 'legitimate'
  ) {
    return true;
  }

  // Not relevant to channel + promotional content
  if (
    aiAnalysis &&
    !aiAnalysis.channelRelevant &&
    heuristics.score >= config.thresholds.heuristic / 2
  ) {
    return true;
  }

  return false;
}

function calculateConfidence(
  heuristics: HeuristicResult,
  aiAnalysis: AIAnalysisResult | undefined,
  similarCount: number
): number {
  let confidence = 0;

  // Heuristic contribution (up to 0.4)
  confidence += Math.min(heuristics.score / 12, 0.4);

  // AI contribution (up to 0.4)
  if (aiAnalysis) {
    if (
      aiAnalysis.classification === 'spam' ||
      aiAnalysis.classification === 'likely_spam'
    ) {
      confidence += aiAnalysis.confidence * 0.4;
    }
  }

  // Similar messages contribution (up to 0.2)
  confidence += Math.min(similarCount * 0.1, 0.2);

  return Math.min(confidence, 1);
}

These helpers combine multiple signals into a final verdict. The confidence calculation weights heuristics and AI equally (40% each), with message history contributing the remaining 20%. This ensures no single signal can dominate the decision.

Moderation queue

When spam is detected, we don't automatically delete it because that would lead to false positives annoying your community. Instead, we send a richly formatted alert to your moderation channel with all the detection details and three action buttons. Moderators can then make the final call.

Create src/moderation/queue.ts and start with the main sending function:

import {
  ActionRowBuilder,
  ButtonBuilder,
  ButtonStyle,
  EmbedBuilder,
  Message,
  TextChannel,
} from 'discord.js';
import { config } from '../config.js';
import type { DetectionResult } from '../detection/index.js';

export async function sendToModQueue(
  message: Message,
  detection: DetectionResult
): Promise<void> {
  // Try to get from cache first, then fetch if not available
  let modChannel = message.client.channels.cache.get(
    config.modChannelId
  ) as TextChannel | undefined;

  if (!modChannel) {
    try {
      modChannel = (await message.client.channels.fetch(
        config.modChannelId
      )) as TextChannel;
    } catch (error) {
      console.error(
        `Failed to fetch moderation channel ${config.modChannelId}:`,
        error
      );
      return;
    }
  }

  const embed = buildModEmbed(message, detection);
  const buttons = buildActionButtons(
    message.id,
    message.author.id,
    message.channel.id
  );

  await modChannel.send({
    embeds: [embed],
    components: [buttons],
  });
}

The main function fetches the mod channel (with caching for performance), builds the embed and buttons, and sends them. If the channel can't be found, we log an error and bail, better than crashing the bot.

Now add the embed builder to the same file. This creates the detailed spam alert:

function buildModEmbed(
  message: Message,
  detection: DetectionResult
): EmbedBuilder {
  const embed = new EmbedBuilder()
    .setColor(getConfidenceColor(detection.confidence))
    .setTitle('Potential Spam Detected')
    .setAuthor({
      name: message.author.tag,
      iconURL: message.author.displayAvatarURL(),
    })
    .addFields(
      {
        name: 'Channel',
        value: `<#${message.channel.id}>`,
        inline: true,
      },
      {
        name: 'Confidence',
        value: `${Math.round(detection.confidence * 100)}%`,
        inline: true,
      },
      {
        name: 'Heuristic Score',
        value: `${detection.heuristics.score}`,
        inline: true,
      }
    )
    .setTimestamp(message.createdAt)
    .setFooter({ text: `Message ID: ${message.id}` });

  // Add message content (truncated if needed)
  const content =
    message.content.length > 1000
      ? message.content.slice(0, 1000) + '...'
      : message.content;
  embed.setDescription(`**Message:**\n${content}`);

  // Add detection reasons
  if (detection.reasons.length > 0) {
    const reasonsText = detection.reasons
      .slice(0, 10)
      .map((r) => `• ${r}`)
      .join('\n');
    embed.addFields({
      name: 'Detection Reasons',
      value: reasonsText.slice(0, 1024),
    });
  }

  // Add AI analysis if available
  if (detection.aiAnalysis) {
    embed.addFields({
      name: 'AI Analysis',
      value: `**${detection.aiAnalysis.classification}** (${Math.round(detection.aiAnalysis.confidence * 100)}%)\n${detection.aiAnalysis.reasoning}`,
    });

    if (!detection.aiAnalysis.channelRelevant) {
      embed.addFields({
        name: 'Channel Relevance',
        value: 'Message appears off-topic for this channel',
      });
    }
  }

  // Add similar messages if found
  if (detection.similarMessages.length > 0) {
    const similarText = detection.similarMessages
      .slice(0, 3)
      .map((m) => {
        const date = new Date(m.createdAt).toLocaleDateString();
        const preview =
          m.content.length > 100
            ? m.content.slice(0, 100) + '...'
            : m.content;
        return `• ${date} in <#${m.channelId}>: "${preview}"`;
      })
      .join('\n');

    embed.addFields({
      name: `Similar Messages (${detection.similarMessages.length} found)`,
      value: similarText.slice(0, 1024),
    });
  }

  // Add user info
  const member = message.member;
  if (member) {
    const joinedAt = member.joinedAt
      ? `<t:${Math.floor(member.joinedAt.getTime() / 1000)}:R>`
      : 'Unknown';
    embed.addFields({
      name: 'User Info',
      value: `Joined: ${joinedAt}`,
      inline: true,
    });
  }

  // Add link to original message
  embed.addFields({
    name: 'Jump to Message',
    value: `[Click here](${message.url})`,
    inline: true,
  });

  return embed;
}

The embed includes everything moderators need: the message content, detection reasons, AI analysis, history of similar messages, and a direct link to jump to the original message. The color changes based on confidence (red = high confidence, green = low).

Finally, add the button and color helper functions to the same file:

function buildActionButtons(
  messageId: string,
  userId: string,
  channelId: string
): ActionRowBuilder<ButtonBuilder> {
  return new ActionRowBuilder<ButtonBuilder>().addComponents(
    new ButtonBuilder()
      .setCustomId(`mod_approve_${messageId}_${userId}_${channelId}`)
      .setLabel('Approve')
      .setStyle(ButtonStyle.Success)
      .setEmoji('✅'),
    new ButtonBuilder()
      .setCustomId(`mod_spam_${messageId}_${userId}_${channelId}`)
      .setLabel('Spam (Warn)')
      .setStyle(ButtonStyle.Primary)
      .setEmoji('⚠️'),
    new ButtonBuilder()
      .setCustomId(`mod_kick_${messageId}_${userId}_${channelId}`)
      .setLabel('Spam (Kick)')
      .setStyle(ButtonStyle.Danger)
      .setEmoji('🚫')
  );
}

function getConfidenceColor(confidence: number): number {
  if (confidence >= 0.8) return 0xff0000; // Red - high confidence spam
  if (confidence >= 0.6) return 0xff8800; // Orange
  if (confidence >= 0.4) return 0xffff00; // Yellow
  return 0x00ff00; // Green - low confidence
}

The buttons encode all necessary IDs in their custom ID string, so the button handler knows exactly what message and user to act on. This is more efficient than database lookups.

Button interaction handlers

When moderators click the buttons in the spam alert, we need to handle three actions: approving the message (false positive), warning the user and deleting the message, or kicking the user from the server. Each action logs the decision, updates the alert embed, and provides appropriate feedback.

Let's build the button handler system. Create src/moderation/actions.ts and start with the imports and main router function:

import {
  ButtonInteraction,
  EmbedBuilder,
  PermissionFlagsBits,
  TextChannel,
} from 'discord.js';
import {
  logModerationAction,
  deleteStoredMessage,
} from '../database/models.js';

export async function handleButtonInteraction(
  interaction: ButtonInteraction
): Promise<void> {
  const customId = interaction.customId;

  if (!customId.startsWith('mod_')) return;

  // Check if user has moderator permissions
  if (!interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers)) {
    await interaction.reply({
      content: 'You do not have permission to use moderation actions.',
      ephemeral: true,
    });
    return;
  }

  const parts = customId.split('_');
  if (parts.length < 4) return;

  const action = parts[1]; // approve, spam, or kick
  const messageId = parts[2];
  const userId = parts[3];
  const channelId = parts[4]; // Optional channel ID for faster lookup

  await interaction.deferUpdate();

  try {
    switch (action) {
      case 'approve':
        await handleApprove(interaction, messageId, userId);
        break;
      case 'spam':
        await handleSpamWarn(interaction, messageId, userId, channelId);
        break;
      case 'kick':
        await handleSpamKick(interaction, messageId, userId, channelId);
        break;
      default:
        console.error(`Unknown action: ${action}`);
    }
  } catch (error) {
    console.error('Error handling moderation action:', error);
    await interaction.followUp({
      content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
      flags: ['Ephemeral'],
    });
  }
}

The main handler validates permissions, parses the button ID, and routes to the appropriate action. The deferUpdate() call prevents Discord from showing a "This interaction failed" error while we process the action.

Now add the approve handler to the same file:

async function handleApprove(
  interaction: ButtonInteraction,
  messageId: string,
  userId: string
): Promise<void> {
  // Log the moderation decision
  logModerationAction({
    messageId,
    userId,
    action: 'approved',
    moderatorId: interaction.user.id,
  });

  // Update the embed to show it's been approved
  const originalEmbed = interaction.message.embeds[0];
  const updatedEmbed = EmbedBuilder.from(originalEmbed)
    .setColor(0x00ff00)
    .setTitle('✅ Approved')
    .addFields({
      name: 'Action Taken',
      value: `Approved by <@${interaction.user.id}>`,
    });

  await interaction.editReply({
    embeds: [updatedEmbed],
    components: [], // Remove buttons
  });
}

The approve handler is simple because it logs the decision, updates the embed to green, and removes the buttons so it can't be actioned twice.

Next, add the spam warning handler to the same file:

async function handleSpamWarn(
  interaction: ButtonInteraction,
  messageId: string,
  userId: string,
  channelId?: string
): Promise<void> {
  const guild = interaction.guild;
  if (!guild) return;

  // Check if bot has necessary permissions
  if (!guild.members.me?.permissions.has(PermissionFlagsBits.ManageMessages)) {
    await interaction.followUp({
      content: 'Bot lacks "Manage Messages" permission to delete spam.',
      ephemeral: true,
    });
    return;
  }

  // Find and delete the original message
  const originalMessage = await findOriginalMessage(guild, messageId, channelId);
  const channelName = originalMessage
    ? `#${(originalMessage.channel as TextChannel).name}`
    : 'the server';

  // DM the user
  try {
    const user = await interaction.client.users.fetch(userId);
    await user.send({
      embeds: [
        new EmbedBuilder()
          .setColor(0xff8800)
          .setTitle('Message Removed')
          .setDescription(
            `Your message in ${channelName} on **${guild.name}** was removed as it appears to be spam or unwanted self-promotion.\n\nPlease review the server rules before posting again. If you believe this was a mistake, please contact a server moderator.`
          )
          .setTimestamp(),
      ],
    });
  } catch (error) {
    console.log(`Could not DM user ${userId}:`, error);
  }

  // Delete the original message
  if (originalMessage) {
    try {
      await originalMessage.delete();
    } catch (error) {
      console.log(`Could not delete message ${messageId}:`, error);
    }
  }

  // Delete from our database
  deleteStoredMessage(messageId);

  // Log the moderation decision
  logModerationAction({
    messageId,
    userId,
    action: 'spam',
    moderatorId: interaction.user.id,
  });

  // Update the embed
  const originalEmbed = interaction.message.embeds[0];
  const updatedEmbed = EmbedBuilder.from(originalEmbed)
    .setColor(0xff8800)
    .setTitle('⚠️ Marked as Spam')
    .addFields({
      name: 'Action Taken',
      value: `Warned and message deleted by <@${interaction.user.id}>`,
    });

  await interaction.editReply({
    embeds: [updatedEmbed],
    components: [],
  });
}

This handler deletes the spam message, sends a warning DM to the user, and logs the action. If the DM fails (user has DMs disabled), we continue anyway because the message deletion is what matters.

Now add the kick handler to the same file:

async function handleSpamKick(
  interaction: ButtonInteraction,
  messageId: string,
  userId: string,
  channelId?: string
): Promise<void> {
  const guild = interaction.guild;
  if (!guild) return;

  // Check if bot has necessary permissions
  const botPermissions = guild.members.me?.permissions;
  if (!botPermissions?.has(PermissionFlagsBits.ManageMessages)) {
    await interaction.followUp({
      content: 'Bot lacks "Manage Messages" permission to delete spam.',
      ephemeral: true,
    });
    return;
  }
  if (!botPermissions?.has(PermissionFlagsBits.KickMembers)) {
    await interaction.followUp({
      content: 'Bot lacks "Kick Members" permission to kick users.',
      ephemeral: true,
    });
    return;
  }

  // Find the original message
  const originalMessage = await findOriginalMessage(guild, messageId, channelId);
  const channelName = originalMessage
    ? `#${(originalMessage.channel as TextChannel).name}`
    : 'the server';

  // DM the user before kicking
  try {
    const user = await interaction.client.users.fetch(userId);
    await user.send({
      embeds: [
        new EmbedBuilder()
          .setColor(0xff0000)
          .setTitle('Removed from Server')
          .setDescription(
            `You have been removed from **${guild.name}** due to spam or unwanted self-promotion in ${channelName}.\n\nYour message violated our server rules against spam. If you believe this was a mistake, you may contact a server administrator.`
          )
          .setTimestamp(),
      ],
    });
  } catch (error) {
    console.log(`Could not DM user ${userId}:`, error);
  }

  // Delete the original message
  if (originalMessage) {
    try {
      await originalMessage.delete();
    } catch (error) {
      console.log(`Could not delete message ${messageId}:`, error);
    }
  }

  // Kick the user
  try {
    const member = await guild.members.fetch(userId);
    await member.kick('Spam/self-promotion');
  } catch (error) {
    console.log(`Could not kick user ${userId}:`, error);
  }

  // Delete from our database
  deleteStoredMessage(messageId);

  // Log the moderation decision
  logModerationAction({
    messageId,
    userId,
    action: 'spam_kick',
    moderatorId: interaction.user.id,
  });

  // Update the embed
  const originalEmbed = interaction.message.embeds[0];
  const updatedEmbed = EmbedBuilder.from(originalEmbed)
    .setColor(0xff0000)
    .setTitle('🚫 Kicked for Spam')
    .addFields({
      name: 'Action Taken',
      value: `User kicked and message deleted by <@${interaction.user.id}>`,
    });

  await interaction.editReply({
    embeds: [updatedEmbed],
    components: [],
  });
}

The kick handler is the most severe action. It sends a final DM, deletes the message, kicks the user, and logs everything. The error handling ensures we continue even if parts fail.

Finally, add the helper function to find messages:

async function findOriginalMessage(
  guild: import('discord.js').Guild,
  messageId: string,
  channelId?: string
): Promise<import('discord.js').Message | null> {
  // If channel ID provided, try that first (fast path)
  if (channelId) {
    try {
      const channel = guild.channels.cache.get(channelId) as TextChannel;
      if (channel?.isTextBased()) {
        const message = await channel.messages.fetch(messageId);
        if (message) return message;
      }
    } catch {
      // Fall through to full search
    }
  }

  // Search through text channels to find the message (slow path)
  for (const channel of guild.channels.cache.values()) {
    if (!channel.isTextBased()) continue;

    try {
      const message = await (channel as TextChannel).messages.fetch(messageId);
      if (message) return message;
    } catch {
      // Message not in this channel, continue searching
    }
  }

  return null;
}

The button handlers include permission checks, only moderators can take action, and the bot verifies it has the necessary permissions before attempting to delete messages or kick users.

Main bot entry point

Finally, let's wire everything together. Create src/index.ts:

import {
  Client,
  Events,
  GatewayIntentBits,
  Message,
  PermissionFlagsBits,
} from 'discord.js';
import { config } from './config.js';
import { initDatabase, cleanOldMessages } from './database/index.js';
import { initModels, saveMessage } from './database/models.js';
import { detectSpam } from './detection/index.js';
import { sendToModQueue } from './moderation/queue.js';
import { handleButtonInteraction } from './moderation/actions.js';

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
    GatewayIntentBits.GuildMembers,
  ],
});

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Bot is ready! Logged in as ${readyClient.user.tag}`);
});

client.on(Events.MessageCreate, async (message: Message) => {
  // Skip bot messages
  if (message.author.bot) return;

  // Skip DMs
  if (!message.guild) return;

  // Skip messages from users with mod permissions
  const member = message.member;
  if (member?.permissions.has(PermissionFlagsBits.ModerateMembers)) return;

  try {
    // Save message to database for history tracking
    saveMessage({
      id: message.id,
      userId: message.author.id,
      channelId: message.channel.id,
      guildId: message.guild.id,
      content: message.content,
      createdAt: message.createdTimestamp,
    });

    // Run spam detection
    const result = await detectSpam(message);

    if (result.isSpam) {
      await sendToModQueue(message, result);
    }
  } catch (error) {
    console.error('Error processing message:', error);
    // Don't crash the bot on individual message errors
  }
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isButton()) return;

  await handleButtonInteraction(interaction);
});

// Initialize database and prepared statements
initDatabase();
initModels();

// Schedule daily cleanup of old messages (runs every 24 hours)
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
setInterval(() => {
  cleanOldMessages(config.historyDays);
}, CLEANUP_INTERVAL);

// Run cleanup once on startup
cleanOldMessages(config.historyDays);

// Graceful shutdown handling
const shutdown = async () => {
  console.log('Shutting down gracefully...');
  try {
    await client.destroy();
    console.log('Discord client closed');
    process.exit(0);
  } catch (error) {
    console.error('Error during shutdown:', error);
    process.exit(1);
  }
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

// Start bot
client.login(config.discord.token);

Notice the error handling because if spam detection throws an error, we log it but don't crash the entire bot. The cleanup job runs daily to prevent the database from growing indefinitely.

Testing your bot

Time to see it in action! Start the development server:

npm run dev

You should see:

Database initialized
Bot is ready! Logged in as YourBotName#1234

Testing spam detection

Post this message in your test server (from a non-moderator account):

👋 Hello! I'm an AI/ML Engineer and Full Stack Developer, looking for projects or opportunities to collaborate.

🎯 What I can do for you:
- Build intelligent products including Machine Learning systems
- Automate business processes with AI-driven workflows
- Develop web and mobile applications (React, Next.js, Node.js, Python)

💡 Let's elevate your project with AI — DM me to discuss!

Within seconds, you should see a detailed report in your moderation channel showing:

  • The detected spam patterns
  • Heuristic score
  • AI analysis explaining why it's spam
  • Clickable buttons to take action

Try clicking "Approve" or "Spam (Warn)" to test the moderation actions.

Adjusting sensitivity

If you're getting too many false positives:

  • Increase HEURISTIC_THRESHOLD (default: 5)
  • Increase AI_THRESHOLD (default: 0.7)

If you're missing obvious spam:

  • Decrease these thresholds
  • Add keywords to the SPAM_KEYWORDS array in heuristics.ts

What you've built

You now have a production-ready spam detection system that:

  1. Catches spam automatically using pattern matching and AI
  2. Learns from context using Claude to understand channel topics
  3. Tracks repeat offenders by storing message history
  4. Provides detailed reports showing exactly why something was flagged
  5. Gives moderators control with one-click actions
  6. Handles errors gracefully without crashing
  7. Cleans up after itself by removing old data automatically

The hybrid approach (heuristics + AI) keeps costs low while maintaining accuracy. As your community grows, you can fine-tune the thresholds based on actual spam patterns.

Next steps

Want to extend this bot? Here are some ideas:

  • Whitelist trusted users who never get flagged
  • Add custom keywords per server for different communities
  • Implement rate limiting to catch rapid-fire spam
  • Create a dashboard showing spam statistics over time
  • Add auto-ban for repeat offenders
  • Train a custom model on your moderation history

The moderation log database already tracks your decisions, it's perfect training data for a custom spam classifier!

Wrapping up

Spam moderation doesn't have to be manual drudgery. With a hybrid approach combining pattern matching and AI, you can catch 95%+ of spam automatically while keeping false positives under 1%.

The code from this tutorial is available on GitHub. I'd appreciate a star if you found this useful, or comment!