DEV Community

Cover image for Build a Discord Bot That Alerts Your Team When Competitors Post
Olamide Olaniyan
Olamide Olaniyan

Posted on

Build a Discord Bot That Alerts Your Team When Competitors Post

My team used to manually check competitor accounts every morning. Five people, ten minutes each, scrolling through Instagram and TikTok at 9am.

That's 250 minutes per week of human time spent doing what a bot can do in 3 seconds.

So I built one. A Discord bot that watches competitor accounts and drops a notification the moment they post something new. Here's exactly how.

What We're Building

A Node.js bot that:

  1. Polls competitor profiles every 30 minutes
  2. Detects new posts since the last check
  3. Sends a rich Discord embed with the post details
  4. Tracks what's already been seen so you never get duplicate alerts

No AI, no fancy ML. Just polling + diffing + Discord webhooks.

The Stack

  • Node.js – runtime
  • SociaVault API – fetch competitor posts across Instagram and TikTok
  • Discord.js – send rich embeds to your server
  • node-cron – schedule polling intervals
  • SQLite (better-sqlite3) – track seen posts (no external DB needed)

Setup

mkdir competitor-alerts && cd competitor-alerts
npm init -y
npm install discord.js node-cron better-sqlite3 axios dotenv
Enter fullscreen mode Exit fullscreen mode
# .env
DISCORD_BOT_TOKEN=your_discord_bot_token
DISCORD_CHANNEL_ID=your_channel_id
SOCIAVAULT_API_KEY=your_api_key
Enter fullscreen mode Exit fullscreen mode

Step 1: The Database (Tracking What We've Seen)

We need to remember which posts we've already alerted on. SQLite is perfect for this — zero setup, single file, no external dependencies.

// db.js
const Database = require('better-sqlite3');
const db = new Database('./seen_posts.db');

// Create table if it doesn't exist
db.exec(`
  CREATE TABLE IF NOT EXISTS seen_posts (
    post_id TEXT PRIMARY KEY,
    platform TEXT NOT NULL,
    username TEXT NOT NULL,
    seen_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`);

// Clean up posts older than 30 days to keep the DB small
db.exec(`
  DELETE FROM seen_posts WHERE seen_at < datetime('now', '-30 days')
`);

const isPostSeen = db.prepare('SELECT 1 FROM seen_posts WHERE post_id = ?');
const markPostSeen = db.prepare(
  'INSERT OR IGNORE INTO seen_posts (post_id, platform, username) VALUES (?, ?, ?)'
);

module.exports = {
  hasBeenSeen: (postId) => !!isPostSeen.get(postId),
  markSeen: (postId, platform, username) => markPostSeen.run(postId, platform, username),
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Fetch Competitor Posts

// fetcher.js
const axios = require('axios');

const api = axios.create({
  baseURL: 'https://api.sociavault.com/v1/scrape',
  headers: { 'x-api-key': process.env.SOCIAVAULT_API_KEY },
});

async function getRecentPosts(platform, username) {
  try {
    let endpoint;

    if (platform === 'instagram') {
      endpoint = `/instagram/posts?username=${username}&limit=5`;
    } else if (platform === 'tiktok') {
      endpoint = `/tiktok/profile-videos?username=${username}&limit=5`;
    } else {
      throw new Error(`Unsupported platform: ${platform}`);
    }

    const { data } = await api.get(endpoint);
    return data.data || data.posts || [];

  } catch (err) {
    console.error(`Failed to fetch ${platform}/@${username}: ${err.message}`);
    return [];
  }
}

module.exports = { getRecentPosts };
Enter fullscreen mode Exit fullscreen mode

The limit=5 is intentional. We only care about the most recent posts. No point fetching 50 posts every 30 minutes when we're checking frequently enough.

Step 3: Build the Discord Embed

Discord embeds are what make this actually useful. Raw text notifications suck. Rich embeds with thumbnails, stats, and links — that's what gets people to actually look.

// embed.js
const { EmbedBuilder } = require('discord.js');

function buildPostEmbed(post, platform, username) {
  const embed = new EmbedBuilder()
    .setColor(platform === 'instagram' ? 0xE1306C : 0x000000)
    .setAuthor({
      name: `@${username} posted on ${platform}`,
      url: platform === 'instagram'
        ? `https://instagram.com/${username}`
        : `https://tiktok.com/@${username}`,
    })
    .setTimestamp(new Date(post.timestamp || post.createTime * 1000));

  // Caption/description
  const caption = post.caption || post.desc || post.text || 'No caption';
  embed.setDescription(caption.slice(0, 300) + (caption.length > 300 ? '...' : ''));

  // Thumbnail
  const thumbnail = post.thumbnailUrl || post.coverUrl || post.imageUrl;
  if (thumbnail) {
    embed.setThumbnail(thumbnail);
  }

  // Stats
  const likes = post.likesCount ?? post.diggCount ?? 0;
  const comments = post.commentsCount ?? post.commentCount ?? 0;
  const views = post.viewCount ?? post.playCount ?? null;

  let statsLine = `❤️ ${likes.toLocaleString()}  💬 ${comments.toLocaleString()}`;
  if (views) statsLine += `  👁️ ${views.toLocaleString()}`;

  embed.addFields({ name: 'Stats', value: statsLine, inline: true });

  // Link to original post
  const postUrl = post.url || post.webVideoUrl || post.shortcode
    ? `https://instagram.com/p/${post.shortcode}`
    : null;

  if (postUrl) {
    embed.addFields({ name: 'Link', value: `[View Post](${postUrl})`, inline: true });
  }

  return embed;
}

module.exports = { buildPostEmbed };
Enter fullscreen mode Exit fullscreen mode

Step 4: Wire It All Together

// index.js
require('dotenv').config();
const { Client, GatewayIntentBits } = require('discord.js');
const cron = require('node-cron');
const { getRecentPosts } = require('./fetcher');
const { buildPostEmbed } = require('./embed');
const db = require('./db');

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

// Your competitors — add as many as you want
const COMPETITORS = [
  { platform: 'instagram', username: 'competitor_one' },
  { platform: 'instagram', username: 'competitor_two' },
  { platform: 'tiktok', username: 'competitor_three' },
  { platform: 'tiktok', username: 'competitor_four' },
];

async function checkForNewPosts() {
  const channel = await client.channels.fetch(process.env.DISCORD_CHANNEL_ID);
  if (!channel) {
    console.error('Channel not found');
    return;
  }

  for (const competitor of COMPETITORS) {
    const posts = await getRecentPosts(competitor.platform, competitor.username);

    for (const post of posts) {
      const postId = post.id || post.shortcode || post.videoId;
      if (!postId) continue;

      // Skip if we've already seen this post
      if (db.hasBeenSeen(postId)) continue;

      // New post! Send alert and mark as seen
      const embed = buildPostEmbed(post, competitor.platform, competitor.username);
      await channel.send({ embeds: [embed] });
      db.markSeen(postId, competitor.platform, competitor.username);

      console.log(`[NEW] ${competitor.platform}/@${competitor.username}: ${postId}`);
    }

    // Small delay between competitors to avoid rate limits
    await new Promise(r => setTimeout(r, 1000));
  }
}

client.once('ready', () => {
  console.log(`Logged in as ${client.user.tag}`);
  console.log(`Watching ${COMPETITORS.length} competitors`);

  // Run immediately on startup
  checkForNewPosts();

  // Then every 30 minutes
  cron.schedule('*/30 * * * *', () => {
    console.log(`[${new Date().toISOString()}] Checking for new posts...`);
    checkForNewPosts();
  });
});

client.login(process.env.DISCORD_BOT_TOKEN);
Enter fullscreen mode Exit fullscreen mode

Step 5: Optional — Add Summary Commands

Let your team ask the bot questions:

// Add to index.js after the client.once('ready') block

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === 'latest') {
    const platform = interaction.options.getString('platform');
    const username = interaction.options.getString('username');

    await interaction.deferReply();
    const posts = await getRecentPosts(platform, username);

    if (posts.length === 0) {
      await interaction.editReply('No posts found.');
      return;
    }

    const embed = buildPostEmbed(posts[0], platform, username);
    await interaction.editReply({ embeds: [embed] });
  }
});
Enter fullscreen mode Exit fullscreen mode

Now anyone on your team can type /latest platform:instagram username:competitor_one and get their most recent post instantly.

Running It

# Development
node index.js

# Production (PM2)
npm install -g pm2
pm2 start index.js --name "competitor-alerts"
pm2 save
Enter fullscreen mode Exit fullscreen mode

Runs 24/7 on any cheap VPS. I run mine alongside other bots on a $5 Hetzner box.

Cost Breakdown

Each check fetches the 5 most recent posts per competitor. With 4 competitors checked every 30 minutes:

  • 4 API calls × 48 checks/day = 192 credits/day
  • ~5,760 credits/month
  • With caching (same posts won't trigger new calls): ~2,000-3,000 credits/month in practice

That fits comfortably in a SociaVault growth plan.

What I'd Add Next

  • Engagement spike alerts — "This post is getting 5x their average likes"
  • Weekly summary — every Friday, bot posts a digest of what competitors published that week
  • Multi-channel routing — Instagram alerts to #instagram, TikTok to #tiktok
  • Reaction tracking — team reacts with 👀 on posts worth studying deeper

Read the Full Guide

Build a Discord Competitor Alert Bot → SociaVault Blog


Need social media data for your bots and tools? SociaVault provides a unified API for TikTok, Instagram, YouTube, and 10+ platforms. One API key, all the data.

Discussion

What automated alerts have you built for your team? I'm curious what other people are monitoring beyond competitor posts.

javascript #discord #automation #webdev #api

Top comments (0)