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).
- Feature highlights
- Architecture overview
- Quick start (Docker)
- Quick start (local Node)
- Configuration (environment variables)
- Discord setup walkthrough
- Twitch setup walkthrough
- YouTube setup walkthrough
- Running
- Project layout
- How things work (developer guide)
- Templates
- Common tasks (recipes)
- Database schema reference
- Operations & troubleshooting
- Known gaps
- License & credits
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
/rulesis 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 addand 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 indata/AIPrompt.md— edit it without touching code. RequiresGEMINI_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.offlineevents 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 inlang/.
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.
┌────────────────────┐
│ 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:
clientManageris the single owner of every long-lived resource (Discord client, Twurple chat client, EventSub listener, YouTube polling intervals, Puppeteer browser). Shutdown is centralized inclientManager.js.- Each platform is independently toggleable. Setting
ENABLE_DISCORD=false/ENABLE_TWITCH=false/ENABLE_YOUTUBE=falseskips its initialization entirely. All three default to enabled. - Only Discord auto-loads handlers from disk. Discord scans
events/discord/andcommands/discord/at startup. Twitch and YouTube wire their handlers up explicitly insidehandlers/<platform>/startup.js.
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 botThe 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.shSlash 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-cmdsPrerequisites: 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 startupNotes:
- On Linux (apt-based),
sudo apt install chromiumand setPUPPETEER_EXECUTABLE_PATH=/usr/bin/chromiumin.env. - On macOS, point
PUPPETEER_EXECUTABLE_PATHat/Applications/Google Chrome.app/Contents/MacOS/Google Chrome(or install Chromium via Homebrew). - On Windows, point
PUPPETEER_EXECUTABLE_PATHat e.g.C:\Program Files\Google\Chrome\Application\chrome.exe. data/andlogs/are created automatically on first run.
main.js exits with FATAL if any of the required variables is missing. The committed .env.example lists every variable below.
| 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). |
| 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. |
| 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. |
| 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. |
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:
- User info — Discord username, server nickname (if set), how long they've been a member, account age, roles, and booster status.
- Conversation history — up to 4 messages of reply-chain context so the bot doesn't lose thread.
- Upcoming streams — fetched from the
upcoming_streamsDB 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. |
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. |
- Create a bot at https://discord.com/developers/applications → New Application → Bot tab → Reset Token (paste into
DISCORD_TOKEN). - 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.
- Copy the Application ID from the General Information tab →
DISCORD_ID. - Invite the bot to your server using the OAuth2 URL Generator with scopes
botandapplications.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
/warnand auto-moderation)
- 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
- The guild/server ID →
- Once
DISCORD_TOKENandDISCORD_IDare 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
- (Optional) Sync custom emojis if you maintain
data/emojis.json:npm run sync-emojis
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.
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:
- Visit the token generator while logged in to the bot account.
- Pick the scopes above.
- Authorize.
- 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 } - On the next boot,
utils/twitchToken.jswill refresh the token and updateLAST_REFRESH. After that it auto-refreshes when the token is older thanTOKEN_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.
- Get an API key from the Google Cloud Console → enable the YouTube Data API v3 → create an API key. Paste into
YOUTUBE_API_KEY. - (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. - Find the channel ID of the streamer's YouTube channel — the 24-character ID starting with
UC. Paste intoYOUTUBE_CHANNEL_ID. - Notifications are posted to the same
DISCORD_NOTIFICATION_CHANNEL(and pinged with the sameDISCORD_NOTIFICATION_ROLE_ID) as Twitch announcements — no extra config needed. - (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.
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).
| 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.
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).
main.js validates required env vars and instantiates clientManager. clientManager.initialize() (clientManager.js:16):
- Calls
db/database.js → initialize()to create tables if they don't exist. - Reads
ENABLE_DISCORD/ENABLE_TWITCH/ENABLE_YOUTUBEand skips any platform set to the literal string"false". (Anything else, including unset, counts as enabled.) - For each enabled platform, calls the relevant
initializeXxx()method, which constructs the platform's client(s) and callshandlers/<platform>/startup.js → bootstrap(). - Registers
SIGTERM/SIGINThandlers soshutdown()runs once on Ctrl-C or container stop.
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. |
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 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).
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.
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.
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:
- Add a
createTable(...)block todb/database.js → initialize(). - Create a new
db/<name>.jswith the helpers. - 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.
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.
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.
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).
| 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. |
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. |
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. |
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. |
| 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. |
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. |
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. |
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. |
| 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.
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.
- No automated tests. Verification is manual.
- Single-channel by design — monitoring multiple Twitch or YouTube channels would require generalizing
clientManagerand the state inutils/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.
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.