Skip to content

Repository files navigation

GalaBot

GalaBot is a multi-platform streaming companion bot for a single content creator's community. It bridges Discord, Twitch, and YouTube in one Node.js process: announcing streams as they go live, generating custom banner images, moderating Discord chat, greeting users, and persisting per-stream stats to a local SQLite database. It is built around the streamer "Gala" (a dinosaur mascot), but the codebase is generic enough to be reused — every channel ID, role, and credential is loaded from environment variables.

If you want the 60-second path: skip to Quick start (Docker). If you want to extend the bot: skip to How things work (developer guide).


Table of contents


Feature highlights

Discord

  • Slash commands: /rules (post or DM the server rules), /warn (warn → timeout → ban escalation), and /scamimage (manage the scam image hash database).
  • Reaction roles: When /rules is posted, configured emoji reactions grant roles automatically. Reactions persist across bot restarts.
  • Greeting responses with a per-user cooldown (greetings on Discord and Twitch share the same cooldown).
  • Auto-moderation: pinging the streamer's personal account (GALA_USER_ID) issues a warning automatically (warn → timeout → ban escalation).
  • Scam image detection: every incoming message with an image attachment is compared against a database of known scam image perceptual hashes. On a match the message is deleted and the author is permanently banned. Hashes are registered by administrators via /scamimage add and are resilient to minor resizing and cropping (blockhash 256-bit, Hamming distance threshold).
  • AI replies via Google Gemini: triggered by @mentioning the bot, replying to one of its messages, or typing its name in a message. Each query automatically injects the sender's server profile (username, tenure, roles, booster status), up to 4 messages of reply-chain history, and upcoming stream data so the bot can answer contextually. A 5 s per-user cooldown rejects spam; a global FIFO queue and 30 RPM cap protect the free-tier quota (configurable via GEMINI_NO_LIMITS_IDS). Transient 5xx errors are retried up to twice before surfacing. The bot character name, personality, and all example dialogue live in data/AIPrompt.md — edit it without touching code. Requires GEMINI_API_KEY.
  • Stream announcements posted as rich embeds with a custom-rendered banner attachment and an optional role mention; the same message is updated when the stream ends with the final stats.

Twitch

  • Connects to chat as a configurable bot account (Twurple).
  • Subscribes to stream.online / stream.offline events via EventSub WebSocket — no polling, near-instant notifications.
  • Samples viewer count every 60 s during a live stream and stores a running average.
  • Fetches the Twitch schedule on stream end and renders a "next streams" image into the end-of-stream embed.
  • Auto-refreshes Twitch OAuth tokens; nothing manual after first setup.

YouTube

  • Detects live streams via a polling state machine. Two cadences: a slow poll (every 3 h, ~100 quota units) discovers upcoming/live videos, and a fast poll (every 1 minute, 1 quota unit) tracks state transitions. Total cost is well under the daily 10 k quota.
  • Periodically fetches and caches YouTube categories (every 48 h) to dynamically map category IDs to readable category names for the stream embeds.
  • Optional fallback API key kicks in if the primary key is exhausted.
  • Resumes tracking automatically after a restart if a stream is still live.

Image generation

  • Stream banners and followup images are HTML templates rendered with headless Chromium (Puppeteer) and attached to the Discord embed. They dynamically adapt colors and links based on the platform (Twitch or YouTube).

Localization

  • English and Spanish, selected per Discord channel via SPANISH_CHANNEL_ID. All user-facing strings live in lang/.

Persistence

  • SQLite (better-sqlite3 + Kysely query builder). Tables for greetings, warns, Twitch/YouTube streams, and upcoming streams (for AI context) are created on first boot.

Architecture overview

                            ┌────────────────────┐
                            │      main.js       │
                            │ (env validation)   │
                            └─────────┬──────────┘
                                      │
                            ┌─────────▼──────────┐
                            │  clientManager.js  │
                            │ (lifecycle owner)  │
                            └─┬────────┬────────┬┘
                              │        │        │
              ┌───────────────┘        │        └──────────────┐
              │                        │                       │
     ┌────────▼────────┐    ┌──────────▼──────────┐   ┌────────▼────────┐
     │ handlers/discord│    │  handlers/twitch    │   │  handlers/youtube │
     │   /startup.js   │    │    /startup.js      │   │    /startup.js    │
     │                 │    │                     │   │                   │
     │ auto-loads      │    │ wires Twurple       │   │ schedules slow    │
     │ commands/ +     │    │ chat + EventSub     │   │ + fast poll loops │
     │ events/discord/ │    │                     │   │                   │
     └────────┬────────┘    └──────────┬──────────┘   └────────┬────────┘
              │                        │                       │
              └───────┬────────────────┴───────────────────────┘
                      │
            ┌─────────▼──────────┐    ┌──────────────────────┐
            │  db/ (Kysely)      │    │ utils/imageGenerator │
            │  greetings, warns, │    │ (Puppeteer + HTML    │
            │  streams           │    │  templates/)         │
            │  (all providers)   │    │                      │
            └────────────────────┘    └──────────────────────┘

         Logging is per-platform via Winston (utils/core/loggers.js → logs/*.log)

Three properties worth keeping in mind as you read the code:

  1. clientManager is the single owner of every long-lived resource (Discord client, Twurple chat client, EventSub listener, YouTube polling intervals, Puppeteer browser). Shutdown is centralized in clientManager.js.
  2. Each platform is independently toggleable. Setting ENABLE_DISCORD=false / ENABLE_TWITCH=false / ENABLE_YOUTUBE=false skips its initialization entirely. All three default to enabled.
  3. Only Discord auto-loads handlers from disk. Discord scans events/discord/ and commands/discord/ at startup. Twitch and YouTube wire their handlers up explicitly inside handlers/<platform>/startup.js.

Quick start (Docker)

Prerequisites: Docker and Docker Compose.

git clone <your-repo-url>
cd GalaBot
cp .env.example .env
# edit .env — fill in every value (see "Configuration" below)
docker compose up --build -d
docker compose logs -f bot

The container ships with Chromium pre-installed and PUPPETEER_EXECUTABLE_PATH already set, so image generation works out of the box. Persistent state lives in ./data/ and logs in ./logs/ on the host.

To pull the latest code, clean out the old image, and rebuild/restart in one shot, the repo includes init.sh:

bash init.sh

Slash commands are registered with Discord automatically on every bot startup — no manual step needed. To force an immediate republish without restarting the container (e.g. right after adding a command file):

docker compose exec bot npm run generate-cmds

Quick start (local Node)

Prerequisites: Node.js 22+, npm, and a Chromium/Chrome binary on the system for Puppeteer.

git clone <your-repo-url>
cd GalaBot
cp .env.example .env
# edit .env (see "Configuration" below)
npm install
npm start   # slash commands are registered with Discord automatically on startup

Notes:

  • On Linux (apt-based), sudo apt install chromium and set PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium in .env.
  • On macOS, point PUPPETEER_EXECUTABLE_PATH at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome (or install Chromium via Homebrew).
  • On Windows, point PUPPETEER_EXECUTABLE_PATH at e.g. C:\Program Files\Google\Chrome\Application\chrome.exe.
  • data/ and logs/ are created automatically on first run.

Configuration (environment variables)

main.js exits with FATAL if any of the required variables is missing. The committed .env.example lists every variable below.

Discord

Variable Required Description
DISCORD_TOKEN yes Bot token from the Discord Developer Portal.
DISCORD_ID yes Application (client) ID — used to register slash commands with Discord (automatically on startup, or via npm run generate-cmds).
GALA_DISCORD_ID yes Discord guild/server ID. Used by the emoji-sync script (npm run sync-emojis) to fetch guild emojis. Not a user ID.
GALA_USER_ID yes Gala's personal Discord user ID. Bot @-mentions of this account trigger the warn/ban escalation instead of an AI reply.
DISCORD_NOTIFICATION_CHANNEL yes Channel ID where Twitch and YouTube stream notifications are posted.
DISCORD_NOTIFICATION_ROLE_ID no Role ID mentioned in Twitch and YouTube stream notifications. Leave blank for no mention.
SPANISH_CHANNEL_ID no Channel ID treated as Spanish-locale. Any other channel falls back to English.
REACTION_ROLE_{GROUP}_EMOJI* no Reaction role mappings, namespaced by group. Format: REACTION_ROLE_RULES_EMOJI1=🦖:roleId. Supports Unicode and custom Discord emojis (<:name:id>). Each command uses its own group (e.g. RULES).

Twitch

Variable Required Description
TWITCH_CHANNEL yes The Twitch channel name to monitor (without the #).
TWITCH_USERNAME yes The bot account's Twitch username (the account whose token is in data/twitch.json).
TWITCH_URL yes The streamer's Twitch URL to include in embeds.

YouTube

Variable Required Description
YOUTUBE_CHANNEL_ID conditional The YouTube channel ID to monitor (starts with UC...). Required when YouTube is enabled.
YOUTUBE_API_KEY conditional YouTube Data API v3 key. Required when YouTube is enabled.
YOUTUBE_API_KEY_2 no Optional fallback key. Used when the primary key hits its 10 000 unit/day quota.
YOUTUBE_URL yes The streamer's YouTube URL to include in embeds.
YOUTUBE_BLACKLIST_IDS no Comma-separated list of video IDs to ignore. These streams are skipped on all API requests.

Webhooks & toggles

Variable Required Description
POST_DATA_WEBHOOK yes URL that receives a POST with the stream data when a stream ends. Used for external analytics. Set to a placeholder (e.g. https://example.com/noop) if you don't have one yet.
ENABLE_DISCORD no Set to false to skip Discord initialization entirely.
ENABLE_TWITCH no Set to false to skip Twitch initialization entirely.
ENABLE_YOUTUBE no Set to false to skip YouTube initialization entirely.
PUPPETEER_EXECUTABLE_PATH no Path to the Chromium/Chrome binary used by Puppeteer. Defaults to /usr/bin/chromium in Docker.

Gemini AI replies (optional)

When GEMINI_API_KEY is set (and GEMINI_ENABLE is not false), AI replies are triggered by @mentioning the bot, replying to one of its messages, or typing its name (case-insensitive). Get an API key from Google AI Studio. The default model is gemma-4-26b-a4b-it; check your project's Quotas page for exact free-tier limits.

Every query builds a context block injected before the user's message:

  1. User info — Discord username, server nickname (if set), how long they've been a member, account age, roles, and booster status.
  2. Conversation history — up to 4 messages of reply-chain context so the bot doesn't lose thread.
  3. Upcoming streams — fetched from the upcoming_streams DB table so the bot can answer schedule questions with accurate Discord timestamps.

The bot character, personality, rules, and all example dialogues live in data/AIPrompt.md. Template variables ({{BOT_NAME}}, {{GALA_USER_ID}}) are injected at startup from env/Discord. Edit the file to change personality without touching code.

Transient Gemini errors (5xx / INTERNAL) are retried up to 2 times with a 2 s delay before the query fails silently to the user. Quota errors (429) switch to the fallback key and then enter a cooldown.

Variable Required Description
GEMINI_API_KEY no Google AI Studio API key. Feature is disabled when absent.
GEMINI_API_KEY_2 no Optional fallback API key used automatically when the primary returns 429. After both keys are exhausted, AI replies pause for GEMINI_QUOTA_COOLDOWN_MS (1 h default).
GEMINI_MODEL no Model name to use. Defaults to gemma-4-26b-a4b-it when unset.
GEMINI_NO_LIMITS_IDS no Comma-separated Discord user IDs that bypass the per-user cooldown entirely (useful for the bot owner / trusted users).
GEMINI_WHITELIST_ONLY no Set to true to restrict AI replies to users listed in GEMINI_NO_LIMITS_IDS. All other users are silently ignored. Defaults to false.
GEMINI_ENABLE no Set to false to disable AI replies entirely without removing GEMINI_API_KEY. Defaults to enabled when unset.
GEMINI_DEBUG_LOG no Set to true to write the full injected context (user info, conversation history, stream data) and the raw model response to logs/ai.log. Off by default — only enable when debugging prompt behaviour, as it logs user data.

Tunable constants (not env vars)

If you want to change cooldowns, ban thresholds, or polling cadence, edit utils/constants.js:

Constant Default Meaning
GREETING_COOLDOWN_MS 4 h Time before a user can trigger a greeting response again. Shared between Discord and Twitch.
WARN_TIMEOUT_BASE_MS 10 min Per-warn timeout. A user with N warns gets a N * 10 min timeout.
MAX_WARN_BEFORE_BAN 3 Number of warns at which the user is permanently banned.
MAX_WARN_REASON_LENGTH 512 Max characters allowed in a /warn reason.
TOKEN_VALIDITY_MS 59 days How long a refreshed Twitch token is considered valid before re-refreshing.
VIEWER_POLL_INTERVAL_MS 60 s Twitch viewer-count sampling interval during a live stream.
YOUTUBE_FAST_POLL_MS 60 s Cadence of the lightweight videos.list poll (1 quota unit per call).
YOUTUBE_SLOW_POLL_MS 3 h Cadence of the heavier search.list poll (100 quota units per call).
YOUTUBE_CATEGORY_POLL_MS 48 h Cadence for fetching and caching YouTube category mappings via the videoCategories endpoint.
YOUTUBE_STREAM_VALID_HOURS 12 How long after publish a discovered video is still tracked.
YOUTUBE_QUOTA_COOLDOWN_MS 24 h Pause on search.list calls after a quota error.
PUPPETEER_*_TIMEOUT_MS various Puppeteer page/goto/screenshot/selector timeouts. Bump these on slow hardware.
AI_USER_COOLDOWN_MS 5 s Minimum delay between AI requests from the same user. A user pinging again sooner gets a "slow down" reply.
AI_GLOBAL_RPM_LIMIT 15 Hard cap on AI requests dispatched to Gemini per rolling minute (matches free-tier gemma-4-* quota; bump to 30 for gemma-3-*).
AI_GLOBAL_RPM_WINDOW_MS 60 s Sliding-window duration used by AI_GLOBAL_RPM_LIMIT.
GEMINI_QUOTA_COOLDOWN_MS 1 h After both Gemini keys return 429, pause AI replies for this duration before retrying. Auto-resets the fallback flag when the cooldown expires.

Discord setup walkthrough

  1. Create a bot at https://discord.com/developers/applications → New Application → Bot tab → Reset Token (paste into DISCORD_TOKEN).
  2. Enable privileged intents on the Bot tab: Message Content Intent is required (greetings and ping detection rely on reading message content). Server Members Intent is not needed.
  3. Copy the Application ID from the General Information tab → DISCORD_ID.
  4. Invite the bot to your server using the OAuth2 URL Generator with scopes bot and applications.commands, and these bot permissions:
    • View Channels, Send Messages, Embed Links, Attach Files, Use External Emojis (for greetings/notifications)
    • Manage Messages, Moderate Members, Ban Members (for /warn and auto-moderation)
  5. Find IDs by enabling Developer Mode in Discord (Settings → Advanced), then right-clicking the channel/role/user/server → Copy ID. You'll need:
    • The guild/server ID → GALA_DISCORD_ID (right-click the server icon)
    • Gala's personal Discord user ID → GALA_USER_ID (right-click her user)
    • The notification channel ID → DISCORD_NOTIFICATION_CHANNEL (used for both Twitch and YouTube announcements)
    • The role to ping for live streams → DISCORD_NOTIFICATION_ROLE_ID (optional, used for both Twitch and YouTube)
    • The Spanish channel ID, if you have one → SPANISH_CHANNEL_ID
  6. Once DISCORD_TOKEN and DISCORD_ID are set, slash commands register themselves automatically the first time the bot starts — no manual step needed. If you want to force a republish without restarting (e.g. right after adding a command file), run:
    npm run generate-cmds
  7. (Optional) Sync custom emojis if you maintain data/emojis.json:
    npm run sync-emojis

Twitch setup walkthrough

There are two Twitch identities at play:

  • TWITCH_CHANNEL — the streamer whose stream events you want to track. The bot will join this channel's chat.
  • TWITCH_USERNAME — the bot account's username (the account that owns the OAuth token). Often a separate account from the streamer.

Generating a Twitch token (first run only)

The bot uses twitchtokengenerator.com to get and refresh tokens. The required scopes are at minimum chat:read and chat:edit for chat, plus the broadcaster scopes EventSub needs for stream.online / stream.offline (user:read:email is generally sufficient, plus standard helix read scopes).

The flow:

  1. Visit the token generator while logged in to the bot account.
  2. Pick the scopes above.
  3. Authorize.
  4. Copy the resulting Client ID, Access Token, and Refresh Token into data/twitch.json. The schema the bot expects:
    {
      "CLIENT_ID": "your_twitch_client_id",
      "ACCESS_TOKEN": "your_access_token",
      "REFRESH_TOKEN": "your_refresh_token",
      "LAST_REFRESH": 0
    }
  5. On the next boot, utils/twitchToken.js will refresh the token and update LAST_REFRESH. After that it auto-refreshes when the token is older than TOKEN_VALIDITY_MS (59 days).

If chat connects but EventSub fails, your token is missing a scope — regenerate with broader scopes and replace data/twitch.json.


YouTube setup walkthrough

  1. Get an API key from the Google Cloud Console → enable the YouTube Data API v3 → create an API key. Paste into YOUTUBE_API_KEY.
  2. (Strongly recommended) Create a second key on a different project for YOUTUBE_API_KEY_2. Each project gets its own 10 000 unit/day quota; the bot transparently falls back to the second key if the first is exhausted.
  3. Find the channel ID of the streamer's YouTube channel — the 24-character ID starting with UC. Paste into YOUTUBE_CHANNEL_ID.
  4. Notifications are posted to the same DISCORD_NOTIFICATION_CHANNEL (and pinged with the same DISCORD_NOTIFICATION_ROLE_ID) as Twitch announcements — no extra config needed.
  5. (Optional) Blacklist videos by adding their IDs to YOUTUBE_BLACKLIST_IDS (comma-separated). Blacklisted streams are completely ignored by the polling system and never announced. Use this for regularly-scheduled or recurring streams that should not trigger announcements.

Quota math

A day-in-the-life of the YouTube poller, with default constants:

  • Slow poll (search.list, 100 units): every 3 h → 8 calls/day → 800 units/day.
  • Fast poll (videos.list, 1 unit): every 1 min → 1 440 calls/day → 1 440 units/day.
  • Category mapping poll (videoCategories, 1 unit): every 48 h → ~0.5 units/day.
  • Total: ~2 240 units/day, well below the 10 000 unit limit.

If you tighten YOUTUBE_SLOW_POLL_MS to faster than ~25 minutes you'll start to push the daily quota; the bot detects 403 quota errors and pauses search.list calls for YOUTUBE_QUOTA_COOLDOWN_MS (24 h).


Running

Mode Command Notes
Local development npm start Logs to console + logs/.
Docker (foreground) docker compose up --build Useful for first run / debugging.
Docker (background) docker compose up --build -d Restart policy is always (auto-restart on crash).
Pull, rebuild, restart bash init.sh Stops the container, removes the old image, git pulls, then rebuilds and restarts.
Tail logs docker compose logs -f bot All Winston output.
Force-republish slash commands npm run generate-cmds Automatic on every startup; this is only for a republish without restarting.
Format source files npm run format Runs Prettier over all *.js files.
Check formatting (CI) npm run format:check Exits non-zero if any file is not formatted.

The bot handles SIGTERM and SIGINT gracefully: it stops Twitch viewer polling, closes Puppeteer, clears YouTube intervals, destroys the Discord client, and disconnects Twitch chat + EventSub before exiting.


Project layout

GalaBot/
├── main.js                    Entry point. Validates env, instantiates clientManager.
├── package.json               Dependencies and npm scripts.
├── Dockerfile                 Multi-stage Node 22-slim build + Chromium for Puppeteer.
├── docker-compose.yml         Mounts ./data, ./logs, ./.env into the container.
├── init.sh                    Pull + clean-rebuild + restart helper.
├── .env.example               Template — copy to .env and fill in.
│
├── .prettierrc                Prettier config (double quotes, semicolons, 2-space indent, 80-char width).
│
├── commands/discord/          Slash commands. Auto-loaded; one file per command.
│
├── events/                    Event handlers, organized by platform.
│   ├── discord/               Auto-loaded by handlers/discord/startup.js.
│   ├── twitch/                Wired up explicitly in handlers/twitch/startup.js.
│   └── youtube/               Wired up explicitly in handlers/youtube/startup.js.
│
├── handlers/                  Per-platform startup/bootstrap.
│   └── clientManager.js       Owns Discord/Twitch/YouTube clients and shutdown logic.
│
├── messages/                  Response builders (greetings, ping replies, etc.).
│
├── lang/discord/               Localized strings (en + es), split by platform.
│
├── db/                        Kysely-backed SQLite layer (flat — some tables are cross-platform).
│   ├── database.js            Schema creation and migrations.
│   ├── warns.js               Warn read/write helpers.
│   ├── scamHashes.js          Scam image hash CRUD.
│   └── …                      Other topical helpers (greetings, streams, etc.).
│
├── utils/                     Shared helpers.
│   ├── core/                  Zero-dependency cross-cutting code (loggers, constants, types, i18n).
│   ├── helpers/                Platform-agnostic app-aware utilities, image generation and its templates/.
│   │   └── templates/         HTML templates for Puppeteer (streamBanner/Followup/Ended.html).
│   └── discord/imageHash.js   Perceptual hash utilities (computeHash, hammingDistance, isSimilar).
│
├── data/                      Static reference data + runtime state (mounted in Docker).
│   ├── runtime/galabot.sqlite SQLite database file.
│   ├── twitch.json            Cached Twitch tokens.
│   ├── resources.json         Greeting/response pool used at runtime.
│   ├── emojis.json            Custom emoji mapping.
│   ├── AIPrompt.md            System prompt for Gemini AI replies. Uses {{BOT_NAME}} and {{GALA_USER_ID}} placeholders injected at startup.
│   ├── aiprompt-drafts/       Superseded AI-prompt draft history.
│   └── youtubeCategories.json Cached YouTube category mappings.
│
└── logs/                      Winston log output (created on first boot).
    ├── combined.log           Aggregated output from all loggers.
    ├── discord.log            Discord-specific events.
    ├── twitch.log             Twitch-specific events.
    ├── youtube.log            YouTube-specific events.
    ├── system.log             Startup/shutdown/fatal events.
    ├── db.log                 Database operations.
    └── ai.log                 Gemini AI events (retries, errors; full context if GEMINI_DEBUG_LOG=true).

How things work (developer guide)

Startup flow

main.js validates required env vars and instantiates clientManager. clientManager.initialize() (clientManager.js:16):

  1. Calls db/database.js → initialize() to create tables if they don't exist.
  2. Reads ENABLE_DISCORD / ENABLE_TWITCH / ENABLE_YOUTUBE and skips any platform set to the literal string "false". (Anything else, including unset, counts as enabled.)
  3. For each enabled platform, calls the relevant initializeXxx() method, which constructs the platform's client(s) and calls handlers/<platform>/startup.js → bootstrap().
  4. Registers SIGTERM / SIGINT handlers so shutdown() runs once on Ctrl-C or container stop.

Discord command auto-loading

handlers/discord/startup.js reads every .js file in commands/discord/ and stores it in a Collection keyed by command.data.name. A command file looks like:

const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");

module.exports = {
  data: new SlashCommandBuilder()
    .setName("mycommand")
    .setDescription("Does something")
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),

  async execute(interaction, client, clientManager) {
    await interaction.reply({ content: "Hello", ephemeral: true });
  },
};

After adding a new file, just restart the bot — events/discord/clientReady.js publishes the current commands/discord/ list to Discord automatically on every startup. Run npm run generate-cmds instead if you want the republish immediately, without restarting.

Current commands

Command Permission Description
/rules Manage Messages Posts or DMs the server rules embed with reaction roles.
/warn Manage Messages Issues a warning. Escalates: warn → timeout → ban at threshold.
/ban Ban Members Permanently bans a user and deletes up to 7 days of their messages (configurable).
/scamimage add Administrator Downloads up to 3 image attachments, computes their perceptual hash, and stores them in scam_image_hashes.
/scamimage list Administrator Lists all registered hashes with their IDs and optional descriptions.
/scamimage remove Administrator Deletes a registered hash by its database ID.
/aidocs Manage Messages Posts AI usage documentation.
/forcepolling Administrator Forces an immediate YouTube poll cycle.

Discord event auto-loading

handlers/discord/startup.js does the same scan for events/discord/*.js. Each file exports:

module.exports = {
  name: "messageCreate", // any discord.js event
  once: false, // optional; default false
  async execute(message, client, clientManager) {
    /* ... */
  },
};

Twitch and YouTube events (explicit wiring)

Twitch and YouTube do not auto-load. Their handlers are imported by name inside handlers/twitch/startup.js and handlers/youtube/startup.js. To add a new Twitch chat trigger, edit events/twitch/messageCreate.js (or add a new handler and import it from handlers/twitch/startup.js).

Localization

utils/language.js resolves a Discord channel ID to a locale: anything matching SPANISH_CHANNEL_ID returns es, everything else returns en. Strings live in lang/<topic>.js keyed by locale. Greetings and dynamic response pools live in data/resources.json so they can be edited without redeploying.

To add a third language, add a new locale key to each file under lang/, add a matching block to data/resources.json, and extend utils/language.js with a new mapping.

Image generation

utils/imageGenerator.js keeps a single Puppeteer browser instance alive (it relaunches on failure) and renders one of the HTML files in templates/ to PNG. Templates use {{BG_CLASS}}, {{LINK_TEXT}} and other markers that the generator replaces dynamically depending on the current provider (YouTube or Twitch) before rendering. The BANNER_SETTLE_MS and NEXT_STREAMS_SETTLE_MS constants in utils/constants.js control how long the page is given to settle (load fonts, run animations) before the screenshot.

Database

All DB access goes through Kysely. db/database.js declares the schema; the topical files (db/greetings.js, db/warns.js, db/streams.js) expose typed query helpers. All stream data (Twitch and YouTube) lives in the single streams table — the provider column distinguishes rows, and getMostRecentStream(provider) scopes queries per platform. To add a new table:

  1. Add a createTable(...) block to db/database.js → initialize().
  2. Create a new db/<name>.js with the helpers.
  3. Import and call those helpers from your event handlers.

There are no migrations — ifNotExists() means new tables are added on next boot, but column additions to existing tables require a manual ALTER TABLE against data/runtime/galabot.sqlite.

YouTube polling state machine

State lives in utils/youtubePoller.js (getState() / setState()). The workflow queries fetchAndCacheCategories() to resolve category IDs to strings via data/youtubeCategories.json.

The flow:

unknown ──slow poll discovers upcoming/live──▶ upcoming
upcoming ──fast poll: isLive=true──▶ starting   (grace tick)
starting ──fast poll: isLive=true again──▶ live  (fires streamStart)
live ──fast poll: endTime present──▶ ended      (fires streamEnd)

The starting state exists to avoid false positives from a single flaky API response. On bot restart, checks the DB for an unfinished YouTube stream and restores state to live so we don't double-announce.

Twitch token lifecycle

utils/twitchToken.js → getValidTwitchConfig() is called once during Twitch initialization. It reads data/twitch.json, refreshes the token if LAST_REFRESH is older than TOKEN_VALIDITY_MS, and writes the new tokens back. From there, StaticAuthProvider carries the token for the whole process lifetime — there's no in-process refresh loop, so very long-running bots should restart at least once every 59 days.


Templates

The bot renders image attachments via headless Chromium. The templates live in templates/:

  • streamBanner.html: Generated when a stream begins, replacing placeholders with title, category, and background images.
  • streamFollowup.html: Attached to the embed when a stream ends if upcoming scheduled streams exist on the platform.
  • streamEnded.html: Attached as a fallback if no upcoming streams are available at the end of a broadcast.

All templates dynamically apply a distinct color scheme and standard URLs based on the active provider (Twitch or YouTube).


Common tasks (recipes)

I want to… Edit
Add a Discord slash command Add a new file under commands/discord/ exporting { data, execute }, then restart the bot (or run npm run generate-cmds for an immediate republish).
Add a Discord chat behavior Edit events/discord/messageCreate.js (or add a new handler — Discord auto-loads new files).
Add a Twitch chat command Add a branch to events/twitch/interactionCreate.js.
Configure reaction roles on /rules Add REACTION_ROLE_RULES_EMOJI1=emoji:roleId to .env. Use REACTION_ROLE_{GROUP}_EMOJI* for other embeds.
Blacklist a YouTube stream Add the video ID to YOUTUBE_BLACKLIST_IDS in .env (comma-separated for multiple). No restart needed.
Tune greeting cooldown / ban threshold / timeout duration Edit constants in utils/constants.js.
Tune Twitch viewer poll cadence VIEWER_POLL_INTERVAL_MS in utils/constants.js.
Tune YouTube polling cadence YOUTUBE_FAST_POLL_MS and YOUTUBE_SLOW_POLL_MS in utils/constants.js (mind the quota).
Change the stream banner art Edit templates/streamBanner.html. Re-run the bot — Puppeteer reloads the file each render.
Disable a platform Set ENABLE_DISCORD=false / ENABLE_TWITCH=false / ENABLE_YOUTUBE=false in .env.
Change the AI bot personality / rules / examples Edit data/AIPrompt.md. Use {{BOT_NAME}} and {{GALA_USER_ID}} as placeholders — injected at startup.
Debug why the AI is replying unexpectedly Set GEMINI_DEBUG_LOG=true in .env, restart, and inspect logs/ai.log for the full injected context.
Register a known scam image Run /scamimage add image1:<attachment> as an Administrator. Up to 3 images per call; optional description: note.
Review registered scam hashes Run /scamimage list to see all IDs, hash previews, and dates.
Remove a false-positive scam hash Run /scamimage remove id:<id> with the ID shown by /scamimage list.
Tune scam image similarity threshold Edit HASH_THRESHOLD in utils/discord/imageHash.js (default 10 out of 256 bits, ~4%).
Format all source files Run npm run format. Run npm run format:check in CI to verify without writing.

Database schema reference

The schema is created by db/database.js on first boot. The file lives at data/runtime/galabot.sqlite. All stream data — regardless of whether it came from Twitch or YouTube — is stored in the single streams table. The provider column ('twitch' | 'youtube') distinguishes rows.

db/streams.js exposes a provider-aware API:

Function Purpose
insertStream(data) Insert a new stream row. data.provider is required.
getActiveStream(provider) Returns the current live stream (end IS NULL) for the given provider. Used by stream-end handlers and on-boot resume.
getMostRecentStream(provider) Returns the most recent stream regardless of end status. Use after updateStreamEnd to read back final data.
getStreamById(id) Fetch a specific stream row by ID.
streamExists(id) Boolean existence check.
updateStreamViewers(id, viewers) Update the running viewer average.
updateStreamEnd(id, endTime) Set the end timestamp.
updateStreamDiscordMessage(id, discMsgId) Store the Discord notification message ID.

greetings

Tracks when each user was last greeted (per-user cooldown).

Column Type Purpose
id integer (PK, auto) Row ID.
userId text User ID (Discord or Twitch).
timestamp datetime When the greeting fired.

warns

One row per warning issued, used for the /warn escalation logic.

Column Type Purpose
id integer (PK, auto) Row ID.
userId text Discord user ID.
timestamp datetime When the warn was issued.
reason text Free-form reason, capped at MAX_WARN_REASON_LENGTH.

streams (all providers)

Column Type Purpose
id text (PK) Stream/video ID (Twitch stream ID or YouTube video ID).
provider text (default 'twitch') Source platform: 'twitch' or 'youtube'.
timestamp datetime Stream start time.
title text Stream title.
viewers real Running average of viewer samples.
viewerSamples integer (default 0) Number of samples taken.
category text (nullable) Game / category name. Twitch only; NULL for other providers.
tags text (nullable) JSON-encoded array of tags. Twitch only; NULL for other providers.
thumbnail text (nullable) Thumbnail URL. YouTube only; NULL for other providers.
discMsgId text (default "") Discord notification message ID — used to update the embed on stream end.
end datetime (nullable) Stream end time; NULL while the stream is live.

reaction_role_messages

Tracks messages with reaction roles enabled (for persistent role assignment across restarts).

Column Type Purpose
id integer (PK, auto) Row ID.
message_id text (unique) Discord message ID of the message with reactions.
channel_id text Discord channel ID where the message was posted.
guild_id text Discord guild (server) ID.
group_name text (default RULES) Env var group name — drives which REACTION_ROLE_{GROUP}_EMOJI* vars are used.
created_at datetime When the message was tracked.

scam_image_hashes

Stores perceptual hashes of known scam images. On every incoming message with an image attachment, the bot computes the attachment's blockhash and compares it against every row here using Hamming distance. A match (≤ 10 bits different out of 256) triggers an auto-ban.

Column Type Purpose
id integer (PK, auto) Row ID. Used by /scamimage remove.
hash text 64-char hex blockhash (256-bit, computed with image-hash + sharp).
description text (nullable) Optional admin note set via /scamimage add description:.
added_by text Discord user ID of the admin who registered the hash.
added_at integer Unix timestamp (ms) when the hash was added.

upcoming_streams

Stores future Twitch and YouTube streams for AI context injection. Rows are upserted on every Twitch schedule sync / YouTube slow poll and deleted automatically when their start time passes.

Column Type Purpose
id text (PK) Twitch segment UUID or YouTube video ID.
provider text Source platform: 'twitch' or 'youtube'.
title text Stream title.
scheduled_start text ISO-8601 scheduled start time. Used for expiry — rows with a past start are deleted on next sync.
scheduled_end text (nullable) ISO-8601 scheduled end time. Twitch usually provides this; YouTube often does not.
url text Watch URL — YouTube watch link or TWITCH_URL env var value.
category text (nullable) Game / category name, or NULL when not available.

Operations & troubleshooting

Log files

File Contents
logs/combined.log Everything from every logger.
logs/discord.log Discord client events (login, command execution, embed posts, AI reply metadata).
logs/twitch.log Twurple chat connect/disconnect, EventSub subscriptions, viewer polling.
logs/youtube.log Slow/fast poll outcomes and state transitions.
logs/system.log Startup, shutdown, fatal failures.
logs/db.log Database operations.
logs/ai.log AI-specific events: retries, non-quota errors, think-block stripping. When GEMINI_DEBUG_LOG=true, also logs injected context and raw responses.

docker compose logs -f bot shows everything that hits stdout.

Common failures

FATAL: Missing required environment variables: … The required-vars list is DISCORD_TOKEN, DISCORD_ID, GALA_DISCORD_ID, GALA_USER_ID, TWITCH_CHANNEL, TWITCH_USERNAME, DISCORD_NOTIFICATION_CHANNEL, POST_DATA_WEBHOOK. Set the missing one in .env (or set the relevant ENABLE_*=false if you don't need that platform — but note that Twitch vars are required even if Twitch is disabled because they're in the unconditional REQUIRED_ENV list in main.js).

Puppeteer can't find Chromium You'll see an error like Failed to launch the browser process. Set PUPPETEER_EXECUTABLE_PATH to your Chrome/Chromium binary, or use the Docker setup (which has it pre-installed).

Twitch token expired / EventSub fails to subscribe Check data/twitch.json exists and has a valid REFRESH_TOKEN. If the refresh-token itself is dead, regenerate via twitchtokengenerator.com and overwrite the file. Restart the bot.

YouTube quota exhausted You'll see 403s in logs/youtube.log. The bot pauses search.list for 24 h automatically. Set YOUTUBE_API_KEY_2 to a key from a different Google Cloud project to fall back transparently.

Slash commands aren't appearing in Discord They're republished automatically on every bot startup — restart the bot, or run npm run generate-cmds to force it without restarting. Global slash commands can take a few minutes to propagate. Confirm the bot was invited with the applications.commands scope.

The bot isn't replying to @mentions with AI / is warning people instead GALA_USER_ID must be set to Gala's personal Discord user ID (not the bot's). The bot detects its own @mention via client.user.id at runtime — no env var needed for that. GALA_DISCORD_ID is the guild/server ID and is unrelated to mention detection.

AI replies return "I can't reply right now" GEMINI_API_KEY is either unset or invalid. Get a key from Google AI Studio and set GEMINI_API_KEY in .env. Also ensure GEMINI_ENABLE is not set to false. If the model name in GEMINI_MODEL is wrong or unavailable on the free tier, requests will fail — leave it unset to use the default gemma-4-26b-a4b-it.

AI replies feel wrong / bot is ignoring context Set GEMINI_DEBUG_LOG=true in .env and restart. logs/ai.log will show the exact context block (user info, conversation history, stream data) and raw model response for every query. Turn it off again after debugging — it logs user data.


Known gaps

  • No automated tests. Verification is manual.
  • Single-channel by design — monitoring multiple Twitch or YouTube channels would require generalizing clientManager and the state in utils/youtubePoller.js.
  • No DB migrations — adding columns to existing tables requires a manual ALTER TABLE.
  • Twitch tokens are loaded once per process; restart at least every ~59 days.

License & credits

No LICENSE file is currently present in the repository. Treat the source as "all rights reserved" until one is added.

Built around the streamer Gala and her dinosaur mascot. Powered by discord.js, Twurple, Kysely, better-sqlite3, Puppeteer, and Winston.


About

Source code for a custom Discord bot designed specifically for the GalaYaki VTuber server

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages