Twitch Stream Notifications Bot

Twitch Stream Notifications Bot

I denne guiden skal vi bygge en komplett Discord bot som automatisk sender meldinger når Twitch-streamere går live. Boten vil kunne overvåke flere streamere samtidig på tvers av flere Discord-servere og sende pene embeds med live-informasjon.

Hva du lærer

  • Koble sammen Discord og Twitch APIer
  • Jobbe med MySQL database for å lagre konfigurasjoner
  • Bygge et system som sjekker live-status regelmessig
  • Lage pene Discord embeds med knapper
  • Håndtere flere servere samtidig med samme bot

Oversikt over systemet

Vår Twitch bot består av følgende hovedkomponenter:

  1. Hovedbot-fil (bot.js) - Håndterer Discord-tilkobling og kommandoer
  2. Twitch-modul (twitch.js) - Kommuniserer med Twitch API
  3. Database-tabeller - Lagrer streamer-konfigurasjoner og aktive streams
  4. Kommandosystem - Lar administratorer legge til/fjerne streamere

Del 1: Prosjektoppsett

Lag ny mappe og installer pakker

Åpne VS Code og lag en ny mappe for prosjektet:

bash
mkdir twitch-discord-bot
cd twitch-discord-bot

Opprett package.json filen:

json
{
  "name": "twitch-discord-bot",
  "version": "2.0.0",
  "type": "commonjs",
  "main": "bot.js",
  "scripts": { 
    "start": "node bot.js" 
  },
  "dependencies": {
    "discord.js": "^14.15.3",
    "dotenv": "^16.4.5",
    "mysql2": "^3.10.0"
  }
}

Installer pakkene:

bash
npm install

Forklaring av pakker:

  • discord.js - Discord API bibliotek
  • dotenv - For å laste miljøvariabler fra .env fil
  • mysql2 - MySQL database tilkobling med Promise støtte

Opprett .env fil

Lag en .env fil for å lagre API-nøkler og database-info:

env
DISCORD_TOKEN=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
MYSQL_HOST=
MYSQL_PORT=3306
MYSQL_USER=
MYSQL_PASSWORD=
MYSQL_DATABASE=

Del 2: Skaff API-tilganger

Discord Bot Token

  1. Gå til Discord Developer Portal
  2. Lag en ny applikasjon
  3. Gå til "Bot" seksjonen
  4. Kopier token og lim inn i .env filen

Twitch API-nøkler

  1. Gå til Twitch Developer Console
  2. Lag en ny applikasjon
  3. Kopier Client ID og Client Secret
  4. Lim inn begge i .env filen

Viktigt: Twitch bruker OAuth2 for autentisering, så vi trenger begge nøklene.

MySQL Database

Du trenger en MySQL database. Dette kan være:

  • Lokal database (XAMPP/MAMP)
  • Cloud database (PlanetScale, AWS RDS, etc.)
  • VPS med MySQL installert

Fyll inn tilkoblingsdetaljene i .env filen.

Del 3: Database-struktur

Opprett database.sql fil med tabellene vi trenger:

sql
CREATE TABLE IF NOT EXISTS guild_streamers (
  id INT AUTO_INCREMENT PRIMARY KEY,
  guild_id VARCHAR(32) NOT NULL,
  channel_id VARCHAR(32) NOT NULL,
  streamer_login VARCHAR(100) NOT NULL,
  UNIQUE KEY uniq_cfg (guild_id, streamer_login)
);

CREATE TABLE IF NOT EXISTS live_streams (
  id INT AUTO_INCREMENT PRIMARY KEY,
  guild_id VARCHAR(32) NOT NULL,
  channel_id VARCHAR(32) NOT NULL,
  message_id VARCHAR(32) NOT NULL,
  streamer_login VARCHAR(100) NOT NULL,
  last_title VARCHAR(255) DEFAULT NULL,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_guild_streamer (guild_id, streamer_login)
);

Forklaring av tabeller:

guild_streamers tabell

  • guild_id - Hvilken Discord server
  • channel_id - Hvilken kanal meldinger sendes til
  • streamer_login - Twitch brukernavnet til streameren
  • UNIQUE KEY - Forhindrer duplikater av samme streamer per server

live_streams tabell

  • message_id - ID til Discord-meldingen som viser live-status
  • last_title - Siste stream-tittel (for å oppdage endringer)
  • updated_at - Når meldingen sist ble oppdatert
  • UNIQUE KEY - Én aktiv stream per streamer per server

Kjør denne SQL-koden i din MySQL database for å opprette tabellene.

Del 4: Hovedbot-fil (bot.js)

Opprett bot.js som er hjerte av vår bot:

javascript
require("dotenv").config();
const {
  Client,
  GatewayIntentBits,
  Partials,
  PermissionsBitField,
} = require("discord.js");
const mysql = require("mysql2/promise");
const checkTwitch = require("./twitch");

// Feilhåndtering
process.on("unhandledRejection", (e) => console.error("Unhandled rejection:", e));
process.on("uncaughtException", (e) => console.error("Uncaught exception:", e));
require("events").defaultMaxListeners = 50; 

// Discord client oppsett
const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
  partials: [Partials.Channel, Partials.Message, Partials.GuildMember],
});

let db;

Forklaring av koden:

  • require("dotenv").config() - Laster .env filen
  • GatewayIntentBits - Definerer hvilke Discord-events boten skal lytte til
  • Partials - Lar boten håndtere ufullstendige objekter
  • PermissionsBitField - For å sjekke brukerrettigheter
  • mysql2/promise - Promise-basert MySQL tilkobling
  • Error handlers - Fanger opp uventet feil

Database initialisering

javascript
async function initDB() {
  db = await mysql.createPool({
    host: process.env.MYSQL_HOST,
    port: process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306,
    user: process.env.MYSQL_USER,
    password: process.env.MYSQL_PASSWORD,
    database: process.env.MYSQL_DATABASE,
    waitForConnections: true,
    connectionLimit: 10,
  });

  // Test tilkobling
  await db.query("SELECT 1");

  // Opprett tabeller hvis de ikke eksisterer
  await db.query(
    `CREATE TABLE IF NOT EXISTS guild_streamers(
      id INT AUTO_INCREMENT PRIMARY KEY,
      guild_id VARCHAR(32) NOT NULL,
      channel_id VARCHAR(32) NOT NULL,
      streamer_login VARCHAR(100) NOT NULL,
      UNIQUE KEY uniq_cfg (guild_id, streamer_login)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
  );

  await db.query(
    `CREATE TABLE IF NOT EXISTS live_streams(
      id INT AUTO_INCREMENT PRIMARY KEY,
      guild_id VARCHAR(32) NOT NULL,
      channel_id VARCHAR(32) NOT NULL,
      message_id VARCHAR(32) NOT NULL,
      streamer_login VARCHAR(100) NOT NULL,
      last_title VARCHAR(255) DEFAULT NULL,
      updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
      UNIQUE KEY uniq_guild_streamer (guild_id, streamer_login)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
  );

  console.log(
    `✅ MySQL tilkoblet: ${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}`
  );
}

Forklaring:

  • createPool() - Lager en pool av tilkoblinger for bedre ytelse
  • connectionLimit: 10 - Maksimalt 10 samtidige tilkoblinger
  • ENGINE=InnoDB - Bruker InnoDB for ACID-compliance og foreign keys
  • utf8mb4 - Støtter alle Unicode-tegn (inkludert emojis)

Bot ready event

javascript
client.once("clientReady", () => {
  console.log(`✅ Logget inn som ${client.user.tag}`);
  
  // Start periodisk Twitch-sjekk hver 60. sekund
  setInterval(async () => {
    try {
      await checkTwitch(client, db);
    } catch (e) {
      console.error("Feil i periodisk Twitch-sjekk:", e);
    }
  }, 60 * 1000);
});

Forklaring:

  • clientReady - Kjøres når boten er logget inn og klar
  • setInterval() - Kjører Twitch-sjekk hver 60. sekund
  • Try-catch - Forhindrer at feilen stopper hele boten

Del 5: Kommandosystem

La oss bygge kommandosystemet som lar administratorer styre boten:

javascript
client.on("messageCreate", async (msg) => {
  // Ignorer meldinger fra andre bots og DMs
  if (!msg.guild || msg.author.bot) return;
  
  // Kun !twitch kommandoer
  if (!msg.content.startsWith("!twitch")) return;

  // Sjekk administrator-rettigheter
  const isAdmin = msg.member.permissions.has(
    PermissionsBitField.Flags.Administrator
  );
  
  // Del opp kommandoen i deler
  const parts = msg.content.trim().split(/\s+/);
  const sub = parts[1]?.toLowerCase(); // add/remove/list
  const arg1 = parts[2]; // streamer navn
  
  // Hjelpefunksjon for admin-sjekk
  const requireAdmin = () => {
    if (!isAdmin) {
      msg.reply("Du må ha **Administrator** for å bruke denne kommandoen.");
      return true;
    }
    return false;
  };

  // Rens streamer-navn
  const sanitizeLogin = (s) =>
    String(s || "")
      .trim()
      .toLowerCase()
      .replace(/^@/, "") // Fjern @ i starten
      .replace(/[^a-z0-9_]/g, ""); // Kun tillatte tegn

Forklaring:

  • messageCreate - Kjøres for hver melding i serveren
  • msg.guild - Sjekker at det ikke er en DM
  • msg.author.bot - Ignorerer andre bots
  • split(/\s+/) - Deler opp meldingen på whitespace
  • sanitizeLogin() - Renser Twitch-brukernavnet for ugyldige tegn

Add-kommando

javascript
  try {
    if (sub === "add") {
      if (requireAdmin()) return;
      const login = sanitizeLogin(arg1 || "");

      // Finn kanal fra mention eller navn
      let channel = msg.mentions.channels.first();
      if (!channel) {
        const arg2 = parts[3] || parts[2];
        if (arg2) {
          const wanted = arg2.replace(/^#/, "").toLowerCase();
          channel = msg.guild.channels.cache.find(
            (c) => c.name.toLowerCase() === wanted
          );
        }
      }

      // Sjekk at vi har både streamer og kanal
      if (!login || !channel)
        return msg.reply(
          "Bruk: `!twitch add <streamer_login> #kanal` eller `!twitch add <streamer_login> kanalnavn`"
        );

      // Legg til i database (eller oppdater eksisterende)
      await db.query(
        "INSERT INTO guild_streamers (guild_id, channel_id, streamer_login) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = VALUES(channel_id)",
        [msg.guild.id, channel.id, login]
      );
      
      return msg.reply(`✅ Lagt til **${login}** → ${channel}`);
    }

Forklaring:

  • msg.mentions.channels.first() - Finner første nevnte kanal (#kanal)
  • msg.guild.channels.cache.find() - Søker etter kanal på navn
  • ON DUPLICATE KEY UPDATE - Oppdaterer kanal hvis streameren allerede eksisterer

Remove og List kommandoer

javascript
    if (sub === "remove") {
      if (requireAdmin()) return;
      const login = sanitizeLogin(arg1 || "");
      if (!login) return msg.reply("Bruk: `!twitch remove <streamer_login>`");
      
      await db.query(
        "DELETE FROM guild_streamers WHERE guild_id = ? AND streamer_login = ?",
        [msg.guild.id, login]
      );
      return msg.reply(`🗑️ Fjernet **${login}** fra denne serveren.`);
    }

    if (sub === "list") {
      if (requireAdmin()) return;
      const [rows] = await db.query(
        "SELECT streamer_login, channel_id FROM guild_streamers WHERE guild_id = ? ORDER BY streamer_login",
        [msg.guild.id]
      );
      
      if (!rows.length) return msg.reply("Ingen streamere konfigurert.");
      
      return msg.reply(
        "Konfigurerte streamere:\n" +
          rows
            .map((r) => `• **${r.streamer_login}** → <#${r.channel_id}>`)
            .join("\n")
      );
    }

    // Ukjent kommando
    return msg.reply(
      "Tilgjengelig: `!twitch add <login> #kanal`, `!twitch remove <login>`, `!twitch list` (admin only)"
    );
  } catch (e) {
    console.error(e);
    return msg.reply("Noe gikk galt. Sjekk loggene.");
  }
});

Forklaring:

  • <#${r.channel_id}> - Discord formatering for kanal-mentions
  • .map().join() - Konverterer array til formatert streng
  • Try-catch fanger database-feil

Oppstart av bot

javascript
// Start database og bot
initDB()
  .then(() => client.login(process.env.DISCORD_TOKEN))
  .catch((e) => {
    console.error("DB-init feilet:", e);
    process.exit(1);
  });

module.exports = { client };

Del 6: Twitch API-modul (twitch.js)

Opprett twitch.js som håndterer all Twitch API-kommunikasjon:

javascript
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js");

// Token cache for å unngå unødvendige API-kall
let tokenCache = { access_token: null, expires_at: 0 };
const gameNameCache = new Map(); 

// Rens Twitch login (samme som i bot.js)
const cleanLogin = (s) =>
  String(s || "")
    .trim()
    .toLowerCase()
    .replace(/^@/, "")
    .replace(/[^a-z0-9_]/g, "");

Twitch OAuth2 Token

javascript
async function getTwitchToken() {
  const now = Date.now();
  
  // Bruk cached token hvis den ikke er utløpt
  if (tokenCache.access_token && now < tokenCache.expires_at)
    return tokenCache.access_token;

  // Hent ny token
  const url = new URL("https://id.twitch.tv/oauth2/token");
  url.searchParams.set("client_id", process.env.TWITCH_CLIENT_ID);
  url.searchParams.set("client_secret", process.env.TWITCH_CLIENT_SECRET);
  url.searchParams.set("grant_type", "client_credentials");

  const res = await fetch(url, { method: "POST" });
  const data = await res.json();
  
  if (!res.ok)
    throw new Error(`Twitch token error: ${res.status} ${JSON.stringify(data)}`);

  // Cache token med 1 minutt buffer før utløp
  tokenCache.access_token = data.access_token;
  tokenCache.expires_at = Date.now() + ((data.expires_in || 3600) - 60) * 1000;
  
  return tokenCache.access_token;
}

Forklaring:

  • OAuth2 Client Credentials flow for app-til-app autentisering
  • Token caching reduserer API-kall betydelig
  • 60 sekunder buffer sikrer at vi ikke bruker utløpte tokens

Twitch Helix API

javascript
async function helix(endpoint, params = {}) {
  const token = await getTwitchToken();
  const url = new URL(`https://api.twitch.tv/helix/${endpoint}`);
  
  // Legg til query parameters
  for (const [k, v] of Object.entries(params)) 
    url.searchParams.append(k, v);
  
  const res = await fetch(url, {
    headers: {
      "Client-ID": process.env.TWITCH_CLIENT_ID,
      Authorization: `Bearer ${token}`,
    },
  });
  
  const data = await res.json();
  if (!res.ok)
    throw new Error(
      `Helix ${endpoint} error: ${res.status} ${JSON.stringify(data)}`
    );
    
  return data.data;
}

Forklaring:

  • Helix er Twitch sin moderne API
  • Krever både Client-ID og Bearer token
  • Returnerer data.data array fra responsen

Stream og Game funksjoner

javascript
async function getStream(loginRaw) {
  const login = cleanLogin(loginRaw);
  if (!login) return null;
  
  const rows = await helix("streams", { user_login: login });
  return rows[0] || null; // Returnerer første stream eller null
}

async function getGameName(game_id) {
  if (!game_id) return "Ukjent";
  
  // Sjekk cache først
  if (gameNameCache.has(game_id)) return gameNameCache.get(game_id);

  // Hent fra API og cache
  const rows = await helix("games", { id: game_id });
  const name = rows[0]?.name || "Ukjent";
  gameNameCache.set(game_id, name);
  return name;
}

Forklaring:

  • getStream() returnerer stream-objektet hvis live, ellers null
  • getGameName() bruker cache for å unngå gjentatte API-kall for samme spill
  • ?. optional chaining for sikker tilgang til objektegenskaper

Discord Embed Builder

javascript
function buildEmbed(loginRaw, stream, gameName) {
  const login = cleanLogin(loginRaw);
  
  const embed = new EmbedBuilder()
    .setAuthor({
      name: `${login} er nå live!`,
      iconURL: "https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png",
    })
    .setTitle(stream.title || "Live nå")
    .addFields(
      { name: "Spill", value: gameName || "Ukjent", inline: true },
      { name: "Seere", value: String(stream.viewer_count ?? "0"), inline: true }
    )
    .setImage(
      (stream.thumbnail_url || "")
        .replace("{width}", "1280")
        .replace("{height}", "720")
    )
    .setURL(`https://twitch.tv/${login}`)
    .setColor("#9146FF"); // Twitch sin lilla farge

  // Knapp for å åpne stream
  const row = new ActionRowBuilder().addComponents(
    new ButtonBuilder()
      .setLabel("Se stream")
      .setStyle(ButtonStyle.Link)
      .setURL(`https://twitch.tv/${login}`)
  );

  return { embed, row };
}

Forklaring:

  • EmbedBuilder lager rike meldinger med bilder og felt
  • Twitch thumbnail URLs har placeholder som vi erstatter
  • ActionRowBuilder og ButtonBuilder lager klikkbare knapper
  • ButtonStyle.Link lager en URL-knapp

Del 7: Hovedfunksjonen - checkTwitch

Dette er hjertet av systemet som sjekker live-status og håndterer meldinger:

javascript
module.exports = async function checkTwitch(client, db) {
  // Hent alle konfigurerte streamere
  const [cfg] = await db.query(
    "SELECT guild_id, channel_id, streamer_login FROM guild_streamers"
  );
  
  if (!cfg.length) return; // Ingen streamere konfigurert

  // Gå gjennom hver streamer
  for (const { guild_id, channel_id, streamer_login } of cfg) {
    const loginClean = cleanLogin(streamer_login);
    
    if (!loginClean) {
      console.warn("Hopper over tom/ugyldig login:", streamer_login);
      continue;
    }

    try {
      // Sjekk om streameren er live
      const stream = await getStream(loginClean);

      // Sjekk om vi allerede tracker denne streamen
      const [existingRows] = await db.query(
        "SELECT * FROM live_streams WHERE guild_id = ? AND streamer_login = ?",
        [guild_id, loginClean]
      );
      const isTracked = existingRows.length > 0;

Håndtering av nye streams

javascript
      if (stream) {
        // Streameren er live
        const gameName = await getGameName(stream.game_id);

        if (!isTracked) {
          // Ny stream - send melding
          const channel = await client.channels.fetch(channel_id).catch(() => null);
          if (!channel || !channel.send) continue;

          const { embed, row } = buildEmbed(loginClean, stream, gameName);
          const msg = await channel.send({ embeds: [embed], components: [row] });

          // Lagre i database
          await db.query(
            "INSERT INTO live_streams (guild_id, channel_id, message_id, streamer_login, last_title) VALUES (?, ?, ?, ?, ?)",
            [guild_id, channel_id, msg.id, loginClean, stream.title || null]
          );
        } else {
          // Eksisterende stream - oppdater melding
          for (const rowDB of existingRows) {
            const channel = await client.channels
              .fetch(rowDB.channel_id)
              .catch(() => null);
            if (!channel) continue;

            const message = await channel.messages
              .fetch(rowDB.message_id)
              .catch(() => null);
            if (!message) continue;

            // Oppdater embed med ny info
            const { embed, row } = buildEmbed(loginClean, stream, gameName);
            await message.edit({ embeds: [embed], components: [row] });

            // Oppdater database
            await db.query(
              "UPDATE live_streams SET last_title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
              [stream.title || null, rowDB.id]
            );
          }
        }

Håndtering av streams som stopper

javascript
      } else if (!stream && isTracked) {
        // Streameren er ikke lenger live - slett meldinger
        for (const rowDB of existingRows) {
          try {
            const channel = await client.channels
              .fetch(rowDB.channel_id)
              .catch(() => null);
            if (channel) {
              const message = await channel.messages
                .fetch(rowDB.message_id)
                .catch(() => null);
              if (message) await message.delete();
            }
          } catch {
            // Ignore deletion errors
          } finally {
            // Fjern fra database uansett
            await db.query("DELETE FROM live_streams WHERE id = ?", [rowDB.id]);
          }
        }
      }
    } catch (e) {
      console.error(`Twitch-sjekk feilet for ${loginClean}:`, e.message);
    }
  }
};

Forklaring av logikken:

  1. Stream starter: Send ny melding og lagre i live_streams
  2. Stream fortsetter: Oppdater eksisterende melding med ny info
  3. Stream stopper: Slett Discord-melding og database-entry
  4. Feilhåndtering: Continue med neste streamer hvis én feiler

Del 8: Testing av boten

Start boten

bash
npm start

Du burde se:

✅ MySQL tilkoblet: din-host:3306
✅ Logget inn som DinBot#1234

Test kommandoer

I en Discord-server hvor boten har tilgang:

!twitch add twitchNavn #stream-notifications
!twitch list
!twitch remove twitchNavn

Debug tips

  • Sjekk konsollen for feilmeldinger
  • Kontroller at .env filen har riktige verdier
  • Verifiser at databasen er tilgjengelig
  • Test Twitch streamere som faktisk er live

Del 9: Avanserte features

Error handling og logging

Legg til bedre logging:

javascript
// I bot.js
const logMessage = (level, message, data = null) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${level}: ${message}`, data || '');
};

// Bruk slik:
logMessage('INFO', 'Bot started successfully');
logMessage('ERROR', 'Twitch API error', error);

Rate limiting

Twitch API har rate limits. Legg til delays:

javascript
// I twitch.js
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// Mellom API-kall:
await sleep(100); // 100ms delay

Graceful shutdown

Håndter bot shutdown:

javascript
// I bot.js
process.on('SIGINT', async () => {
  console.log('Shutting down...');
  await db.end();
  client.destroy();
  process.exit(0);
});

Del 10: Deployment

Environment setup

For produksjon, sett opp:

  • Robust MySQL database (ikke lokal)
  • Process manager som PM2
  • Proper logging system
  • Health checks

PM2 deployment

bash
npm install -g pm2
pm2 start bot.js --name "twitch-bot"
pm2 startup
pm2 save

Docker container

Opprett Dockerfile:

dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "bot.js"]

Konklusjon

Du har nå bygget en fullstendig Twitch-integrasjon for Discord! Boten kan:

✅ Overvåke flere streamere på tvers av flere servere
✅ Sende pene embeds når noen går live
✅ Oppdatere meldinger med ny stream-info
✅ Slette meldinger når streams stopper
✅ Håndtere administrator-kommandoer
✅ Cache API-data for bedre ytelse

Dette er et solid fundament du kan bygge videre på! 🎉