Skip to content

Changelog ​

All notable changes to Egg Shen Bot will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

2.73.0 - 2026-11-03 ​

Added ​

  • /siege: the whole server fights a monster. One of 11 monsters attacks a games channel, among them the Blob, Gremlins, the Count, the Wolfman, Killer Klowns, Pod People and the Atomic Lizard (plus the Headless Horseman in October), each with a trait of its own. A card with its HP bar shows the fight.

    • Fight it: /siege attack weapon:food, weapon:doom or weapon:potion, from its channel or a thread of it. Food is steady, a doom hits hard but can spook you, and a potion poisons it over time. A πŸ₯ˆ Silver Bullet can't backfire and does triple damage.
    • It fights back: every few minutes it grabs a fighter, who can't attack until someone else frees them with a normal /rescue, or poisons one, who does half damage until someone gives them a helpful /potion. Someone left grabbed for 10 minutes is out of the fight and loses 5.
    • Win or lose: beat it in time and everyone who helped shares the points, by damage plus every fighter freed and cured. The MVP gets a bonus and loot. If it escapes, everyone who attacked loses 3.
    • When: admins start one with /siege start and end it with /siege end. Monsters also turn up at random a few times a week while people are playing. /eggshen-config-games happenings siege-frequency: and siege-minutes: set how often and how long.
    • Limits: the first 30 attacks a day score, and rewards come from at most 2 sieges per person a day. A siege won alone pays only its share of the points.
    • New titles: Monster Hunter, Siege MVP, Legend of the Drive-In, Kaiju Killer, Field Medic and Monster Chow. Every 4 wins earns a Silver Bullet.

    See Siege and the siege guide

  • /how-to-play: quick help for the games, privately, by topic: the basics, items, prizes and titles, nights in a row, twists, happenings and sieges. Every number is this server's own, so it never goes out of date. See Games Quick Guides

  • Games guides and FAQ on the site, and /eggshen-ask answers questions about the games from them: "how do I use a Golden Pie?", "someone got grabbed, how do I save them?"

  • Drawn results. A decided tournament matchup posts a picture: the two posters, the winner framed in gold with a trophy, a lightning VS, the vote split, and an UPSET! stamp when there is one. A closed survey posts a chart of the results. If a picture can't be made, the card posts as before

Changed ​

  • Tournaments wear the Egg Shen look. These use the same icons and stone bars as the game cards:
    • Live standings and /bracket status: each matchup gets stone bars (or one split bar when there are many), and the totals read πŸ—³οΈ votes Β· πŸ‘₯ voters Β· βš”οΈ matchups. A card picks its layout from the number of matchups and the titles, never from the votes, so it doesn't jump around while people vote. /bracket status now shows up to 16 matchups, up from 12
    • Tiebreakers: the leader's bar is gold, the rest blue
    • Matchup results: a split bar of the vote, πŸ† Winner and πŸ₯ˆ Runner-up
    • Group results: medals beside the titles that advance
    • Your ballot: a jade tick beside each title you picked
  • Surveys: the leading option's bar is gold and the others blue, with 82% Β· 14 votes beside each. Once a survey closes, the bars turn grey and the πŸ† marks the winner. Bars shrink to fit a survey with long options, rather than the card failing to post
  • Escape Velocity: the climb is three carved stone bars, and streaks show as πŸ”₯ 3
  • The bracket image uses the bot's own fonts, and its ballot and lock signs draw properly instead of as empty boxes
  • The leaderboard's server stats include how many sieges were won and how many got away

Fixed ​

  • /survey results could fail to reply when your picks had long names (10 options of 100 characters went over Discord's limit for one field). It now shortens them

Developer ​

  • Siege: src/utils/siegeStore.js holds the state, stored in the happenings file. Every write is keyed by siege id, and both the arrival card and the results are claimed before they're sent, so a restart can lose a post but never send one twice, and never lock /siege. src/utils/siegeCard.js draws the cards and src/commands/siege.js is the command. The happening scheduler runs poison ticks, monster actions (claim, announce, then void after 90 s), digests, random spawns (p = 1 βˆ’ (1 βˆ’ R/7)^(1/240); measured 1.58, 3.09 and 5.01 a week), card edits and results. src/data/siegeMonsters.js holds the monsters and siegeAttackLines.js the 136 attack lines written for monsters. Every tunable is under SIEGE in gameRewards.js. Rescues and cures are siegeAssist inside finishPlay
  • Siege HP is round(clamp((2Β·A7 + 45Β·F)Β·D, 40, 2000)Β·monster.hp), recomputed as each fighter joins, with no growth in the last 5 minutes. The server's difficulty dial moves Γ—1.05 on a win and Γ—0.88 on an escape. It only moves when 2 or more fought, an escape only lowers it if they did at least half the monster's HP, and it stays between 0.5 and 2.5
  • Siege tests: tests/harness/siegeSim.js sends real interactions through dispatchCommand, runs real scheduler passes, restarts the bot, and makes Discord fail on purpose, checking invariants after every step. Suites: siege-sim, siege-sim-restarts, siege-sim-fights-back, siege-store, siege-commands, plus tests/fuzz/siege.fuzz.js. An independent review's 8 bugs and 4 suspected ones were all fixed with scenarios, and 58 planted bugs were all caught
  • /how-to-play: src/utils/howToPlay.js reads every number from the reward tables and the server's settings. Public cards answer clicks privately. docs/guides/games/ adds a 22-question FAQ and two how-to guides, and docsIndex treats an apostrophe's leftover letters as stopwords
  • tournamentUI.formatStandings(matchups, round, {kit, budget}) with STANDINGS_LAYOUTS (two, split, squares, then titles held to 50). The layout is measured at 9,999 votes a side against the whole message with messageProblems, and falls back to squares when the kit isn't ready. Also standingsTotals and rankedBar (replaces createRankedBar). 13 unused helpers removed. formatKnockoutVotes clips with clipText
  • pollManager.createPollEmbed(poll, showResults, {kit}): bar width from SURVEY_BAR_WIDTHS [10, 8, 6, 5, 4], then squares, then none, measured at 9,999 votes. The β–ˆβ–“β–‘ helper is gone
  • escapeUI: kit.ladder owns its formatting (no code span once the bars are custom)
  • emojiKit.bar: a muted plain-emoji bar keeps its colour, since the only plain grey square is the empty one
  • Tests: standings-layout, survey-card, escape-ui, and tournament simulator runs of a 32-title knockout and a groups tournament under the longest custom kit. A new invariant after every step: no custom emoji in a title, author, footer, button or menu label, or code span
  • Images: src/utils/canvasKit.js holds the bundled fonts (Anton and Barlow, OFL, in assets/fonts/; missing glyphs fall back by cmap), drawKitIcon, loadImageCached (an LRU of 64, 30-minute TTL) and the Egg Shen stone, stud and lightning painters. src/utils/cardImages.js draws the matchup result and survey chart, each with a 5-second deadline and a text-only fallback
  • Deploy: new commands (/siege, /how-to-play) and new options need deploy-commands

2.72.1 - 2026-11-02 ​

Fixed ​

  • Typing an item's name said "That isn't an item." item:Golden Pie, as the docs show it, only worked if you picked it from the suggestions. Typed names now work, in any case, with or without the emoji, or as a part only one item has (pie, water). So do typed titles in /scoreboard title: (Red Shirt, No title), foods in /foodfight lines and potion types in /potion responses
  • Asking for an item you don't have now says so and how to earn one ("Every 25 hits earns one, and /scoreboard shows how close you are"). An empty item list points to /scoreboard too. Something that isn't an item lists the ones you can use

Developer ​

  • gameRules.resolveTyped(value, table, nameOf): the key itself, a key or name typed in any case with or without its emoji, a whole pasted choice label, or a part of a name only one entry has; otherwise null. Used by item:, title:, /foodfight lines food: and /potion responses type:. It keeps the own-key check against inherited keys
  • Tests: typed names through the real commands, with each resolution rule break-tested
  • No command changes: restart only

2.72.0 - 2026-11-01 ​

Added ​

  • Twists. Now and then (4% of scoring plays) something happens to you on top of your play. Some are good:

    • πŸ’° Loose Change, +3
    • 🎁 Glovebox Find, a free item
    • πŸ€ Lucky Break, your next play goes your way
    • πŸ‘» Friendly Ghost, a 30-minute shield from /doom

    Some are bad:

    • 🦝 Pickpocketed, βˆ’3
    • πŸ§› Bitten, half points for 3 plays
    • πŸˆβ€β¬› Jinxed, your next play keeps the worse of two draws
    • πŸš— Car Break-In, an item stolen

    There are 16 in all. They only ever touch you, never the person you aimed at. You get at most 3 a day, a bad one is always followed by a good one, and twists can cost you at most 8 points a day and never take your month below 0. Ongoing ones show on your card under πŸŒ€ In effect. 30 twists earns the 🎲 Plot Twist title. See Twists

  • Happenings: events for the whole server. About once a day on a busy server, something takes over for 30–60 minutes and the bot announces it:

    • πŸŒ• Full Moon: dooms score double
    • 🍻 Happy Hour: feeds are tastier
    • 🌫️ The Fog: throws miss a lot more
    • 🎞️ Double Feature: every point counts twice
    • πŸŒ… Sunrise: rescues rarely get caught, and shields last longer
    • πŸ§ͺ Mad Scientist Hour: potions backfire less
    • πŸ“Ό Be Kind, Rewind: a bad play won't break your streak
    • πŸŽͺ Intermission: twists are three times as likely

    Halloween and Friday the 13th each get an all-day happening of their own. A play a happening changed shows its tag (πŸ’€ Doomed! πŸŒ•Γ—2), and the cards and the leaderboard show what's on. See Happenings

  • /eggshen-config-games happenings (Admin/Mod): how often happenings come (off, rare, normal, often), which channel announces them, start or end one now, and switch twists off or change their chance. Both are on after this update. See Happenings and twists settings

Developer ​

  • src/data/gameHappenings.js is the contract between the scheduler and the play rules: each happening's duration, rate and mods (weights, points, goodBonus, shieldMult, keepStreaks, twistMult). Calendar happenings are worked out from the date
  • src/utils/happeningStore.js (guild_happenings/, GUILD_HAPPENINGS_DIR; atomic writes with .bak): activeHappenings checks expiry by the clock, so effects stop on time even if the scheduler runs late. Also noteActivity (throttled, 7-day prune) and maybeSpawn (gap, daily cap, recent players, real lunar phase for Full Moon). Spawn chance per active minute is Ξ£rate Γ— frequency / 240; seeded simulations give about 1 a day at normal
  • src/utils/happeningScheduler.js: a 60-second pass with a per-server running flag. It saves the claim before sending, so an announcement is never sent twice or late after a restart, and checks channel permissions before posting
  • Twists in recordPlay, rolled with gameLuck after the play's own scoring. Statuses live in users[uid].effects. Happening weights apply in resolvePlay, and points inside MULTIPLIER_CAPS (shield Γ—6 max). GOOD moved to gameRules.js (re-exported)
  • Tests: game-twists, game-happening-mods, happening-store, game-happenings (FakeDiscord, fake clock, real commands, restart cases), plus card and leaderboard cases. 110 rules break-tested. GAME_HAPPENINGS=off in jest.setup.js keeps calendar days from changing other suites' draws
  • New subcommand: needs deploy-commands

2.71.0 - 2026-11-01 ​

Added ​

  • The cards have their own icons. Game cards and the leaderboard now use a set of 64 icons drawn in Egg Shen's own style, matching the bot's logo:

    • bars are carved stone channels filling with blue lightning
    • your nights row is a string of paper lanterns
    • medals and points are old Chinese coins
    • the Glovebox is the Six Demon Bag
    • every game and item has its own icon

    They'll come to tournament and survey cards too. Nothing to set up: the bot adds them to itself the first time it starts. Until then, and anywhere they can't load, the cards use ordinary emoji and read the same

Developer ​

  • src/utils/emojiSync.js uploads the kit as application emojis (they work in every server without Use External Emojis) on startup: one bounded read before the schedulers start, then any missing ones in the background, retried hourly. EMOJI_SYNC=off skips it. Names are es_<code>_<hash>; changed art is uploaded beside the old version, never replacing it, because posted messages point at emojis by id. npm run deploy-emojis (--dry-run, --prune for versions over 30 days old) does it by hand
  • src/utils/emojiKit.js: readiness by family (bars, ticks, icons), so a bar never mixes custom and plain pieces
  • The art is drawn in code (scripts/emoji-styles/egg-shen.js, canvas vector shapes) and built by npm run build:emojis into assets/emojis/<code>.png, manifest.json (hash = sha256 of the PNG) and contact-sheet.png. The PNGs are committed, since servers install with --omit=dev. assets/emojis/art/<code>.png overrides any drawing
  • Tests: emoji-sync (fake application emoji manager: uploads once, keeps old versions, prune rules, foreign emojis untouched, partial failures and retry, name clashes, the 2,000 cap) and emoji-art (every code drawn, 128Γ—128 with transparency, bar edges line up, manifest hashes match the PNGs)
  • Self-hosting: see Deployment. Give a development bot its own Discord application, since emojis belong to the application
  • No command changes: restart only

2.70.0 - 2026-11-01 ​

Added ​

  • Prizes for playing. Every 25 hits earns a πŸ₯§ Golden Pie, every 15 tasty feeds a πŸ§„ Garlic, every 25 dooms a πŸ“Ό Cursed VHS, every 15 rescues a πŸ’§ Holy Water, every 15 helpful potions an πŸ’Š Antidote, every 20 harmful ones a πŸ€ Lucky Penny, plus 3 bonus points each. Every 750 points you gain earns a random one. Only your own scoring plays count, and you can win at most 2 prizes a day: a counter that fills after that waits at 24/25 until tomorrow. Your card shows each counter's progress. See Prizes

  • The Glovebox: items you can use. Add item: to a play to use one: /foodfight throw target:@Sam item:Golden Pie. The list shows only what you hold and what fits that play.

    • πŸ₯§ Golden Pie: a throw that can't miss and scores double, or a feed that's sure to be tasty
    • πŸ“Ό Cursed VHS: a doom that can't backfire, cuts through Garlic and rescue shields, and scores double
    • πŸ’§ Holy Water: a rescue that can't be caught, with a shield three times as long
    • πŸ€ Lucky Penny: a potion that's sure to work, at double points
    • πŸ§„ Garlic and πŸ’Š Antidote protect you by themselves, from a doom and from a harmful potion
    • πŸ”¦ Night Light saves your nights row once when you miss a night

    One item per play, up to 3 a day. Items do nothing on a play past the daily scoring cap; they stay in your Glovebox. See The Glovebox and items

  • Titles. 19 titles for what you do and what's done to you, among them Pie Sniper, Final Girl, Human Dartboard, Red Shirt, Guinea Pig and Night Owl. Each is worth 10 points. Show one beside your name on your card and the leaderboard with /scoreboard title:. Members who had already earned some got them quietly, without the points. See Titles

  • Nights in a row. Play on consecutive nights to climb the tiers: Regular at 3, Night Owl at 7, Creature of the Night at 14, each adding a point to your first few good plays a day. A night runs noon to noon UTC, so an evening watch party counts as one night. Your card shows the last 14 nights. See Nights in a row

Changed ​

  • Your game card adds Rewards in progress, your nights row, your Glovebox and your titles
  • /foodfight lines picks the food from a list

Developer ​

  • src/data/gameRewards.js holds every tunable (items, prizes, caps, multipliers, nights tiers, titles); gameRewardLines.js holds the wording
  • src/utils/gameRules.js: pickLine draws once over per-line weights and is identical to the old pick at equal weights, so existing Math.random stubs still land on the same lines. Item effects, nights and title progress live here too. gameLuck.js is the second random source (GAME_LUCK=off in tests)
  • gamePlay.resolvePlay draws the line (item, shield, defences) and recordPlay saves, back to back with no await, in one mutateScores load-modify-save. recordPlay still accepts a bare outcome. The scores file keeps a .bak and recovers from it
  • Title progress and streak titles count scoring plays only (capped plays never build them); a shield, Garlic or Antidote only defends a scoring play
  • getPlayerCardExtras (src/utils/gameCardData.js) feeds the card's new sections; getLeaderboard rows carry shownTitle
  • /foodfight lines food is autocomplete (5,980 β†’ 4,082 bytes) to make room for item: on every play
  • Tests: game-rules, game-rewards, plus new game-commands cases driving the real autocomplete values; each rule break-tested. An independent review ran 40,000 random plays against the invariants (items spent if and only if used, ≀3 defences, ≀2 prizes and ≀3 item uses a day, nothing scored past the cap)
  • New and changed command options: needs deploy-commands

2.69.0 - 2026-11-01 ​

Added ​

  • Right-click a member β†’ Apps β†’ Game card to see their game card, just for you. No typing, no picking them from a list. See Game card

Changed ​

  • /scoreboard is a game card. Your avatar, your total and rank as at-a-glance numbers, then a column for each game: points and rank, plays, what you did and what was done to you, and your streak. An active rescue shield shows too. Same options as before (user, game, period, private). See Scoreboard

  • /leaderboard is one card for the whole server, with the leader's avatar at the top:

    • the top 15, one line each, with a medal, a bar against the leader and their points
    • who's leading each game
    • who got splatted, fed, doomed, rescued, poisoned and healed the most
    • server stats: plays, points, players, the most played game and the best streak

    Games switched off on the server are left out of it entirely. See Leaderboard

  • /eggshen-config commands toggle can switch off /escape, /quote and /suggest-quote. Those switches existed but couldn't be reached

Fixed ​

  • Switching off /doom, /foodfight or /escape failed on servers set up before those games existed. The toggle only accepted settings the server had already saved
  • A survey or a closed tournament card with a very long title could fail to post or update. Titles and descriptions are now shortened to fit, without cutting an emoji, a mention or a timestamp in half

Developer ​

  • src/utils/embedFit.js: Discord's message limits (LIMITS), messageProblems(payload), embedTextLength, clipText (never splits custom emoji, <t:…>, mentions, slash mentions, surrogate pairs or a backtick pill), chooseLayout, fieldsFromLines
  • src/utils/emojiKit.js: the kit renderers draw through (e, game, bar, splitBar, ticks, ladder, pill, progressPill), with per-family readiness. Plain emoji only in this release; the pixel-art kit and its upload follow
  • src/utils/gameCard.js (renderGameCard, CARD_STEPS, replyWithGameCard) and src/utils/leaderboardCard.js (renderLeaderboard, LEADERBOARD_STEPS): pure renderers that degrade step by step until the card fits. Cards are drafted as plain embed objects, because EmbedBuilder throws on an over-limit value instead of shrinking. getLeaderboard returns 15 rows, perGame leaders and totals, and both getters take the server's enabled games
  • src/handlers/commandDispatch.js: the slash-command path moved out of index.js, now also taking user context-menu commands. rememberCommand is for slash commands only. docsAnswer, deploy-commands, check-command-size, the rate limiter and the logger handle a command named "Game card"
  • updateCommandPermission validates against defaultConfig.commandPermissions, not the saved config
  • The fake Discord is as strict as Discord about limits. An over-limit message or autocomplete reply throws a 50035 DiscordAPIError and is recorded in discord.violations, which the tournament and Escape simulators assert stay empty. New userMenu() interactions
  • Tests: embed-fit, emoji-kit, game-card, leaderboard-card, command-dispatch, command-permission-toggle, fake-discord-limits, embed-overflow, plus rewritten /scoreboard and /leaderboard cases in game-commands. Each was break-tested
  • New user command and changed /eggshen-config choices: needs deploy-commands

2.68.1 - 2026-10-31 ​

Changed ​

  • Bracket winners keep their poster and get a trophy. In the bracket image, a matchup's winner used to turn solid light blue with dark text. It now looks like every other title, with its poster behind it, drawn slightly larger with a soft shadow and a shining gold πŸ† at the end
  • The champion card has a trophy either side of "CHAMPION", instead of one squeezed in above the card

Fixed ​

  • The champion's trophy no longer covers the finals. It sat in the narrow gap above the champion card, over the bottom of the finals winner's title
  • Matchup labels (1A, 1B…) all sit at the same height. Every label after the first was drawn lower than intended, right on its matchup's frame

Developer ​

  • bracketVisualizer.js: WINNER_SCALE (1.08) grows a winner's tile around its centre; the winner is drawn after its opponent so its shadow isn't painted over. drawTrophyIcon rewritten as a shaded, glowing trophy with sparkles (drawSparkle), drawn as shapes since the server has no colour emoji font. The matchup label sets its own textBaseline rather than inheriting the last title's middle. COLORS.winner removed
  • tests/bracket-visualizer.test.js: 3 tests on the rendered pixels of a real generateBracketImage (winner filled like its opponent, larger than its slot, gold trophy only on the winner)
  • No command definition changed: restart only

2.68.0 - 2026-10-29 ​

Added ​

  • Survey ties now get settled. When a survey closes with two or more options tied for first, a runoff is posted right below the results, with only the tied options. It's one vote each, and it runs as long as the original survey did (or 24 hours if it had no timer). If the runoff ties too, the bot picks one at random and says so with a 🎲
    • /survey create has a new tie-break option: Runoff vote (default) or None, for surveys where a tie is a fine answer
    • A survey nobody voted in, or a tie for second place, doesn't start a runoff. /survey results on a tied survey links to its runoff

Fixed ​

  • A survey closes only once. /survey close and the auto-close timer landing at the same moment could each post results

Developer ​

  • pollManager.js: createPoll takes { tieBreak, round, runoffOf } and stores durationMinutes; closePoll returns null when already closed (the closing claim); new findTie, runoffMinutes, pickRandomWinner (injectable random, done once) and tieBreakOf (surveys without the field read as runoff). closePollAndAnnounce posts the results, then the runoff as a new message
  • tests/survey-tie-break.test.js: 12 tests through the real closePollAndAnnounce, the runoff card's own button through the real button handler, and the real /survey create
  • Command definition changed: run deploy-commands

2.67.1 - 2026-10-27 ​

Fixed ​

  • /announce schedule's event list now appears. Discord limits how often a bot can look up a server's events, and the lookup could wait about 12 seconds, much longer than the 3 seconds Discord gives the list to show up. So it showed nothing. The list now comes from a copy the bot keeps up to date, and while that's refreshing, the list says it's loading rather than staying empty
    • An empty list now says why: no upcoming events (with how to create one), nothing matching what you typed, or that only moderators can schedule

Developer ​

  • upcomingEvents.js: one events cache per guild (guildEvents), shared by the picker and the announcement scheduler, whose passes keep it warm; one fetch in flight per guild; waitMs lets autocomplete give up after 2s and answer from the cached list, however old. Measured in production: a second scheduledEvents.fetch within seconds waited ~12s on the rate limit, which discord.js waits out silently
  • /announce schedule checks the cached list first and fetches fresh only if the picked event isn't in it
  • tests/announce-schedule.test.js: 6 tests with a stalled fetch (stale-cache answer, "loading" then the list, one fetch for two keystrokes, scheduler warming the picker, explanation lines never matching a real event)

2.67.0 - 2026-10-25 ​

Added ​

  • /announce schedule posts a watch-party announcement automatically, a set time before a scheduled event. Start typing in event to pick from the server's upcoming events, then say how long before the start (hours, minutes) and where (channel, such as #announcements). It posts the full watch-party card, with the start time shown in each reader's own timezone and the event's link so people can click Interested
    • Tied to the event: if the event is moved, the announcement moves with it; if it's cancelled, so is the announcement; if the bot was offline past the start, it's skipped rather than posted late
    • Title: taken from the event's name (a year like "(1978)" is ignored, episode ranges are picked up), with title1 for events named something else. Titles are checked when you schedule, so a typo shows up right away
    • /announce scheduled lists what's waiting to post, with a Cancel button for each, and what happened to recent ones
  • A "starting soon" card 10 minutes before every scheduled event, posted in the event's own channel (its voice channel's chat, or the channel named in its location), with the event's cover image and link. On by default; turn it off with /eggshen-config-watch-party watch-party starting-soon enabled:false

Developer ​

  • New: src/utils/announcementScheduler.js (60-second pass; follows moved/cancelled events; claims each post before sending so a restart can skip one but never repeat it), announcementStore.js (scheduled_announcements.json, synchronous load-modify-save), upcomingEvents.js (cached guild.scheduledEvents.fetch, autocomplete choices, eventChannelId), announcementCompose.js (the /announce party card code, moved out of announce.js unchanged so scheduled and hand-posted cards match)
  • guildConfig.js: announceStartingSoon: true default, read through getStartingSoonEnabled. buttonHandler.js routes ann_cancel_ to announce.js
  • tests/announce-schedule.test.js: 14 tests driving the real autocomplete into the real command, then scheduler passes at controlled times
  • Command definitions changed: run deploy-commands

2.66.0 - 2026-10-21 ​

Added ​

  • Escape Velocity (/escape): a multi-day genre trivia event in the style of Who Wants to Be a Millionaire?. Every question is about movie and TV genres and subgenres: slashers, folk horror, space opera, heists, anime and more. Climb 15 stages, each worth more and harder than the last, from 100 points to 1,000,000 for reaching orbit
    • One launch a day (UTC). A wrong answer ends that day's run; your best run across the event is your score
    • Stage separations at 5 (1,000) and 10 (32,000): once cleared, a later wrong answer can't take them back. Abort any time to keep what's banked
    • Mission card: a public card in the event's channel shows the top 10 and has πŸš€ Launch and πŸ“Š My mission buttons. Launch opens your own private dashboard with the question, four options, what you've banked and your progress. A fresh card is posted each day, and the channel hears about anyone who clears stage 10 or reaches orbit
    • Real data: questions are built from TMDB, so answers are always right: name a title from its synopsis, its lead, director, year, creator, network, number of seasons, who played a role, and "which of these is a slasher film?" Popular titles make easy questions, obscure ones hard
    • /escape play, /escape rules and /escape standings
  • /escape-setup runs an event (admins and moderators). create opens a private screen to pick movies and/or TV and any of 8 genres (each brings its subgenres), then builds about 125 questions and launches. pool, extend, end-now and cancel manage it while it runs, and the bot adds questions on its own as people play
    • Lifelines (Jettison, Poll mission control, Hail a crewmate) come in the next releases

Developer ​

  • New: src/commands/escape.js, escape-setup.js, src/handlers/escapeHandler.js (all ev_ buttons and setup selects), src/utils/escapeStore.js (one event per guild in guild_escape/, atomic + .bak, synchronous load-modify-save helpers; markLaunched/rolloverDay/finishEvent report a change once so posts never repeat), escapeQuestions.js (templates, popularity tiers, decoy rules), escapeUI.js, escapeScheduler.js (launch, UTC rollover, card refresh, finish, pool top-up), src/data/escapeGenres.js
  • scripts/verify-escape-genres.js checks every genre name and keyword id against live TMDB and that each filter actually narrows results
  • tmdbService.js: buildDiscoverParams takes keywords (OR), minVotes/maxVotes and page; new discoverPage and getTriviaDetails. gameScores.js exports dayKey. escape: true added to commandPermissions
  • Test harness: httpReplay takes a per-simulator fixture file and a shrink hook, and while recording replays a response already captured in the same run (live data drifting between two identical requests made replays take a different path). fakeDiscord records allowedMentions
  • New tests: escape-store, escape-questions (recorded TMDB), escape-sim and escape-sim-restarts (tests/harness/escapeSim.js). Re-record with SIM_RECORD=1 npm test -- --runInBand tests/escape-sim.test.js tests/escape-sim-restarts.test.js
  • New commands: run deploy-commands

2.65.0 - 2026-10-17 ​

Added ​

  • /timer title sets the timer's duration from the title's runtime. The runtime counts from when the timer started, since the timer was started when playback was. 20 minutes into a 100-minute film, the duration becomes 1h 50m (runtime plus the usual 10-minute buffer) with about 90 minutes left. Episode ranges ("Show S2: E1-E3") get the same summed total /timer start would give them, and a paused timer gets the new duration when it resumes
    • The duration stays as it was for a show with no episodes given, when the starter set the duration and someone else is naming the timer, or when the timer has already run past the runtime. The reply says which
  • /timer title posts a Now Watching card with the title's art, year and synopsis, plus the timer's duration, elapsed and remaining time and when it ends

Fixed ​

  • /timer adjust and the Extend button no longer end a timer early after a pause. The new end time counted paused minutes as watched, so a 10-minute break made an adjusted timer stop 10 minutes early

Developer ​

  • src/utils/timerManager.js: adjustTimerDuration computes remaining time from elapsedMsOf (the pause fix). It now works on a paused timer (sets remainingMsAtPause, schedules nothing) and takes { realDuration } to clear isFallbackDuration
  • src/commands/timer.js: durationForTitle uses the start flow's rules (movie runtime + 10; TV only with an episode range). applyTimerTitle changes the duration for the starter, a moderator, or anyone on a fallback-only timer. buildTitleCard is new, and fetchTimerArt accepts details already in hand
  • tests/timer-set-title.test.js: 10 new tests; its afterEach now drains queued timer saves, which were landing on the next suite's timers file. tests/timer-pause-resume.test.js: 2 new tests
  • Command definition changed: run deploy-commands

2.64.1 - 2026-10-17 ​

Fixed ​

  • /timer title now lets you pick the right title, like /movie and /tv do. Before, if what you typed wasn't one exact, unambiguous title, the timer was named with your text and left unmatched, so "Triangle" or "the thing" never got identified for watch history. Now a clear match is used right away, and anything ambiguous shows a picker of movies and shows, with exact title matches listed first
    • The first option, Keep "…" as typed, names the timer without matching it. Only a title TMDB finds nothing for is named with your text directly
    • Episode notation ("Show S2: E1-E3") gets the same picker when several shows share the name, and keeps the episode range

Developer ​

  • src/utils/watchTitle.js: new searchWatchTitle returns the matcher's raw movie/TV results alongside match; resolveWatchTitle wraps it
  • src/commands/timer.js: /timer title falls back to buildTitlePicker (customId timer_select_title, values <nonce>_<type>_<id> / <nonce>_keep via pendingSelections) and adds /movie's landslide rule. applyTimerTitle is shared with the select handler
  • src/handlers/selectHandler.js: timer_select_title handler. It refuses a pick if the timer was replaced since the menu opened (startTime check), and re-checks canSetTimerTitle
  • tests/timer-set-title.test.js: 7 end-to-end tests feeding the command's own option values into the real select handler
  • No command definition change, so restart only

2.64.0 - 2026-10-11 ​

Added ​

  • /image user: imagines a server member as an original character. For example, /image user:@Doug prompt:a Lego minifig version of them. The character is built from what the bot can see of the member, as text: their name and nickname, their profile accent color and role colors as signature colors, their top roles, and the movies and shows they've looked up or wanted on the watchlist, worked in as motifs and props
    • Their avatar is never used. It isn't downloaded or sent to the image generator, so nobody can turn someone else's photo into an image. Discord doesn't let bots read bios or Nitro profile themes, so those aren't used either
    • prompt is required with user, and user can't be combined with message, title1/title2 or matchup. The result is titled "🎨 Name, imagined" and uses the member's accent color

Developer ​

  • src/commands/image.js: new user option, collectUserProfile (forced user.fetch(true) for hexAccentColor, member roles, statsTracker searches filtered to that user's movie/tv lookups, watchlist wantedBy, each source failing soft) and exported buildUserPersonaPrompt. generateAndPostImage takes an optional embed color. Generations are recorded as user-image
  • Command definition changed: run deploy-commands
  • tests/image-command.test.js: 4 new tests. They cover the prompt contents, that other users' titles stay out, that the avatar is never read or fetched, degrading to name-only, the required prompt, and the one-mode rule

2.63.0 - 2026-10-11 ​

Added ​

  • /watchparty ready: check who's ready before you press play. It posts a card with βœ… Ready and ⏳ Need a minute buttons. Anyone can answer and switch their answer, and the card lists who's ready and who you're waiting on. It's only a count; you still start the timer yourself. message: adds a line to the card, and role: pings a role
    • If the channel has a watch party scheduled around now (running, or starting within about two hours), the card also compares the answers with who marked the Discord event Interested: how many of them are ready, who hasn't checked in yet, and how many ready people didn't mark Interested. Without an event, it's a plain tally
    • The host gets a ping when the last person who asked for a minute presses Ready. It's a new message, so it notifies
    • It stops taking answers and freezes with the final count when a timer starts in the channel, when the channel's event goes live, when someone posts a newer ready check there, or after three hours
  • /watchparty ready-close stops taking answers early. Only the person who posted the check, or a moderator, can use it

Developer ​

  • New src/utils/readyCheckManager.js: one check per channel, kept in ready_checks.json (READY_CHECKS_FILE) with synchronous load-modify-save and an atomic rename, so it survives a restart and concurrent clicks can't overwrite each other. Button customIds are ready_check_<hexId>_<ready|minute>
  • findEventForChannel takes startsWithinMs: a Scheduled event matches only if it starts within that window of now (Active events always match). Existing callers don't pass it. New fetchInterestedUserIds(event) pages fetchSubscribers over REST (no new intent) and returns null on error
  • startTimerCountdown calls closeForTimer after startTimer, without awaiting it, so the timer is never delayed. The button handler closes a check whose event has gone live since it was posted, using the same event fetch that refreshes the Interested list (at most once a minute)
  • /watchparty gained ready and ready-close. Command definition changed: run deploy-commands
  • tests/watchparty-ready.test.js: 14 tests through the fake Discord, which now supports getRole. They cover the tally, the Interested comparison, a next-week event, an unreadable list, the host ping, every way a check closes, and a restart

2.62.0 - 2026-10-10 ​

Added ​

  • Seeding that matters, March Madness style. Every part is optional, and the tournament creator chooses. /bracket-setup create has two new options, and the setup form and tournament files have matching settings:
    • seeding: Random (the default, as before), The order I add them, or the new By rating. By rating ranks the titles by their TMDB, RAWG, BoardGameGeek or Google Books rating and makes the best one seed 1. Titles with no rating go last, and /bracket status names them while you're still setting up
    • Seeds are shown in a seeded bracket: on the ballot, vote buttons, results, live standings and bracket image, for example "(1) Halloween vs (8) Hereditary". A worse seed winning gets 🚨 Upset! (8) knocks out (1) on the result post
    • ties: Runoff vote (the default, as before), or Higher seed wins, which sends a tied matchup straight to the better seed with no runoff and says so in the result. It needs a seeded bracket; with Random seeding, ties always get a runoff
  • Tournaments already running are unchanged: no seeds appear, and ties still get a runoff
  • New guide: Sports-style tournaments. It shows which settings run a tournament like NCAA March Madness, the College Football Playoff, the NFL, MLB, NBA or NHL playoffs, the FIFA World Cup or an FA Cup draw, and where each one differs. Twelve seeded titles give the College Football Playoff's layout exactly, and 48 titles in groups give the 2026 World Cup's. The quick guides, FAQ and /bracket help now cover seeding and ties too

Changed ​

  • Setting up a tournament has its own command: /bracket-setup. create, setup-link, manage-titles, resize, edit-name and announce moved there from /bracket. Everything you use while a tournament runs stays on /bracket: open, close, status, voting, view, export. Only admins and moderators see /bracket-setup. /bracket had nearly reached Discord's size limit for a command, and this makes room for more tournament features. If Discord says a command is outdated, reload Discord (Cmd/Ctrl+R)
  • /bracket help no longer mentions a /bracket image command, which never existed. Matchup images come from /image matchup:

Developer ​

  • bracketManager: SEEDING_MODES, TIE_RULES, getSeedingMode, getTieRule (always runoff for random seeding), titleRating, seedTitles. Seeded participants get seed: n when the bracket is generated. Winners advance as the same object, so the seed travels with them
  • closeKnockoutMatchup settles a tie by seed under higher-seed and records decidedBy: 'seed' | 'votes' | 'coin' in knockoutResults. It also sets matchup.decidedBy when the seed decided it
  • tournamentUI: displayTitle prefixes the seed; new seededTitle (seed and bare title, used where the bare title was shown before, so unseeded output is byte-identical), knockoutOutcome and upsetNote
  • Book entries store averageRating and board games bggRating. completeEntry now also fetches BGG details for a board game with no rating
  • Import/export: seeding accepts rating; new ties field
  • create gained seeding and ties, which took /bracket to 7841/8000 bytes. Split by phase into src/commands/bracket-setup.js (3586 bytes) and /bracket (4346). The handlers stayed in bracket.js: bracket-setup calls its execute(), so the commands.bracket toggle (shared by both commands) and the admin/mod gate are the same code. The new command sets setDefaultMemberPermissions(ModerateMembers), which matches that gate. SETUP_SUBCOMMANDS is exported, and /eggshen-ask knows both commands' admin subcommands. Command definitions changed: run deploy-commands
  • tests/bracket-command-references.test.js: every /bracket … and /bracket-setup … in src/ and public/ names a real subcommand (docs-commands.test.js already covers the docs). Size ceilings are now pinned per command. The simulator's sim.bracket() routes setup subcommands to /bracket-setup, and sim.command() refuses a subcommand the named command doesn't have, as Discord does
  • Tests: rating, tie, upset, random-tie and legacy-file scenarios in tournament-sim.test.js; rating, validation and round-trip tests in tournament-import.test.js

2.61.1 - 2026-10-10 ​

Changed ​

  • The "used a command" line is a clickable command now, and shows option names only. It reads "@Doug used /movie query": the command is Discord's blue command chip, which you can click to start typing that command yourself, followed by the options that were used. What was typed into them is no longer shown

Developer ​

  • formatCommandLine builds a </name sub:commandId> mention from interaction.commandId, then the option names in backticks. A picker opened before a restart loses the id and falls back to bold **/movie**
  • relabelOption removed, along with its calls in /recommend and /image, since values aren't shown. /recommend keeps the small genreNameFor helper split out of describeFilters
  • No command definition changed

2.61.0 - 2026-10-10 ​

Added ​

  • Public results show the command that produced them. A small grey line above the result says who ran what, options included, e.g. "@Doug used /movie query:the thing", so anyone watching can see how to do it themselves. This covers /movie, /tv, /episode, /episode-list, /game, /boardgame, /book, /image, /recommend, /soundtrack, /watched, /eggshen-ask, every "did you mean…?" picker selection, and the /random episode and /similar picker results. The mention never pings. Results kept private:true don't get the line. Options picked from a suggestion list show the name you picked (director:Ridley Scott, matchup:2B), not an internal ID

Developer ​

  • Why it was missing: these commands defer ephemeral and deliverResult() posts the public answer with channel.send, a plain message with no interaction attached, so Discord drew no "used /command" header. Commands that reply publicly through the interaction already had it and are unchanged
  • src/utils/commandEcho.js: rememberCommand (called in index.js before execute) records the command line by interaction id for 15 minutes. withCommandEcho stamps it onto a public response with allowedMentions: { parse: [] }. A picker selection finds its command through message.interactionMetadata.id. After a restart only the command name survives. The header is dropped rather than push content past 2000 characters
  • relabelOption(interaction, name, display) swaps an autocomplete id for a readable name; used by /recommend genre/director and /image matchup
  • tests/command-echo.test.js (9), including a real ambiguous /movie through its own picker into the real select handler
  • No command definition changed

2.60.0 - 2026-10-10 ​

Added ​

  • /survey voting-post brings a survey back down. Like /bracket voting-post, it posts the survey again at the bottom of the channel as a new message, with its vote buttons and the votes so far, so nobody has to scroll up to find it. The old card greys out its buttons and links to the new one, and the new card is the one that shows the final result when the survey closes. Leave out poll_id when only one survey is open. Anyone can use it; members once every 10 minutes per survey, the survey's creator and admins any time

Changed ​

  • The command docs said surveys are voted on with emoji reactions; they've used buttons for a while, and now say so

Developer ​

  • pollManager.movePollCard(guildId, pollId, channelId, messageId): synchronous load-modify-save pointing the poll at the new card. Needed because closePollAndAnnounce (used by /survey close and the auto-close scheduler) edits only poll.messageId; without it a repost kept live buttons after closing
  • pollManager.buildDisabledSurveyButtons(poll), shared by closing and the retired card
  • Older reaction-voted surveys can't be reposted (their votes are reactions on that one message) and say so
  • tests/survey-voting-post.test.js (10), end to end through the fake Discord: real create, real button clicks on each card, repost, close
  • Command definition changed: run deploy-commands

2.59.0 - 2026-10-10 ​

Added ​

  • /random movie and /random tv can pick from your streaming services. A new streaming option takes one or more services, separated by commas, and only picks something that's on at least one of them: /random movie genre:Horror streaming:Shudder, Tubi TV. Start typing to see the services available in the server's streaming region, and picking one adds it to the list. A service includes its Amazon, Apple TV and Roku channel versions, so Shudder also finds what's on Shudder through Prime Video, and Peacock or Paramount+ cover every plan. A name the bot doesn't recognize is named back to you rather than quietly left out

Developer ​

  • tmdbService: getWatchProviderList(type, region) (TMDB's provider list, cached a day per type and region, since autocomplete fires per keystroke), getStreamingServices (providers grouped by name with the channel and tier suffix stripped, so new Amazon channels group without a hand-kept map), resolveStreamingServices(type, region, text) (exact name, else a unique prefix; returns unknown for anything else)
  • discoverRandomMovie/discoverRandomTV take providerIds + region β†’ with_watch_providers (pipe = any) and watch_region. Measured: without watch_region TMDB ignores the provider filter entirely (Shudder: 20,001 results, the unfiltered count, vs 761 with US). with_watch_monetization_types is deliberately not sent: TMDB applies it to a title's offers on any service, not the requested ones
  • random.js gains autocomplete(); the option is on the movie and tv subcommands
  • tests/random-streaming.test.js (17), against a recorded subset of TMDB's real US provider list (tests/fixtures/tmdb-watch-providers-movie-us.json), including the suggestion fed back into the command
  • Command definition changed: run deploy-commands

2.58.2 - 2026-10-05 ​

Fixed ​

  • Titles were cut short in the live standings, and remakes looked alike. The live standings and /bracket status cut every title at 25 characters ("The Town That Dreaded ..."), with plenty of room to spare. Titles now show in full with their year, "The Town That Dreaded Sundown (1976)", so the original and the remake can't be confused. The voting buttons and your ballot's vote list show the year too. Only a very long title is shortened, and never its year

Developer ​

  • tournamentUI.displayTitle(entry, max = 80): title plus year (unless the title already ends with one), cut before the year only past max. Used by formatStandingsMatchup, formatKnockoutVotes and the ballot buttons (72, inside Discord's 80-character label)
  • Restart only

2.58.1 - 2026-10-05 ​

Fixed ​

  • The bracket image showed "TBD" for a winner until the whole round was over. A decided matchup's winner now appears in their next matchup straight away, in /bracket view and the bracket posted after each matchup. The bot still fills the real next round when the round finishes, as before
  • The bracket image never highlighted a winner. It compared the stored winner with "movie1"/"movie2", which never matched. Winners now get the blue tile, with dark text you can read on it and a tick drawn as a shape (the βœ“ character showed as an empty box)

Developer ​

  • src/utils/bracketProgress.js: placeWinners (the pairing rule: the winner of matchup i goes to matchup floor(i/2), top slot when i is even), now behind bracketManager.propagateWinners too; bracketWithWinnersSeated (a copy for drawing, filling only empty slots); winnerSide (matches the stored winning title to its side, by id and type, or title)
  • tests/bracket-progress.test.js; checked by drawing the live Slasher Showdown bracket
  • Restart only

2.58.0 - 2026-10-05 ​

Changed ​

  • /bracket status shows the vote bars. Each open matchup appears exactly as on the live standings card: coloured bars, counts, the πŸ”₯ leader and when it closes, instead of a "Leading:" line. Up to 12 open matchups are shown. See /bracket status
  • /bracket voting-post brings the live standings down too. In the knockout rounds, a fresh standings card is posted right under the reposted voting card, and votes update that one from then on. The old card stays, marked "No longer updating", so nobody reads its counts as current. The card only moves when someone runs the command, never on its own

Developer ​

  • tournamentUI.formatStandingsMatchup(matchup) is the one matchup block, used by buildPublicKnockoutLeaderboard and /bracket status, so they can't drift apart
  • moveLiveStandingsDown(channel, tournament, matchups, client) in buttonHandler.js posts the new card and points both the tournament file and the in-memory cache at it; with only the file updated, the next vote kept editing the old card until a restart
  • Simulator scenarios: status matches the live card block for block; the card moves and takes the next vote; and the same after a restart, when only the file remembers it
  • No command changes: restart only

2.57.0 - 2026-10-05 ​

Added ​

  • /bracket voting-post posts the voting card again, with its Start Voting button, for whatever is open right now: the open matchups (or groups) and when voting closes. In a long tournament the card from /bracket open scrolls far up the channel, and members were pinning it to find the button. Anyone can run it, once every 10 minutes per channel (admins and moderators any time); earlier cards stay, and their buttons keep working. See /bracket voting-post

Developer ​

  • handleVotingPost in bracket.js: a new public message (an edit notifies no one), button start_knockout_voting_<phase> or start_group_voting_<ids>, cooldown in memory per guild and channel (VOTING_POST_COOLDOWN_MS, resetVotingPostCooldowns for tests)
  • /bracket ends smaller (7,404 bytes, from 7,432) despite the new subcommand: wordy option descriptions were tightened, and the size ceiling in tests/command-size-limit.test.js came down to match
  • A simulator scenario posts it as a member, votes through its button, and checks the cooldown and the admin exception
  • New subcommand: needs deploy-commands

2.56.3 - 2026-10-04 ​

Changed ​

  • Vote bars are in colour. Live standings and tiebreaker votes draw their bars with coloured squares instead of plain β–ˆ and β–‘ characters, which Discord can't colour: 🟩 for the title ahead, 🟧 for the one behind, 🟨 for a tie, ⬜ for the rest. Each bar is 10 squares, so each square is 10%. See How members vote

Developer ​

  • tournamentUI.VOTE_BAR holds the squares. createVoteBar(votes, totalVotes, length = 10) colours a two-title bar by comparing it with the other title; the new createRankedBar(votes, allVotes) does the same for a tiebreaker's several options. Both tiebreaker builders (bracket.js, buttonHandler.js) use it
  • Tests cover the colours on the live standings and both tiebreaker paths, and that no β–ˆ/β–‘ is left on those cards
  • Restart only; no command changes

2.56.2 - 2026-10-03 ​

Fixed ​

  • Tournament cards showed a raw "Deadline: <t:…:f>". Discord doesn't turn timestamps into times in a card's footer, so seven voting cards (voting opened, region and matchup openings, group stage, knockout start) showed the code. The footer now reads "Voting closes β€’ Tomorrow at 8:04 PM", in each viewer's own time zone

Docs ​

  • The example Discord messages in the tournament guides are drawn in HTML, in Discord's own colours, so they follow the docs' light/dark switch. They replace screenshots that had drifted from what the bot sends (coloured vote bars, a removed "Total votes" line): the voting post, your voting dashboard, live standings, a matchup's results card, /bracket status, and the group-stage ballot
  • Selected group-stage buttons turn purple, not green

Developer ​

  • docs/.vitepress/theme: DiscordMessage.vue draws a message from an object (embeds, fields, buttons, ephemeral note); DiscordCard.vue names one from discordCards.js; discordMarkdown() in inline.js renders Discord markdown safely, with [ts:…] and [mention:…] stand-ins. Styles are --dm-* variables in custom.css
  • The tournament simulator now fails when any card the bot posts has a <t:…> timestamp in its title, author or footer, where Discord shows it raw
  • Restart only; no command changes

2.56.1 - 2026-10-03 ​

Changed ​

  • Your ballot's vote list reads by round. Your own voting ballot now groups your votes under Round 1, Round 2… and the Final. Where a round has more than one matchup per region, each region gets its own heading (Round 1 Β· Region 1) and its matchups go by letter (A, B…); later rounds keep the full label (1A). Only you see this list. See How members vote

Developer ​

  • tournamentUI.matchupRegion(position, round) returns { region, letter, perRegion }; matchupLabel is built from it, unchanged. formatKnockoutVotes numbers rounds from the bracket's first round (counting rounds a voter skipped), groups by region where perRegion > 1, and drops the label on the final
  • No command changes: restart only

2.56.0 - 2026-10-03 ​

Changed ​

  • The voting ballot shows every vote you've cast. Instead of a count ("πŸ“Š Total votes: 12"), it lists each knockout matchup you've voted in by its label, your pick ticked: 1A: βœ… Session 9 vs A Nightmare on Elm Street. Earlier rounds stay listed under their own heading as the tournament goes on, and the list updates as you vote or change a vote. See How members vote

Developer ​

  • tournamentUI.formatKnockoutVotes(tournament, userId) builds the list in bracket order, escapes markdown in titles, and keeps the latest rounds if it would pass 3,000 characters. Both ballot paths in buttonHandler.js use ballotVotesText. A simulator scenario votes, changes a vote and moves to the next round
  • No command changes: restart only

2.55.2 - 2026-10-03 ​

Fixed ​

  • Free services now show up in where-to-watch. /movie, /tv, /random and the search picker's details never listed free and ad-supported services (Tubi, Pluto TV, The Roku Channel…) that TMDB knows about. They now get their own Free: line, and a free service no longer also appears under Stream:
  • AMC+ is listed once. "AMC Plus Apple TV channel" showed up next to AMC+ (Session 9, for one) instead of being folded into it

Developer ​

  • buildStreamingText reads watchProviders.free and removes those names from the Stream line. normalizeProviders looks names up case-insensitively, since TMDB's casing varies between channels
  • tests/streaming-availability-embed.test.js checks the exact field text, using TMDB's real Session 9 response
  • No command changes: restart only

2.55.1 - 2026-10-03 ​

Security ​

  • The event request form's login can no longer be forged. The login cookie wasn't signed, so someone could write one claiming to be any member of the server, and a request's submitter was taken from what the form sent. The cookie is now signed by the bot, and a request is always made as whoever is actually logged in. Everyone signed in to the form will need to log in once more

Fixed ​

  • "Fargo" was read as the TV show. With two films and a show sharing a title, and no year given, the where-to-watch line and artwork suggestions picked the show. They now treat it as unclear (artwork offers all three; the where-to-watch line asks for the year), and a year still settles it

Developer ​

  • src/utils/sessionCookie.js: signSession/readSession, HMAC-SHA256 checked with timingSafeEqual, 24-hour expiry. Secret: SESSION_SECRET, else derived from DISCORD_CLIENT_SECRET, so existing installs need no new setting (see .env.example). POST /api/event-request takes the submitter from the session and returns 401 without one; submitterDiscordId/submitterUsername in the body are ignored
  • Tests sign cookies with tests/harness/sessionCookie.js (Jest) and tests/e2e/fixtures/session-cookie.js (Playwright); tests/session-cookie.test.js covers forged, tampered, expired and unsigned cookies and a spoofed submitter
  • resolveWatchTitle counts exact title matches across movies and TV together
  • No command or static form changes: restart only

2.55.0 - 2026-10-03 ​

Added ​

  • Artwork suggestions on the event request form. Type a title and the form suggests artwork for it from TMDB: wide backdrops first (they fit an event cover with little cropping), then a couple of posters. If several titles match, like the Fargo film and the show, each gets its own row with its year. Nothing is picked for you: click one to crop it, or use your own image. Picking artwork also tells the bot exactly which title you mean, so the moderators' where-to-watch line uses it instead of guessing. Trying different suggestions doesn't count against the upload limit. See Artwork Suggestions

Fixed ​

  • Submitting right after picking an image could hang. If the crop tool hadn't finished loading the image, the request never went through and no message appeared; it now waits for the image
  • A confidently matched title whose TMDB image list was empty showed no suggestions; it now falls back to the title's own backdrop and poster

Developer ​

  • GET /api/event-request/title-art (logged-in members only, 30 a minute): src/utils/titleArtwork.js returns the confident match from resolveWatchTitle, or the top three candidates, each with backdrops and posters from the new tmdbService.getTitleImages. The browser loads the chosen image straight from TMDB (which allows cross-origin reads) and uploads its original with the crop at submit
  • The form sends the picked title as tmdbTitle; chosenTitleFrom validates it and the where-to-watch lookup uses it instead of matching the typed title
  • uploadCurrentCrop waits for Cropper.js's ready. tests/e2e/artwork-suggestions.spec.js stubs the lookup and TMDB's images
  • Static form changes only: needs deploy-domain-copy --all, no deploy-commands

2.54.0 - 2026-10-03 ​

Added ​

  • Guidance on the event request form. Every form now shows helper text under its fields, an intro and a footer: login only confirms membership, check the Events tab first, add the title's year, say where to watch it, schedule within 2 weeks, you'll be the host. Each server's admins and moderators can rewrite any of it for their own site with /eggshen-config-events event-requests guidance, which opens a Discord form filled in with what's shown now. Empty a box to go back to the default, or type none to hide it. See Guidance on the Form
  • Where to watch, on every request. Each request's title is looked up for where it streams. Moderators see "πŸ“Ί Streaming on Shudder, AMC+ and Tubi (free)" on the request, and approving adds it to the event's description, with a link to every other place to watch it. Each server picks its own services and their order with /eggshen-config-events event-requests streaming (the default: Shudder, AMC+, Tubi, Plex, Roku, Prime Video, Hulu, Peacock and Hoopla), or switches it off. It never guesses: if the title doesn't match one movie or show exactly, nothing is added and moderators are told to add the year. See Where to Watch

Fixed ​

  • A request with a long description failed to submit. Descriptions over Discord's 1,024-character limit made the whole request fail; the form now allows 800 characters, and the moderators' card and the event both stay within Discord's limits
  • The description box no longer suggests "(Shudder, AMC+, etc.)" on every server's form
  • The docs' frequency list was missing Daily

Developer ​

  • src/utils/eventRequestGuidance.js: DEFAULT_GUIDANCE, stored per piece as own text, false (hidden) or nothing (default); returned by GET /api/guild-config/:guildId as guidance and rendered by public/app.js with textContent only. The pop-up handlers live in eggshen-config-events.js so server.js doesn't import config-saving code
  • src/utils/eventStreaming.js: per-server services with an alias table (AMC+ never matches AMC); looked up at submit (5 s limit) and stored as requestData.streaming, reused at approval unless the title was edited. src/utils/watchTitle.js (resolveWatchTitle) is the /timer title matcher, now shared, with a year that fits only one of a same-named movie and show settling it
  • TMDB's free/ads and Watchmode's free sources are kept in a new free list; flatrate is unchanged, so /movie and /tv look the same
  • EVENT_STREAMING_LOOKUP=off in tests/jest.setup.js and the e2e harness keeps tests off the real TMDB. The fake Discord emulates pop-ups (showModal, modalSubmit)
  • New subcommands and a rebuilt static form: needs deploy-commands and deploy-domain-copy --all

2.53.0 - 2026-10-02 ​

Added ​

  • The games keep score. /potion, /foodfight, /doom and the new /rescue are games now: every play has an outcome, and each outcome gives or takes points from you and the member you picked, shown under the line (🎯 Hit! Sam +3 (42 pts Β· πŸ”₯ 3 in a row) Β· Alex βˆ’1). Aiming at a role, @everyone or yourself scores only you. Good plays in a row build a streak. See Games & Scores
  • /scoreboard: your points, or anyone's, with their avatar, total and rank, and for each game the points, plays, outcomes, what they received and their best streak. This month, this year or all time. Posted in the channel, or private:true
  • /leaderboard: the server's top 10 players, each with a small avatar, plus who got splatted, doomed, poisoned, healed, fed and rescued the most, and the best streak. By game and by month, year or all time
  • /rescue: /doom's opposite. Save someone, a role or @everyone with a Final Girl, holy water, Ghostbusters, the boomstick, a salt circle, a silver bullet, sunrise, a getaway car, Van Helsing, the survival rules, garlic or the dog. Rescued, caught, or you sacrifice yourself for them. A rescue that works shields them from the next /doom for an hour: the doom is blocked and the shield used up. You can't rescue yourself. 300 lines
  • /foodfight feed: the kind half of a food fight. Tasty, gross, or spilled all over yourself. 181 lines
  • Many more, and funnier, lines: 300 food fight throws, 300 dooms and 339 potions, up from 72, 72 and 85. Still PG-13: some swearing, gross-out humor and cartoon gore, nothing hateful or sexual
  • Limits so the games can't be farmed: each game once every 20 seconds per person, 6 plays a minute across games, and the first 20 plays of each game a day score points (more still play, for fun)
  • /eggshen-config-games (Admin/Mod): change those limits and the shield's length, keep the games to certain channels or out of them (a thread counts as its channel), and reset scores for everyone, one game or one person. /eggshen-config commands toggle turns each game off, rescue included

Changed ​

  • Your server's own lines for /foodfight, /doom, /rescue and /potion take an outcome, which decides their points. Lines added before this count as a hit, a doom, a rescue or a potion that worked
  • /foodfight lines takes action:throw or action:feed
  • /potion responses picks the potion type from an autocomplete list
  • The bot's general per-command rate limit no longer applies to the games, which have their own; a per-command limit set for one of them still does

Developer ​

  • src/utils/gameScores.js: points table, per-server score file in guild_games/ (GUILD_GAMES_DIR) with synchronous atomic load-modify-save, month/year/all-time periods in UTC, daily cap, streaks, shields, in-memory play limits, channel rules, leaderboard and resets. gamePlay.js runs one play (beginPlay β†’ finishPlay); gameBoards.js holds what /scoreboard and /leaderboard share
  • Lines live in src/data/ as { text, outcome }, each set validated for placeholders, outcome mix, duplicates and a banned-terms list, and read through by hand
  • The leaderboard is one embed per player so each avatar can be an author icon (~24px); Discord's 10-embed limit makes it a top 10
  • tests/game-scores.test.js and tests/game-commands.test.js drive every row of the points table, caps, periods, shields, limits, channel rules and both boards through the real commands; each was mutation-checked (23 of 24 mutations caught; the miss is a cleanup sweep reads already ignore). The fake Discord gains guild.members.fetch and client.users.fetch
  • New commands and changed options: needs deploy-commands

2.52.0 - 2026-10-02 ​

Added ​

  • The bracket after each matchup. Whenever knockout matchups are decided, the bot posts the bracket image (as /bracket view draws it) under the results: Bracket So Far, then the Final Bracket with the champion. Matchups decided together, like a whole round at its deadline, get one image rather than one each. A tie posts its bracket once its tiebreaker decides it. Works however a matchup is decided: its deadline, /bracket close, /bracket close-matchup, the Close button, opening the next matchup, or a tiebreaker. See View the Bracket

Fixed ​

  • A final decided with /bracket close-matchup or a tiebreaker didn't add the champion to the watchlist (with auto-add-champion on). Only a final closed by its deadline did

Developer ​

  • tournamentScheduler.js: postBracketUpdate(guild, channelId) draws and posts the bracket; postBracketAfter calls it once per batch from the deadline loop and closeMatchupsNow, skipping ties. afterKnockoutDecided (bracket plus champion's watchlist entry) runs after /bracket close-matchup and both tiebreaker resolutions, which close matchups outside autoCloseMatchup
  • Simulator scenarios cover a round at its deadline (one image, after the results; the champion at the end), one matchup at a time, ties through both tiebreaker routes, and the final via close-matchup reaching the watchlist. No command definitions changed, so no deploy-commands

2.51.2 - 2026-10-02 ​

Fixed ​

  • The final of a tournament couldn't be opened by its label. /bracket open-matchup suggested "Finals", then refused it as Invalid label "FINALS", as did typing matchup:Finals the way the docs show. /bracket close-matchup matchup:Finals had the same problem. Both work now, in any case
  • A failed /bracket open-matchup could end the vote in progress. A mistyped label, or a matchup already open or already decided, opened nothing but still closed whatever was voting first. Matchups voting now are only closed when a new one really opens; the same goes for an old matchup button left in the channel
  • "Finals" typed during an earlier round is refused, rather than opening that round's first matchup

Developer ​

  • parseRegionalLabel (bracket.js) matched only 'Finals'/'finals', but every caller upper-cases the input. It now accepts any case, and only when the round is the final. handleOpenMatchup and handleOpenMatchupButton check that something will open before closeEarlierMatchups
  • Simulator scenarios: the final opened through /bracket open-matchup's own suggestion and closed by label; labels and a stale button that open nothing leave voting alone. npm run test:fuzz now fails if a value the bot suggested is refused as an invalid label (6 of 60 runs failed on the old code). No command definitions changed, so no deploy-commands

2.51.1 - 2026-10-02 ​

Fixed ​

  • /image matchup: couldn't find tournament matchups. Its own description said to type a label like "1A", but it only compared titles, so labels never matched. The list /image shows with no options numbered the matchups, but typing a number searched the titles, so "1" picked whichever title happened to contain a 1. Now:
    • Start typing in matchup: to pick from a list of the tournament's matchups, voting ones first
    • Labels work: /image matchup:1A, /image matchup:Finals, as in /bracket open-matchup
    • Titles still work, in either order or one on its own, matched exactly rather than as fragments
    • /image with no options lists this round's matchups by label, voting ones first, and leaves out decided ones

Developer ​

  • The "1A"/"Finals" label function moved from bracket.js to tournamentUI.js (matchupLabel), so /bracket and /image label matchups the same way. image.js gains autocomplete and findMatchup (suggestion id, label in this round first, exact titles)
  • tests/image-matchup.test.js drives /image through the fake Discord with fetch stubbed, reading the matchup chosen from the "Generating…" message. Command definition changed (matchup autocompletes): run deploy-commands

2.51.0 - 2026-10-02 ​

Added ​

  • /doom: deal someone a horror-movie fate. /doom fate target:@Friend trope:zombie picks from 12 tropes β€” zombie, monkey's paw, slasher, possession, cursed tape, haunted doll, vampire, werewolf, killer clown, Necronomicon, Ouija board, Bloody Mary β€” and the bot says how it went: the victim gets theirs, the plan backfires, or someone narrowly escapes. 72 built-in horror-comedy lines from Evil Dead to The Ring; leave out trope and fate decides. Like /foodfight, it can target a member, a whole role or @everyone (roles and @everyone are shown, never pinged), admins add their own lines with /doom lines, and it can be switched off with /eggshen-config commands toggle setting:doom. See Doom

Developer ​

  • src/commands/doom.js is built like foodfight.js (one TROPES list for every option, doomLines in the server config via getDoomLines, src/utils/socialTarget.js for targets) and is 4320 bytes. Its main subcommand is fate because Discord lists subcommands alphabetically β€” /foodfight's lines entries show before throw β€” and a test guards that fate sorts first. CLAUDE.md's note that subcommands render in registration order is corrected
  • tests/doom.test.js drives /doom and the toggle through the fake Discord and asserts the exact allowedMentions. New command: run deploy-commands

2.50.1 - 2026-10-02 ​

Changed ​

  • Tournament result cards show the winner's poster. Each matchup's result, the Matchup Complete! reply from /bracket close-matchup, and tiebreaker results now carry the winning title's poster as a small thumbnail in the corner, so you can tell who won at a glance. The summary for several matchups closed at once is unchanged, since it names more than one winner. Titles without a poster show none

Developer ​

  • setTitleThumbnail(embed, title) (tournamentUI.js) sets the thumbnail only for an http(s) posterUrl, since Discord rejects the whole message for anything else
  • tests/tournament-result-thumbnails.test.js runs tournaments through the simulator and checks each posted card's thumbnail is that winner's own poster. No command definitions changed, so no deploy-commands

2.50.0 - 2026-10-02 ​

Added ​

  • /foodfight: start a food fight, after the classic BBS door game. /foodfight throw target:@Friend food:pie lands a cream pie, spaghetti, pea soup or nine other foods on someone, and the bot says how it went: a hit, a miss, or a throw that backfires on you. 72 built-in lines from the Three Stooges to The Exorcist; leave out food for whatever's on the tray. Admins add their own lines with /foodfight lines. See Food Fight
  • Potions and food fights can target a whole role, or @everyone. Pick a member, a role or @everyone. A role or @everyone shows in the message without pinging anyone; a member is pinged as before
  • /potion and /foodfight can be switched off with /eggshen-config commands toggle (setting:potion, setting:foodfight)

Fixed ​

  • /potion responses list showed "[object Object]" for every built-in response instead of its text
  • /eggshen-config commands toggle said "undefined has been disabled" for games, board games, books and the watchlist. Every setting now names itself
  • /eggshen-help listed /potion under AI Image Generation; it's now under Fun & Games with /foodfight
  • The docs said potions had "78+" responses (there are 85) and that custom responses were set with /eggshen-config (it's /potion responses)

Developer ​

  • src/utils/socialTarget.js: resolveSocialTarget reads a Mentionable option through options.get() (.user/.member or .role; @everyone is the role whose id is the server's), and allowedMentionsFor sends { parse: [], users: [actor, member target] }, so roles and @everyone never ping. /potion give user: is now a Mentionable option (same name)
  • /foodfight keeps its food choices in one FOODS array shared by every subcommand (4127 bytes; /potion, which repeats its 13 choices five times, is at 5980). Server lines live in foodfightLines, read through getFoodfightLines. New commandPermissions keys potion and foodfight, absent = on
  • tests/foodfight.test.js drives /foodfight, /potion and the toggle through the fake Discord, whose options now support get()/getMentionable(); it asserts the exact allowedMentions. Command definitions changed: run deploy-commands

2.49.4 - 2026-10-01 ​

Documentation ​

  • The home page has 12 feature cards, which fill four rows of three; 10 left a short last row. New: Whole Seasons at a Glance (/episode-list) and Discover Something New (/random and /similar)
  • The home page said to run /similar "after searching for something". It takes its own title:; the example now shows that

2.49.3 - 2026-10-01 ​

Documentation ​

  • The home page covers everything the bot does now. New: the server watchlist and /recommend, /eggshen-ask, status quotes, the tournament quick guides and setup form, one-matchup-at-a-time tournaments with live standings, and announcing approved event requests to members. The feature cards were rewritten so the strongest lead, and each links to its guide
  • The home page's tournament example was wrong for its own size. It created 16 titles and said /bracket open "opens round one", but a 16-title first round is 8 matchups, more than a ballot holds, so the bot asks to open it in parts. The example now uses 8 titles and explains opening in parts
  • Feature card links showed as raw text, brackets and all, instead of as links. Cards now use proper links

2.49.2 - 2026-10-01 ​

Fixed ​

Tournaments now ride out a bot restart (a deploy, a crash, the server rebooting) mid-vote.

  • A tournament could be lost if the bot stopped while saving it. A save cut off partway left a damaged file that read as "no tournament", and the next /bracket create would have replaced it. Saves can no longer be left half-done, and the previous save is kept to recover from. One damaged file also made every scheduled check log an error
  • A tie whose tiebreaker vote never got posted β€” the bot stopped in between β€” couldn't be voted on, and was decided at random when it ran out. The bot now posts it, with its full voting time from then
  • "Voting closing soon" warnings were sent again after a restart
  • A restart started a second Live Standings card for matchups already being voted on
  • A voting deadline that passes while the bot is down closes as soon as it's back, results and all (now tested)

Developer ​

  • saveTournament writes <id>.json.tmp and renames it over the file, keeping the previous version as .bak; loadTournament recovers a file that won't parse from .bak and keeps the damaged one as .damaged-<time> (also when there's no backup). deleteTournament removes the .bak/.tmp too
  • Restart-surviving state lives in the tournament file, written by synchronous load-and-save helpers so they can't overwrite a vote saved in between: markWarningSent (warnedForDeadline on the group or matchup, so an extended deadline still warns), recordLiveStandingsCard/getLiveStandingsCard (liveStandings), restartTiebreakerClock. The scheduler posts an active tiebreaker with no messageId after a 2-minute grace (postUnpostedTiebreaker)
  • tests/tournament-sim-restarts.test.js restarts the bot at each of these moments, each seen failing first; the simulator's cleanup now removes every file of a scenario's server. CLAUDE.md records the rule. No command definitions changed, so no deploy-commands

2.49.1 - 2026-10-01 ​

Fixed ​

  • Live Standings stopped appearing under new matchups. In a round run one matchup at a time (/bracket open matchups:1 or /bracket open-matchup), votes on the new matchup updated the card posted for an earlier one, far up the channel. Edited messages don't notify or move, so voters couldn't see that their vote counted. Each new matchup now gets its own card, under it; earlier cards stay as those matchups' final tallies. See One Matchup at a Time

Changed ​

  • Your voting dashboard shows your avatar, not the bot's: your server avatar if you've set one, otherwise your account's. Only you see your dashboard, so everyone sees their own. /bracket my-votes shows it too
  • /stats type:My Stats names whose stats they are: "πŸ“Š Sam's Stats", with that person's avatar. It's posted in the channel, where "Your Stats" didn't say whose

Developer ​

  • The public knockout leaderboard is keyed by the set of open matchups, not the round (buttonHandler.js). Kept in memory, so after a restart the next vote posts a fresh card
  • src/utils/personCard.js: avatarOf (member's server avatar, else the account's) and nameOf. Simulator scenarios cover each voter's own avatar, my-votes, and a new standings card per matchup through both ways of opening one at a time; tests/stats-personal-card.test.js covers /stats. The fake Discord's members now have displayAvatarURL() and nicknames, as discord.js members do. No command definitions changed, so no deploy-commands

2.49.0 - 2026-10-01 ​

Fixed ​

Found by a new simulator that runs whole tournaments through the bot (see Developer).

  • A 2-title tournament could never finish. Its only matchup started as a "semifinal" with no final after it, so closing it never crowned a champion. Two titles are now simply a final
  • Ties closed with /bracket close never got a tiebreaker vote, so the matchup sat waiting with nothing to vote on, and a group-stage tie closed that way answered "An error occurred". A tie closed with the Close button crashed. Every way of closing now posts the tiebreaker vote and the results, exactly as the deadline does. /bracket close's tiebreaker-duration is now respected for knockout ties
  • The admin buttons from /bracket open-matchup and /bracket close-matchup (open a matchup, open a region, close a matchup) answered every press with "An error occurred", and a member pressing one got that instead of "admins only". They now answer properly. (This fix went out with 2.47.0's code; it's listed here)
  • Voting on a matchup posted from those buttons replaced the public post, for everyone, with that voter's private ballot. A vote there now opens the voter's own ballot and leaves the post alone
  • A groups tournament's champion never reached the watchlist with /eggshen-config-watch-party watchlist settings auto-add-champion:true. How a title qualified (group winner, runner-up, wildcard) overwrote what it was (a movie). Tournaments already under way are fixed too
  • Board games and books had no year, cover or authors on the setup form, and when /bracket manage-titles found exactly one match. Board game and book pick-lists showed no years either, so "Catan" listed dozens of editions with nothing to tell them apart
  • The bracket image labelled every title "WC" (wildcard) in a straight bracket. Only titles that qualified from groups carry a W, R or WC label now

Developer ​

  • Tournament simulator (tests/harness/): the real /bracket command, button and select handlers and scheduler, against a fake Discord as strict as discord.js (answer once, no followUp before an answer, no click left unanswered), with real TMDB/RAWG/BGG/Google Books responses recorded once (tests/fixtures/tournament-http.json, keys stripped; SIM_RECORD=1 to add titles). It checks after every action that stored votes agree, no ballot needs more than 5 matchups, no public message shows a private ballot, and nothing answers "An error occurred". tests/tournament-sim.test.js: 25 scenarios covering bracket and groups, 2–32 titles, commands and the setup form, every title type, every way of opening and closing, ties, deadlines, extensions, seeding and a bot restart. SIM_REPORT=<dir> writes what each scenario posted as HTML
  • npm run test:fuzz: seeded random tournaments (stale ballots, admin commands mid-vote, time passing, buttons pressed out of turn), every invariant checked after each action, then finished. Not part of npm test. 400 runs of 150 actions came back clean after these fixes
  • Manual closes go through tournamentScheduler.closeMatchupsNow, which takes { tiebreakerDurationMs }. Knockout entries keep their media type and record qualifiedAs; addChampionToWatchlist falls back to the tournament's type for older data. completeEntry (bracketTitles.js) fetches a board game's details for its image once one is settled on. getStartingRound(2) is 'finals'
  • The fakes in bracket-open-buttons, bracket-button-permissions and bracket-voting-ballot tests now read the admin's reply from editReply and mark ballots ephemeral. No command definitions changed, so no deploy-commands

2.48.0 - 2026-10-01 ​

Added ​

  • Moderators can crop an image from a URL. The crop page now has the request form's two ways in: upload a file, or paste an image URL and click Fetch & Crop. Either one works for adding an image to a request that has none, or for replacing one. See Cropping or Replacing the Image

Fixed ​

  • The πŸ–ΌοΈ Crop Image button stopped working 30 minutes after a request arrived. Its link was created once, when the request was submitted, expired after 30 minutes and was used up by one save, and nothing replaced it. Clicking Crop Image now gives the moderator a fresh private link every time. Requests already waiting keep their old link; new requests get the working button
  • A request whose image was a link opened the crop page saying it had no image. The linked image now loads into the cropper. If the link no longer works, the page says so
  • The Edit form's Image URL field now points to Crop Image for uploading or cropping, since an image set there is used without cropping

Developer ​

  • crop_event_<requestId> (buttonHandler.js) signs the crop token on click, for moderators only, replying ephemerally with a link button. The moderation message's button is a plain button and only appears when a bot URL and EVENT_CROP_LINK_SECRET are both set
  • GET /crop/:id/current-image falls back to fetching the request's imageUrl and marks it X-Image-Source: url, so the page sends those bytes as the new original on save. New POST /crop/:id/fetch-image-url, token-gated (verified, not consumed)
  • Tests: the crop button is clicked through the real handler two hours after submission and its token verified; route tests cover the link fallback and URL fetch, end to end through save. The crop page was also run in headless Chrome (link image, pasted URL, dead link). No command definitions changed, so no deploy-commands

2.47.0 - 2026-10-01 ​

Added ​

  • Announce approved events where members can see them. The approve/deny message goes to the moderation channel, which members usually can't see, so approved watch parties were never announced to them. /eggshen-config-events event-requests announcement-channel now posts a πŸ“… New watch party! message, either in the event's own channel (where the watch party takes place) or in a channel you pick. Discord shows the event card with its Interested button, and the submitter is credited without being pinged. Off by default. If the bot can't post there, the event is still created and the approving moderator is told why. See Event Requests

Changed ​

  • Approving a request now replies with just "βœ… Event created successfully!" above the event card. It no longer repeats the title, location, event type, event ID and URL that the card already shows
  • Text-channel events no longer repeat their location in the description. The event already shows the channel as its location. Voice events still name their text channel, which appears nowhere else

Developer ​

  • postPublicEventAnnouncement (eventRequestApproval.js) runs after every approval path (Approve buttons, the channel picker, saving an edit) and returns whether it posted, for the moderator's reply. New key eventRequests.announcementChannel: null = off, 'event' = the event's own channel, else a channel ID, read through getAnnouncementChannelSetting()
  • The approval reply and the announcement put the event link behind masked link text; Discord still unfurls the card from it
  • tests/eventPublicAnnouncement.test.js sets the option through the real config command, then approves through the real button handler. New subcommand, so deploy-commands is needed

2.46.1 - 2026-10-01 ​

Fixed ​

  • /eggshen-ask gave only one way to do things that have two. Asking how to set up a 16-team tournament listed the commands but never mentioned the setup form (/bracket setup-link), which is quicker for a list of titles. Answers now give the main way and mention the other in a line. The Tournament FAQ answer for 16 titles now describes both
  • /eggshen-ask answered questions nobody asked. "How do I open a matchup with only 2 titles?" got instructions for creating a two-title tournament. Every matchup already is one title against one title, so it now answers with /bracket open matchups:1 or /bracket open-matchup, without setup steps. The Tournament FAQ answers it too

Developer ​

  • answerFromDocs's prompt asks for an alternative way in one line when the excerpts show one, and only for the task asked; it no longer adds steps before the one asked about, and its glossary says every matchup is one title against one title. Checked against the live model: setup questions lead with /bracket setup-link, matchup questions get the matchup commands, single-command questions are unchanged
  • Keyword search treats 1–5 as their words and "only" as "just". fixSlashes also removes a space inside backticks before a command
  • eggshen-ask.test.js checks that the 16-title FAQ names both ways and that the two-title matchup question finds its answer. No command definitions changed, so no deploy-commands

2.46.0 - 2026-10-01 ​

Added ​

  • /eggshen-ask: ask the bot how to do something, in your own words. "How do I run a tournament one matchup at a time?" gets the exact commands to run, answered from the bot's own documentation (every command page, feature page and guide), with links to the pages it came from. Answers are private; add public:true to post one for the channel. If a command in the answer is for admins and moderators only, it says so. See Ask the Bot
  • Every command in an answer is checked against the bot's real commands before anyone sees it, down to the option names. An answer naming a command that doesn't exist is thrown away for the matching docs section as written
  • /eggshen-config-ai ai-ask turns AI-written answers on or off (on by default), separately from AI announcement text. With it off, or without an OpenAI key, /eggshen-ask shows the best-matching docs section instead
  • /eggshen-help now points to /eggshen-ask
  • A tournament FAQ answer for setting up 16 titles
  • /eggshen-config stats toggle can now switch off game, board game and book tracking (setting:trackGames, trackBoardGames, trackBooks), alongside movies, shows and episodes. /eggshen-config settings view shows them too

Fixed ​

  • The docs showed commands that don't exist, or with the wrong options. Getting Started's first examples used /movie title: (it's query:), and the same went for /tv, /game, /book, /episode and /episode-list. Other pages described /notifications …, /leaderboard, /watched list and /watched remove, /cooldown, /ban-status, /whitelist-mode and /timer schedule, none of which exist. Every page now shows real commands with their real options, and describes only what the bot does. The Notifications and Statistics pages were rewritten, and the API Reference for self-hosters now matches the real code: function names, settings structure, stored data and handler templates
  • Going over the auto-ban threshold said "Server moderators have been notified." Nothing notified them. The warning now says what happens: moderators can see who's over the limit with /eggshen-config-moderation moderation auto-ban-list. The docs no longer say auto-ban bans anyone either; it flags people for moderators
  • Turning stats tracking off didn't stop most commands recording. /eggshen-config stats toggle setting:enabled enabled:false was honored by /movie, /tv, /episode, /game, /boardgame and /book, but /random, /watched, /similar, /recommend, /watchlist, /soundtrack and timers kept counting. Now nothing is recorded while it's off
  • Book lookups were never counted in stats on servers set up before book tracking existed: their settings lacked the switch, and /book read a missing switch as off. A missing switch now counts as on, as it was meant to

Developer ​

  • src/utils/docsIndex.js splits docs/**/*.md into sections by heading (plus frontmatter howto steps and faq entries) and ranks them with BM25, synonyms and light stemming. With AI on, it blends in OpenAI embeddings (text-embedding-3-small, 256 dimensions; 65% semantic, 35% keyword), cached in docs_embeddings.json (gitignored, written atomically, re-embedding only changed sections). The changelog and self-hosting pages are left out
  • src/utils/docsAnswer.js builds a catalog of every command path and option from the live definitions, gives it to answerFromDocs (gpt-4o-mini) with the top 8 sections, and validates the answer: strictly in code spans and blocks, leniently in prose, ignoring URLs
  • The checker also catches option values the definitions pin down (choices, number ranges, booleans), reads only lowercase name: as an option (so label:The Lord of the Rings: The Fellowship is one value), keeps balanced quotes (time:"8:00 PM EST"), and corrects \bracket to /bracket. The model sees full option details for the commands its excerpts show
  • trackSearch checks the stats switches itself, so a new caller can't forget to; the per-command checks (which read a missing switch as off) are gone (tests/stats-tracking-switch.test.js)
  • tests/docs-commands.test.js runs that checker over every docs page, so the docs can't show a command or option the bot doesn't have. Its allow-list holds only URL paths and syntax patterns
  • New dependency: yaml. Command definitions changed (new command, eggshen-config-ai group): run deploy-commands. Suite: 116 files, 1597 tests

2.45.0 - 2026-10-01 ​

Added ​

  • Tournament Quick Guides on eggshenbot.com: movie night bracket, one matchup at a time, 32-title bracket, groups tournament, set up from a spreadsheet, seeded bracket and a tournament FAQ. Each starts with a short "Quick steps" box, then tips, then the details
  • /bracket open matchups:<1-5> opens just the next N matchups of a knockout round, in bracket order. matchups:1 runs a tournament one matchup at a time: run it again to close the current matchup and open the next, and at the end of a round it moves on to the next. From setup it builds the bracket first. This was the only way to pace a small bracket (8 titles or fewer), whose whole round /bracket open would otherwise open at once
  • Search engines and AI assistants can read the docs properly. Every page now carries schema.org structured data: the site, its author, the bot itself, the page and its breadcrumbs. The guides add step-by-step HowTo data and the FAQ adds FAQPage, drawn from the same text readers see

Fixed ​

  • Every docs page told search engines it was the home page. One site-wide canonical tag, along with the social-preview URL, pointed every page at https://eggshenbot.com/. Search engines could treat the whole site as duplicates of the home page and leave the rest out of results. Each page now names itself
  • The old structured data included a self-made 5-star rating, which Google's guidelines treat as spam and can penalise. It's gone, and the version number it reported (stuck at 1.0.0) is now the real one
  • Fifteen docs pages had no description, so search results and link previews fell back to a generic one. Each has its own now
  • A finished group stage with 6 or more groups left voters unable to vote. /bracket open opened the knockout's whole first round, 8 or 16 matchups, and a ballot holds 5, so everyone got "Too many matchups open". It now builds the knockout and asks you to open it in parts (matchups: or region:); /bracket open matchups:4 builds and opens the first four in one step

Developer ​

  • docs/.vitepress/seo.js builds each page's canonical URL, social tags and JSON-LD @graph through transformHead. <QuickSteps /> and <FaqList /> (in docs/.vitepress/theme/) display a page's howto and faq frontmatter, the same data the JSON-LD uses, so markup can't describe text the page doesn't show
  • npm run docs:check (scripts/check-structured-data.js) runs in the docs deploy and fails it if a page loses its own canonical URL, its social tags or its JSON-LD, if any rating reappears, or if a HowTo step or FAQ answer isn't in the page's visible text. Mutation-checked against all three
  • lastUpdated: true: pages show real "Updated" dates and carry dateModified
  • /bracket is 7432 of 8000 bytes; command-size-limit.test.js's ceiling was raised deliberately, with a note to split the command next time. Command definition changed: run deploy-commands. Suite: 112 files, 1560 tests

2.44.0 - 2026-10-01 ​

Added ​

  • Pick matchups from a list. /bracket open-matchup's matchup now suggests the current round's matchups that haven't been voted on yet, with both titles (2A Β· Halloween vs Scream); /bracket close-matchup suggests the ones voting now. Type a label or part of a title to narrow it, and after a comma the list suggests the next one
  • A live countdown on every ballot and in the live standings: "closes in 2 hours", kept current by Discord. /bracket status still lists every open matchup's time left, for anyone

Changed ​

  • Opening the next matchup closes earlier ones. Any matchup in the round still voting, other than the ones being opened, closes first, exactly as its deadline would: winner announced, tie sent to a tiebreaker vote. The reply lists what closed. Before, earlier matchups stayed open until their own deadline, so in a round run one matchup at a time people could keep changing votes on matchups that had moved on. To keep several voting together, open them together

Fixed ​

  • Knockout ties got stuck forever. Closing a tied matchup created its tiebreaker and then, in the same step, saved over it, so the matchup sat in "tiebreaker" with nothing to vote on and the round could never finish. Tiebreakers are kept now, and the scheduler repairs any matchup already stuck that way by giving it a new tiebreaker vote
  • A tie closed by the deadline posted no tiebreaker vote. Only group-stage ties got one. Knockout ties now post theirs in the matchup's channel
  • Deadline results and warnings weren't posted for matchups opened by command. /bracket open and /bracket open-matchup never recorded which channel voting was in, so when a deadline passed the matchup closed silently: no result, no one-hour warning. Group stages opened with /bracket open had the same problem. The channel is recorded now

Developer ​

  • bracketManager.openKnockoutMatchups, openKnockoutRound and openGroupVoting take the channel and record it (without overwriting one already set)
  • tournamentScheduler.closeMatchupsNow(guild, ids) closes matchups through the deadline path (autoCloseMatchup, which now returns a summary and posts knockout tiebreakers), used by every open path. repairOrphanedKnockoutTiebreakers runs each pass; checkVotingDeadlines(client) is exported so a test can run a real pass. bracketManager.recreateKnockoutTiebreaker creates and links in one save
  • /bracket gained autocomplete on two options. Command definition changed: run deploy-commands. /bracket is 7272 of 8000 bytes
  • New tests/bracket-matchup-flow.test.js drives the round with a fake channel to see what's posted; every fix was mutation-checked. Suite: 112 files, 1555 tests

2.43.1 - 2026-09-30 ​

Fixed ​

  • Members can vote again. Since a permissions fix on 2026-09-16, the "Start Voting" buttons that open your ballot were limited to admins and moderators, so everyone else was told "Only administrators and moderators can start knockout voting." Those buttons only show you your own ballot, so they're open to everyone again; the buttons that manage a tournament stay admin/mod-only
  • The group stage's "Start Voting" button from /bracket open did nothing. It was posted under a name the bot doesn't listen for, so Discord showed "This interaction failed." It now opens your ballot
  • Live standings now show both titles' votes. Each matchup showed a vote bar for its first title only; the second title showed its name and a πŸ”₯ or 🀝, never a count. So a matchup voted 0–2 read as "0 votes", and votes for the second title looked lost. They were always counted; now both bars are shown, with "vs" between them
  • Ballots read as head-to-head matchups. Each matchup was a row of two buttons, so four open matchups looked like four titles facing four others. Each row now has a grey "vs" between its two titles: [1A Β· Terrifier 2] (vs) [1A Β· I Know What You Did…]
  • /bracket my-votes showed literal \n throughout, and its round names weren't capitalized. Two event-request channel confirmations had the same \n problem

Developer ​

  • One ballot builder, buildKnockoutBallotRows, for both opening and redrawing the ballot (they were two copies). buildPublicKnockoutLeaderboard is exported for tests
  • New tests/bracket-voting-ballot.test.js drives voting as a regular member: the real /bracket open posts the button, and its own customId goes to the real handler. bracket-button-permissions.test.js had encoded the bug (it expected members to be refused from Start Voting); it now asserts the opposite. Each fix was mutation-checked. Suite: 111 files, 1541 tests

2.43.0 - 2026-09-30 ​

Added ​

  • Each server can have its own bot address. /eggshen-config-website bot-url sets the address used in links the bot posts in that server: the tournament setup form, the moderator crop page and the quotes admin page. Servers without one keep using the bot's PUBLIC_BOT_URL. One bot can now serve a test server and a live server on different domains, and each server's links go to its own site, so testing takes the same path the live server does. When you set it, the bot checks that the address reaches this bot and that the setup form's page is forwarded, and warns you if either isn't. See Bot URL

Fixed ​

  • Starting the knockout too early named a command that doesn't exist. With groups still open, the error said to run /bracket close-group. It now points to /bracket close or /bracket close-groups
  • Timers could be lost on a restart. Every timer change saves the list of running timers to disk, but saves didn't wait for each other. Starting a timer and pausing it a moment later wrote the same file twice at once, which could leave it half-written, and then no timers came back after the next restart. Saves now happen one at a time, and each writes a complete file before replacing the old one
  • /bracket resize now updates the tournament's capacity. A groups tournament resized from its starting size kept the old maximum (a 5-group tournament still said 36 titles)
  • Self-hosting: the tournament setup form needs two paths forwarded by your reverse proxy, the same way /crop/ already is. The setup guide now shows the nginx blocks. Without them, the link opens your website's own page instead of the form

Developer ​

  • getPublicBotUrl(guildConfig) in guildConfig.js builds every link the bot posts: the server's website.botUrl, else PUBLIC_BOT_URL. Kept apart from website.url, the event form's host, which for self-hosters can be a static site. Command definition changed (/eggshen-config-website bot-url): run deploy-commands
  • Test runs no longer hang or leak. Four modules started a cleanup setInterval as soon as they were imported: logger, aiImageTracker, rateLimiter and buttonHandler. The event-request route also set a 7-day setTimeout per submission, and clearAllTimers emptied its map without cancelling pending auto-stops. So a test file importing any of them never exited on its own, and every full run printed "A worker process has failed to exit gracefully". The housekeeping timers are now unref()'d, which doesn't matter to the bot since its Discord connection keeps it running; clearAllTimers cancels what it drops; and every test file exits alone. The warning is gone
  • Found the intermittent test failures. For weeks, about one full run in ten failed somewhere, in a different test each time: a 400 or 404 a route can't produce, a reset connection, or a 15-second hang. supertest starts each test app with listen(0), which binds every address, then connects to 127.0.0.1:<port>. On macOS that bind succeeds even when another program already holds the port on 127.0.0.1, so the request went to that program instead. Editors, password managers and the like keep dozens of those ports open. tests/jest.setup.js now makes supertest listen on 127.0.0.1 itself, and tests/supertest-loopback.test.js fails if that patch ever stops applying. Measured: 60 of 60 full runs pass, up from roughly 1 failure in 10
  • The saves race above also failed timer-pause-resume now and then; back-to-back changes are all on disk before a reload reads them now covers it
  • The backlog said image-url-crop.spec.js depended on the network. It doesn't: it serves its image from a local server. Its one failure was a 5-second wait under load

2.42.0 - 2026-09-30 ​

Added ​

  • Set up a whole tournament from a file. /bracket setup-link gives an admin or mod a private link, good for an hour, to a web form. Upload a CSV or JSON file, or fill it in, and the form looks up every title the same way /bracket manage-titles does. Each match shows its poster; titles with several matches (remakes, sequels) get a menu to pick from, and anything not found can be fixed on the page. Nothing is saved until you press Create. The form stays editable until voting opens, and saving again replaces the lineup, after listing what will be removed
  • Templates to start from: a straight-bracket CSV, a groups CSV and a JSON file with every setting, downloadable from the new Setup Form & Templates page
  • Ordered seeding. A straight bracket can now be seeded from your list instead of shuffled: the first title is seed 1, round one pairs best with worst, seeds 1 and 2 can only meet in the final, and byes go to the top seeds. Random stays the default
  • Saved defaults. The form can set a tournament's voting time and tiebreaker time, used whenever duration or tiebreaker-duration is left out, and an announcement message and banner that /bracket announce uses when you don't type one

Changed ​

  • /bracket export format:json is now a backup you can re-import. It produces the setup form's format: the lineup and settings, with every title's id so nothing needs searching again. It used to dump the stored tournament whole, which couldn't be imported and included every voter's Discord ID in a public channel. The Markdown export is unchanged
  • /bracket advance-knockout is gone. /bracket open does the same job: once every group is closed, it starts the knockout
  • Rewrote the tournament docs. The setup, knockout and tips pages described commands that no longer exist (add-title, open-quarters, open-region and others) and regions that don't match the bot. They now match what /bracket actually does

Fixed ​

  • Opening knockout matchups could undo results and wipe live votes. /bracket open, open-matchup (by label or region) and the region and matchup buttons each had their own way of opening matchups, and most reset whatever they were given. A matchup already voting lost its votes. A decided one lost its result, while its winner stayed in the next round, so the bracket no longer agreed with itself. All five now share one rule: only matchups nobody has voted on are opened, and the reply lists any that were already open or decided
  • Tiebreaker times are 5 minutes to 7 days, as the messages always said. close-groups and close-matchup actually allowed up to 30 days, and /bracket close checked no limit at all
  • Region announcements showed a literal \n instead of line breaks
  • "Left Side" / "Right Side" no longer appears in matchup and region messages. The bracket has four regions, which the labels (1A, 2B…) already show
  • /bracket announce in a straight bracket showed "(null groups)" and "0 titles". It now shows the format and the real title count

Developer ​

  • New src/utils/tournamentImport.js: parsing, rules, matching (through bracketTitles.js), saving and export for the format. Everything is checked before anything is written, and a failed save puts back what was there. parseDuration/isValidDuration moved here from bracket.js
  • New src/api/tournamentSetupRoutes.js, registered from server.js. The guild always comes from the link's token; the page sends only ids, and entries come from the bot's own lookups; saving re-checks the person is still an admin or mod
  • New src/utils/tournamentSetupLinkToken.js: HMAC-signed and verified on every request, not single-use, since setting up is a session. Uses TOURNAMENT_SETUP_LINK_SECRET when set, otherwise a key derived from DISCORD_TOKEN, so self-hosted bots need no new configuration
  • bracketManager: standardSeedOrder, seeded buildBracketTree, and deleteTournament (rollback only)
  • Command definition changed (setup-link added, advance-knockout removed): run deploy-commands. /bracket is 7269 of 8000 bytes
  • bracketManager.openKnockoutMatchups is now the only way matchups open. The unused openKnockoutMatchup is gone, and openKnockoutRound goes through the new function. isValidTiebreakerDuration sits beside isValidDuration
  • The e2e harness sets a test EVENT_CROP_LINK_SECRET, so event-image-crop.spec.js no longer fails on machines without one
  • New tests: tests/tournament-import.test.js (parses the shipped templates), tests/tournament-setup-routes.test.js, tests/bracket-open-buttons.test.js, more in tests/bracket-mode-setup.test.js, and a Playwright spec, tests/e2e/tournament-setup.spec.js. The browser test caught a real bug: editing a title and pressing Find matches did nothing, because the table re-rendered on blur and moved the button out from under the click. Seventeen mutations, one per safeguard, each fail their test. Suite: 108 files, 1523 tests; Playwright: 25

2.41.2 - 2026-09-30 ​

Fixed ​

  • Adding a title without choosing a type no longer fails silently. type is optional on /bracket manage-titles because removing titles doesn't need it, but leaving it out when adding crashed the command before it could reply, so nothing happened at all. This showed up in the server's error log three times. After the first title, the tournament's type is now used automatically; before it, the bot asks you to choose one
  • /bracket open moves the group stage forward instead of reopening finished groups. It opened every group that wasn't currently voting, which included groups already closed with results. So once the group stage was over, "open the next round" reopened finished voting instead of starting the knockout. It now opens only groups that haven't voted yet. When every group is closed, it starts the knockout and opens its first round. If groups are still voting or waiting on a tiebreaker, it names them and changes nothing

Developer ​

  • The knockout start now lives in startKnockout, shared by advance-knockout and /bracket open, so the two can't drift apart
  • Five more tests in tests/bracket-mode-setup.test.js. The three for /bracket open failed against the old code before the fix. Each fix was also checked by removing it and watching its test fail. No command definition changed. Suite: 105 files, 1459 tests

2.41.1 - 2026-09-29 ​

Fixed ​

  • Picking a title from the search menu works in a straight bracket. When adding a title matched more than one result, choosing the right one from the menu always failed with "Invalid group", because the menu handler only knew how to add to groups. In a tournament of 2–32 titles, which has no groups, that meant any title with a remake, a sequel or a common name could not be added at all. Only titles with exactly one match got in
  • You can remove titles from a straight bracket. /bracket manage-titles action:"Remove Title" only looked inside groups, so in a straight bracket every title was stuck once added. Give it the title's number from /bracket list-groups; there's no group to choose
  • /bracket list-groups and /bracket status show a straight bracket's titles. Both only counted groups, so during setup they reported an empty tournament however many titles had been added. list-groups now shows one numbered list, and those numbers are what removal takes
  • /bracket open starts the tournament. Every "title added" reply says to run it, but it refused anything still in setup. For a straight bracket it now builds the bracket and opens the first round. In groups mode it opens every group, once each one has its 4 titles; if any are short, it says which ones instead of opening a lopsided vote
  • A straight bracket keeps to one type. The first title is supposed to fix a tournament as movies, TV, games, board games or books, but in a straight bracket that choice was never saved. You could mix movies with video games, and /bracket announce showed the type as "Not set yet"

Developer ​

  • Title lookup for tournaments now lives in one place, src/utils/bracketTitles.js: searching, building an entry from a search result, and building one from a picked id. The command and the menu handler both use it. The menu used to have its own copy, which is how it came to call the groups-only addGroupTitle. The setup form planned next will use the same module
  • New bracketManager.removeTitle, which works in either mode like addTitle does. buildTitleAddedEmbed is shared too, so the menu no longer says "Added to Group A" in a straight bracket
  • The menu handler now keeps the whole id after the type. Google Books ids can contain _, and splitting on it cut them short
  • manage-titles position now accepts up to 32 (was 4); groups mode still checks for 1–4 itself. This changes the command definition, so run deploy-commands. /bracket is now 7385 of 8000 bytes
  • New tests/bracket-mode-setup.test.js (11 tests). The picker test has the real command build the menu, then feeds that menu's own value to the real handler. Each fix was checked by breaking it and watching its test fail. Suite: 105 files, 1454 tests

2.41.0 - 2026-09-26 ​

Added ​

  • /announce now posts the announcement for you, instead of handing back text to copy and paste. It covers both moments a watch party needs announcing: /announce party for the advance notice an hour or more ahead, and /announce starting for the nudge a few minutes before the timer starts. Both take an optional channel (defaulting to wherever you ran the command) and an optional role to ping β€” the ping goes in the message itself, because a mention inside an embed never notifies anyone
  • Your own words come first. Supply message and it posts exactly as written, with no AI involved at all. Leave it out and, if your server has AI text enabled, the bot writes it from the title's real plot and streaming availability β€” otherwise it uses a plain template. The private confirmation tells you which happened, so an announcement never reads generically without explanation. /announce starting never uses AI: a timing claim shouldn't be paraphrased
  • AI-written announcement text can now be turned off per server, with /eggshen-config-ai ai-text feature-toggle. Separate from the AI image switch, since text costs a fraction of an image β€” a server can have one without the other. /eggshen-config-ai ai-text view reports both the server's setting and whether the bot has an API key at all, because both are needed and only one of them is a server admin's to change

Changed ​

  • The "Now Playing" card is about half as tall. It was using the portrait poster at full card width. Discord gives no control over how large it renders an embed image, so the lever is the artwork's shape: it now uses the title's landscape backdrop, which keeps the art as the feature while taking far less of the channel. Titles without a backdrop still show their poster rather than losing the art. The countdown's small corner thumbnail keeps the poster, where it reads better

Developer ​

  • New tests/ai-text-toggle.test.js; tests/announce-command.test.js rewritten for posting, with the text precedence pinned in both directions. Suite: 104 files, 1443 tests
  • getAiTextEnabled() defaults to true and treats only an explicit false as off. There are no config migrations here, so a guild configured before the key existed simply lacks it β€” defaulting to false would have silently switched the feature off for servers already using it

2.40.1 - 2026-09-25 ​

Fixed ​

  • /random game no longer blames your filters when the games database is down. Both the "nothing matched your filters" case and a genuine API failure (an expired key, a timeout, an upstream error) arrive the same way, and the command reported both as "No games found matching your filters" β€” so people kept adjusting filters that were never the problem, and nothing was written to the log to explain it. The two are now told apart, and the underlying error is always logged. /random boardgame gets the same logging; its wording now covers both causes, because BoardGameGeek's own error handling discards the detail needed to distinguish them
  • A very long tournament name could silently stop group voting from closing. The name goes into embed titles, which Discord caps at 256 characters; past that the embed fails to build. In the background auto-close path that failure was swallowed, so voting simply stayed open with only a log line to show for it. /bracket create and /bracket edit-name now cap the name, and any over-long name already stored is trimmed when it is next saved
  • A long note no longer breaks /watchlist list entirely. The whole listing is one embed description, and one long enough note could push it past Discord's limit β€” which failed the entire list, not just that entry. Notes are now capped when added, older ones are trimmed on display, and the listing shows as many whole entries as fit and says how many of the total that was
  • An event request could lose its cover image. When renaming an upload to its real request ID, the image file was moved on disk before the record of it was written, and a failure partway through left the record pointing at a file that no longer existed β€” the approval embed showed no image, and the cleanup sweep could never reclaim the orphaned file. Whatever was moved is now always recorded
  • /quote's title suggestions could stop working. Discord rejects an entire autocomplete response if any suggestion exceeds 100 characters, so a single long title added through the web admin page meant no suggestions at all. Titles are now clamped for display

Changed ​

  • Search statistics no longer grow forever. Every search appended a row to the server's stats file with nothing ever removing it, and each new search rewrote the whole file β€” so commands got gradually slower the longer a server used the bot. Individual rows are now kept for 35 days, which is longer than the furthest-back view (/stats month) can reach, so every figure /stats reports is unchanged. All-time totals are counted separately and were never affected

Developer ​

  • statsTracker and watchHistoryManager now honour GUILD_STATS_DIR and GUILD_WATCH_HISTORY_DIR. They were the last two stores without a test override, so seven suites wrote into the live guild_stats/ and guild_watch_history/ directories, and tests/test-isolation.test.js could not guard variables that did not exist. Both are now in the guard list
  • New tests/statsTracker-pruning.test.js and tests/option-length-caps.test.js; the retention window is pinned against every /stats filter, and the /watchlist list budget is driven end to end through the real command. Every fix here was verified by breaking it and confirming the new test fails. Suite: 103 files, 1413 tests
  • Measured a pre-existing test flake at roughly 1 failure in 10 full runs on an unmodified tree, hitting a different test each time (eventCropRoute with read ECONNRESET, timer-pause-resume, and two quotesAdminRoutes cases). Jest sets no maxWorkers and every run reports a worker failing to exit, so the cause looks like worker contention and leaked handles rather than any one suite. Recorded rather than fixed here; note that at this rate a single clean run proves nothing

2.40.0 - 2026-09-22 ​

Changed ​

  • Multi-episode watch parties now allow more time for ads. The duration was the summed episode runtimes plus a flat 10 minutes, but TMDB reports ad-free runtimes β€” a 25-minute episode on Tubi or Pluto really runs 33-35. Across four episodes that left the timer roughly 20 minutes short, which is exactly when it dies mid-party. The buffer now scales with the episode count (5 minutes each by default, never less than 10 total), so a four-episode Tales From the Crypt party gets 120 minutes instead of 110. Tunable per server with /eggshen-config-watch-party watch-party episode-buffer β€” 0 for a clean source, 8-10 for an ad-supported one
  • /timer pause and /timer resume now appear before /timer stop in the subcommand list, and their descriptions say what they're for. People reach for stop out of habit and lose the running total for a multi-episode party; Discord renders subcommands in registration order and offers no way to reorder them per invocation, so ordering is the only lever available

Developer ​

  • New CLAUDE.md at the repository root, loaded automatically at the start of a session so the repo's conventions apply without anyone having to remember them: the four-step release checklist (a step that was skipped for three versions running), the testing rules below, the Discord limits that have actually caused bugs here, and the TMDB behavior that has caught us out. .github/WORKFLOW.md had pointed at a /memories/repo/ directory that does not exist; it now points at CLAUDE.md
  • New tests/select-handler-contract.test.js (44 tests) holds every select handler to one contract β€” registered in the allowlist, and graceful on malformed input β€” rather than testing only the two that broke. Five handlers had no coverage at all before this
  • New tests/pendingSelections.test.js and the per-episode buffer cases in tests/timer-episode-range.test.js. Suite: 101 files, 1382 tests

2.39.1 - 2026-09-22 ​

Developer ​

  • /watchparty remind and /timer remind now share one implementation. They are the same feature under two names, and each carried its own byte-for-byte copy of the event lookup, the TMDB search and the announcement builder β€” so a fix landing in one silently skipped the other. That is precisely how /watchparty remind missed the year-suffix handling added in 2.35.0 and kept finding nothing for an event named "The Covenant (2006)"
  • The shared parts moved to src/utils/scheduledEventLookup.js (findEventForChannel, searchEventTitle, formatRuntime), and the announcement itself is now runRemind() in timer.js, which /watchparty remind calls. Net 392 lines removed, with both commands' Discord schemas byte-identical to before β€” nothing changes for anyone using them
  • New tests/watchparty-remind-alias.test.js asserts both entry points search without the year and produce the same announcement, so they cannot drift apart again. Suite: 100 files, 1333 tests

2.39.0 - 2026-09-22 ​

Fixed ​

  • /watched add failed every single time the title was ambiguous. The command packed its context (who asked, any note, the privacy flag) into the select-menu option value as base64 and truncated it to 50 characters to fit β€” but the smallest realistic payload is 108 characters, since a Discord user ID alone is 18 digits. The truncated base64 never parsed, so choosing a result produced "An error occurred while adding to watch history" and nothing was written. The single-result path worked, which is why it survived: anyone testing with an unambiguous title never reached it. Ambiguous titles are exactly the watch-party case β€” Fargo, Watchmen, The Covenant
  • /watchlist add lost the entry whenever the note was longer than about 25 characters. Same cause, narrower trigger: the note pushed the option value past Discord's 100-character cap and it was truncated mid-payload. The note option had no length limit, so an ordinary sentence was enough
  • Time spent paused was counted as watch time. /timer stop computed elapsed as now - startTime and ignored pauses entirely, so a watch party with a 40-minute dinner break was logged as 40 minutes longer than it was β€” permanently, in watch history. docs/commands/watch-party.md has always promised the opposite, and /timer pause is what we actively recommend during breaks. It also made /timer adjust reject valid durations after a long pause, since its "already run for N minutes" guard used the same inflated number
  • /timer remind and /watchparty remind found nothing for an event named with a year. Both searched TMDB with the raw event name, so "The Covenant (2006)" returned zero results and the announcement silently lost its poster, runtime and overview β€” the same bug /timer start had fixed in 2.35.0, in two paths that never got the fix

Developer ​

  • New src/utils/pendingSelections.js holds selection context keyed by a short nonce, with a 15-minute expiry matched to Discord's own component-token lifetime. Embedding the payload in the customId was never viable β€” Discord caps an option value at 100 characters. An expired selection now says so rather than failing silently
  • All six open-coded elapsed-time calculations in timerManager.js now route through one elapsedMsOf() helper. They had drifted: pauseTimer and getTimerStatus froze correctly at pausedAt, while stopTimer and adjustTimerDuration did not
  • tests/timer-pause-resume.test.js had a test named "reports the frozen elapsed time" that set up the exact failing scenario, commented that paused time "must not affect the report", and then asserted only that a reply was sent β€” so it passed throughout. It now checks the number
  • Neither select handler (select_watched, select_watchlist_add) had any test coverage, which is how a 100% failure rate shipped. New tests/selection-payload-roundtrip.test.js drives the real command to build its menu and feeds the menu's own option value back into the real handler; restoring the old truncation fails four of its tests. Suite: 99 files, 1326 tests

2.38.1 - 2026-09-22 ​

Developer ​

  • Fixed the test suite's cross-file flakiness. Roughly one run in three failed a single test β€” a different one each time (quotes-admin, event-crop, OAuth), each passing in isolation. The cause: four suites called rmSync(dir, { recursive: true }) on directories that jest.setup.js scopes per worker, and Jest reuses a worker across many test files in sequence. Deleting the directory pulled fixtures out from under whichever suite ran next in that worker
  • eventCropRoute and event-request-system now claim their own paths before importing anything that reads them; eventImageStore and eventRequestApproval empty the directory's contents instead of removing the directory, which is safe no matter who shares the path. Verified by inspecting the real scratch directories before and after β€” previously both suites resolved to one shared worker-N/guild_configs, now each gets its own
  • New tests/test-isolation.test.js fails, and names the offending file, if any suite recursively deletes a worker-shared directory without claiming it first. It found two of the four offenders on its first run. 10 consecutive full-suite runs clean afterward, where the same machine previously failed about one in three

2.38.0 - 2026-09-19 ​

Added ​

  • New /timer title β€” name a running timer that was started without identifying what's playing. The title is what the watch-history entry gets written under when the timer stops, so a timer nobody named produces an unidentified log entry. Now whoever's watching can fix it mid-party. It runs the same lookup /timer start does, so an exact title resolves instantly and episode notation (Tales from the Crypt S6: E4-E7) records the range too; if nothing matches, the timer is still named with what was typed, since an approximate label beats an unnamed one
  • The permission rule is deliberately asymmetric, because the two cases carry very different risk. While the timer has no title, anyone may set one β€” that is the state worth fixing, and an empty field has nothing to vandalize. Once a title exists, only the starter or a moderator may change it, since overwriting a correct title silently corrupts the watch-history entry written at stop time
  • Setting a title never reschedules the auto-stop. A running timer is synced to real playback, and moving its end time underneath everyone would be worse than an imperfect one β€” /timer adjust exists for that and says what it does

2.37.1 - 2026-09-19 ​

Changed ​

  • /timer status now shows a duration only when the bot actually knows the runtime β€” from a title lookup or a duration you typed. A timer running on the server's auto-stop cap previously reported "auto-stops in 5h 30m", which answers "when does this finish?" with a number that has nothing to do with the film: that cap is a backstop against a timer running forever, not a runtime. Those timers now show elapsed time alone. The auto-stop deadline still announces itself through the expiry warning, which is where it's actually actionable

2.37.0 - 2026-09-19 ​

Changed ​

  • [BREAKING] /timer status and /timer check are now public by default. A watch party is a shared activity, and whoever runs these is almost always asking on everyone's behalf β€” an ephemeral answer meant the next person had to ask again. Pass public:false to check quietly instead. The "no active timer" message stays private either way, since there's nothing to share
  • docs/commands/watch-party.md still described the old four-field status card and the old default; updated to show what the command actually renders now

Developer ​

  • The flag is read with ?? true rather than || true β€” getBoolean returns null for an option the user didn't supply, and || would swallow a deliberate public:false along with it. tests/timer-status-visibility.test.js fails on that exact mistake

2.36.1 - 2026-09-18 ​

Fixed ​

  • /timer status shows seconds again. The new human-readable format dropped them as soon as there was a minute to report, which is fine for a runtime and wrong for elapsed time β€” people sync a watch party against that number, and "2h 43m" isn't precise enough to start on. Elapsed now reads 2h 43m 32s, and the auto-stop countdown does the same. A duration still omits them, since a fixed runtime like 1h 47m gains nothing from a ticking seconds column
  • Once hours are on display the minutes column stays even at zero, so a timer an hour and five seconds in reads 1h 0m 5s rather than 1h 5s, which looks like a column is missing

2.36.0 - 2026-09-18 ​

Changed ​

  • /timer status is now two lines instead of a full card. It was rendering up to four fields plus a description, a footer and a timestamp β€” a lot of channel for a question people ask in passing. It now answers that question directly:

    ⏱️ Timer: The Covenant (2006)
    Elapsed: 2h 43m    Duration: 1h 47m

    The title moves into a smaller heading rather than a full-size embed title, and elapsed and duration share one line. "Started by" and the auto-stop footer are gone β€” /timer status is for checking progress, not for auditing who started what

  • Durations read the way people say them. 2h 43m rather than 2:43:32, which looks like a clock time, and 5m rather than 5:32, which is ambiguous between five hours and five minutes. A timer with auto-stop turned off reads "no limit", and a fallback safety-cap duration says "auto-stops in 5h 30m" rather than claiming a total nobody chose

Developer ​

  • New formatDurationHuman() / formatMinutesHuman() in src/utils/timerManager.js. Added alongside the existing formatElapsedTime rather than replacing it β€” 25 call sites depend on the H:MM:SS form, which is fine where a label says "Total Time" and poor in a line someone is skimming
  • New tests/timer-status-format.test.js (14 tests) pinning the two-line shape, the absence of fields/footer/timestamp, and the human duration formatting. Suite: 95 files, 1274 tests

2.35.0 - 2026-09-18 ​

Fixed ​

  • An event named with a year β€” "The Covenant (2006)" β€” found nothing and sent the user to a "couldn't find a match" screen. TMDB matches a search literally, so the (2006) suffix returned zero results for a film the bot could otherwise have found instantly; the host then had to click Search, retype the title, and pick from 21 results. Auto-detection now searches without the year while the timer keeps the name exactly as the host wrote it. Only a bracketed or dash-separated year is removed, so "Blade Runner 2049", "Summer of 1984" and "1917" are untouched
  • An exact title match was still being overruled into a picker. The auto-select gate required every other media type to return nothing, which is a sensible guard for a fuzzy score but far too strict for a literal match β€” an exact film match produced a 21-option list purely because some unrelated show also matched the words. An exact title match now settles it outright
  • A year in the event name now breaks a tie between identically-titled results. Three films are called "The Covenant" (2006, 2013, 2023), and one show is too. A host who wrote the year already answered which one they meant, so it's used to disambiguate rather than discarded

Changed ​

  • The "Now Playing" card is smaller. The poster drops from TMDB's w500 to w342 rendition β€” still clearly larger than the countdown thumbnail, without the card dominating the channel β€” the title renders one heading size down, and the separator rule between the title and "The timer is running" is gone

2.34.3 - 2026-09-18 ​

Changed ​

  • The spoken countdown now stacks into one message instead of posting three. Each number was its own message, and Discord repeats the bot's author header above every one β€” so three numbers cost six lines of channel, with "[App] Egg Shen Bot" appearing three times. The classic theme never had this problem because it appends to a single message, and this now does the same: the countdown posts once at "3" (which is what notifies the channel) and then grows in place for "2", "1" and "GO". Same audible countdown, a third of the vertical space, and two fewer messages nudging your scroll position

2.34.2 - 2026-09-18 ​

Changed ​

  • The countdown card's colour now matches its numbers. The embed's accent stripe and its block row were still running through orange and amber while the emoji had already switched to the red/yellow/green starting light, so at "3" the card showed orange next to a red ball, and at "2" amber next to a yellow one. All three signals β€” accent stripe, emoji and blocks β€” now carry the same colour at every step: red through 5-4-3, yellow at 2, green at 1

2.34.1 - 2026-09-18 ​

Changed ​

  • Scaled the posted countdown numbers back down. The 3-2-1 messages that make the countdown audible were rendering as full-size headings, which dwarfed the countdown card they were meant to accompany. They now use a smaller heading β€” still clearly visible in the channel, no longer shouting over everything else
  • The countdown now runs red β†’ yellow β†’ green, like a starting light: 3 is red, 2 is yellow, 1 is green. The previous sequence had 3 as yellow and both 2 and 1 as green, so there was no red in the final countdown at all and the last two steps looked identical

2.34.0 - 2026-09-17 ​

Changed ​

  • The watch-party countdown is now something you can actually miss less easily. The entire timer lifecycle β€” all five countdown steps, GO, and "Timer Started" β€” happened inside a single message that was edited seven times. Discord only notifies on new messages and never on edits, so unless someone was already looking at the channel during those six seconds there was no signal at all; people routinely missed both that a party had started and that it had finished. The card still animates in place (that's the point of it), but the final seconds now each post their own message, so the countdown is audible from 3
  • "Timer Started" is its own message and mentions whoever started it. It was the last edit in the chain, which made the single most important moment the least noticeable one. It now posts fresh, with the starter mentioned in the message content β€” a mention inside an embed never pings anyone, which is why the existing "Started by" field never helped
  • Timer cards show poster art. The countdown carries the poster as a thumbnail and the "Now Playing" card shows it full width, using the TMDB id the timer already stores. Embeds size themselves to their widest line, so a short title rendered as a cramped little box; the poster plus a full-width rule gives the card real presence. Entirely decorative β€” a poster lookup that fails or is unavailable leaves the countdown exactly as it was
  • Timer completion messages now mention the starter too. A timer that auto-stops does so precisely when nobody is necessarily watching the channel

Developer ​

  • New tests/timer-countdown-visibility.test.js (10 tests) pinning the behavior that matters: real messages for the final seconds, the start announced as its own message, the mention in content rather than in an embed, and poster art degrading safely when TMDB is unavailable. Suite: 94 files, 1233 tests

2.33.0 - 2026-09-17 ​

Added ​

  • New /recommend command β€” what to watch next, based on what this server actually watches. The bot could describe any title you named, but had nothing to say about what to watch next; this connects the watch history to TMDB. Every option is optional, so a bare /recommend works, and so does /recommend type:TV source:most-watched genre:Horror decade:1980s. Filters are slash options rather than a chain of menus, so one command reaches results in a single step while Discord's own option hints keep them discoverable. Three sources: Watch History (the default) seeds TMDB's similar-titles endpoint from what the server watches most, drops anything already seen, and re-ranks the result with AI for tonal fit; Most Watched counts the local history with no TMDB ranking and no AI; Discover browses TMDB by filter, and is the default for a server with no history yet so a fresh install still gets an answer. Genre and director are autocompleted β€” genres from TMDB's live list, directors from a person search that ranks actual directors ahead of same-named actors. Results link to IMDb, and to Letterboxd for movies (Letterboxd catalogues films only)
  • AI never invents titles. Candidates always come from TMDB; the model only reorders them and writes a one-line "why you'd like this" grounded in titles the server really watched. Every index it returns is validated against the candidate list, and any failure β€” no API key, a network error, unparseable or malformed JSON β€” falls back to popularity ranking with no explanations. The embed footer says which ranking produced the result, so the difference is visible rather than silent

Fixed ​

  • /random's minimum-vote filter was silently doing nothing. params.vote_count_gte used an underscore where TMDB expects a dot (vote_count.gte), and TMDB ignores unrecognized query parameters rather than erroring β€” so the guard was never applied and /random movie min-rating:8 could return a film rated 8.0 by three people. Measured against the live API for vote_average.gte=8: the broken key matched 20,001 results including under-voted titles, the correct key matches 484. The dotted form was already used correctly elsewhere in the codebase, which is what made the typo identifiable
  • /random could return nothing once that filter started working. Both discover helpers asked for a random page 1-50 without checking how many pages the filters actually matched, and returned empty on a page past the end. Harmless while the broken vote filter left the pool enormous; with the guard working, min-rating:8 narrows to roughly 25 pages, so about half of all requests would have come back empty. Both now read total_pages first and pick within the real range, answering single-page results from that same request rather than making a second one
  • /random tv genre:Horror silently returned nothing. Horror (27) and Romance (10749) are movie-only TMDB genre IDs that had been copied into the TV list, and TMDB returns an empty result set for an unknown genre rather than an error β€” so two of the fifteen offered TV genres could never match anything, on a server whose entire theme is horror. Verified against TMDB's own TV genre list, which has neither. Both remain available for movies

Developer ​

  • New src/utils/recommendationEngine.js β€” watch-history aggregation, seed selection, candidate gathering and scoring. The aggregation half is a pure function over an array with no I/O, so the logic deciding what a server likes is testable directly against fixtures. Normalizes tmdbId to a string when keying, since commands/watched.js stores a number while handlers/selectHandler.js stores a string, and the two would otherwise split a rewatch count
  • New src/utils/genreCache.js and TMDB genre-list helpers, both caching for the life of the process. Watch-history rows record no genre data, so filtering by genre means one lookup per title; autocomplete fires on every keystroke and must never make a request per character
  • getSimilarMovies/getSimilarTV throw rather than returning null, so candidate gathering uses Promise.allSettled β€” one unreachable seed must not lose the whole recommendation
  • New tests: recommendationEngine (24), recommendationRanking (20, including hallucinated/out-of-range AI indices), recommend-command (27), tmdb-discover-params (12), random-tv-genres (33). Suite: 93 files, 1223 tests

2.32.0 - 2026-09-17 ​

Security ​

  • Any member could run tournament management buttons, including closing a matchup early to lock in a result. /bracket open-matchup, close-matchup, open-groups and advance-knockout are all admin/mod-gated as commands, but the selectors they post are public messages β€” Discord shows those buttons to everyone in the channel, and the handlers re-ran none of the command's permission checks. All five management buttons (open_matchup_, close_matchup_, open_region_, start_group_voting_, start_knockout_voting_) now verify Administrator or Moderate Members before acting. The voting buttons are deliberately left open β€” those are for everyone

Changed ​

  • [BREAKING] A watch-party timer no longer interrogates you about the title before it starts. When /timer start can't identify an auto-detected event title with confidence, it now offers two buttons β€” ▢️ Start Now or πŸ”Ž Look Up Title β€” instead of dropping you into a 25-option picker. Confident matches (a single result, an exact title match, or a resolvable episode range) skip the prompt entirely and start with the right duration. Controlled per server by a new watchPartyAutoDetectMode setting (ask β€” the default, full for the previous always-show-the-list behavior, or off), via /eggshen-config-watch-party watch-party auto-detect
  • An exact title match is now taken without consulting semantic scores. Production logs showed the confidence check rejecting matches that were plainly correct: "Tales from the Crypt" scored 0.738 against a 0.80 floor, with its runner-up "Tales From The Crypt Collection" just 0.007 behind. Franchises with collections and spin-offs defeat both thresholds by construction β€” near-identical sibling titles drag the score down precisely when the answer is obvious. A single exact (normalized) title match now wins outright, and works even when OpenAI is unavailable and there are no scores at all. Two results sharing the queried title (a genuine remake like Suspiria) still show the picker
  • A TV show with no episode count no longer gets a duration at all. episode_run_time is a single episode (~26 min for Tales from the Crypt), but a watch party is rarely one episode β€” so that runtime ended timers roughly an hour into a two-hour party. Worse, setting any duration cleared the internal fallback flag, which is exactly what earns a timer its expiry warning and Extend button, so the early stop arrived silently. TV now gets a real duration only from an explicit episode range, where the count is known
  • "Start Timer Without Title Selection" is now the first option in every picker, not the 25th, and says plainly what it does
  • The expiry-warning window now scales with the timer's length β€” roughly the last 15%, floored at 10 minutes and capped at an hour (a 6-hour timer warns at 54 minutes, a 1-hour one at 10). The previous fixed one-hour window meant extending a timer by an hour put it instantly inside the window, which is why extending used to suppress all future warnings. A timer that never had a real duration now keeps warning on every extension instead of going silent after the first
  • /timer status now shows when a duration-less timer will auto-stop, labeled "no duration set" so it doesn't read as a detected runtime. It was hidden entirely, leaving people unaware a deadline existed until the warning fired
  • Stop and auto-stop messages now suggest /timer pause during breaks, with wording specific to multi-episode parties, where stopping and restarting loses the running total for the night

Added ​

  • A watch party's episode range is now read from the scheduled event's description. Hosts routinely split the show and its episodes across the two fields β€” name "Tales From the Crypt", description "Season 6 episodes 4 - 7" β€” a shape neither field could parse alone, so the range was invisible and the bot fell back to a single episode's runtime. That event now produces a correct 113-minute timer with no clicks. Descriptions are matched with the verbose "Season N episode M" pattern only: the bare <n>x<n> shorthand reads "Join us at 8x30 pm" as season 8, episode 30

Fixed ​

  • A TV show sharing its name with a movie was logged to watch history as the movie. At /timer stop the bot re-searched TMDB from the timer's label and took the first hit, and that search concatenates movies ahead of TV β€” so Tales from the Crypt (1989 series) was recorded 10 times as Tales from the Crypt (1972 film). The timer now carries the TMDB id the start flow already resolved and discarded, so the logged title is the one that was actually identified. A multi-episode party stays one show-level entry with the range in the notes (Watch party timer: 1h 53m β€’ S6 E4-7), keeping per-show counts answerable
  • A matched TV show no longer shows the "Couldn't find a match" correction screen. A show with no episode count set the same internal flag as a failed search, so the bot offered to re-search a title it had just successfully identified. It now starts the timer and explains why the duration is unset, and how to set one
  • The merged movie/TV/board-game picker had no explicit result cap β€” it stayed within Discord's 25-option limit only because its three sources each capped at 8, which happens to sum to 24. Now capped explicitly

Developer ​

  • New src/utils/timerWatchLog.js β€” resolveWatchedTitle(), buildWatchLogNotes() and buildPauseHint(), shared by both copies of autoLogTimerToWatchHistory so the "what did we watch" decision isn't duplicated between the /timer stop and auto-stop paths
  • pickLandslideWinner(results, query) takes an optional query; callers that omit it keep score-only behavior. getEventTitleForChannel merged into getEventForChannel (byte-identical but for the status filter and return value), which now takes { includeScheduled, logPrefix }
  • Timer records persist tmdbId, type and episodeRange. All three are absent on pre-existing timers and on skipped-title timers, so every reader tolerates undefined
  • New tests: bracket-button-permissions.test.js (18, fails if any single permission guard is removed), timer-autodetect-mode.test.js (21), parseEventEpisodeRange.test.js, timer-skip-option-first.test.js, timer-ambiguous-prompt-buttons.test.js, timerWatchLog.test.js, plus exact-match coverage in pickLandslideWinner.test.js using the real production scores. Suite: 88 files, 1107 tests

2.31.0 - 2026-09-07 ​

Added ​

  • Metacritic scores now appear in the ratings row for movies. The score was already arriving in the OMDB response the bot fetches on every lookup and was simply being discarded, so this costs no extra API call. TV series generally have no Metascore, so the badge renders only when a score exists rather than showing a blank. Toggleable per server like every other service, via /eggshen-config services toggle, with its own optional custom emoji

Changed ​

  • The Rotten Tomatoes badge is now consistently labelled "RT Critics". One of its three branches still read "Rotten Tomatoes", which implied a combined critics+audience score the bot cannot source. Investigated whether the audience score could be added and confirmed it cannot: OMDB's RT data is critics-only (its tomatoes=true fields exist but return N/A for every audience value across both recent and catalogue titles), Watchmode's critic_score is its own aggregate that diverges sharply from RT (71 vs RT's 85% on The Thing), TMDB and Trakt report their own community scores on a different scale, and the official RT API is restricted to approved commercial partners. Labelling any of those as RT's audience score would be presenting the wrong number under a trusted brand
  • docs/commands/search.md claimed Rotten Tomatoes "(Critics & Audience)" β€” corrected, with a note explaining why the audience score is absent and pointing at IMDb/Trakt/Letterboxd as the community-voted alternatives

Fixed ​

  • Two titles from the same group could still meet in the first knockout round. The separation pass paired participants as (0,1), (2,3)… but buildBracketTree gives the first numByes participants a matchup to themselves, which shifts every real pairing β€” so the check was inspecting slots the bracket never actually pairs. It also only searched forward, leaving the final pair with nothing to trade against. Measured at 243 same-group first-round matchups across 1,600 generations before the fix, 0 after
  • Raised the Jest per-test timeout from the 5s default to 15s. A quotes-admin test that spins up a supertest server was intermittently exceeding it under parallel worker load β€” verified as pre-existing (it reproduces without any of this release's changes) rather than newly introduced

Developer ​

  • New tests/ratings-badges.test.js (10 tests) covering Metacritic presence/absence, the per-guild toggle, and the RT critics-only labelling
  • tests/bracket-seeding.test.js now checks same-group separation across all four group sizes and across 25 repeated randomized seedings. The previous single-sample check caught the bug above only about 1 run in 9; the strengthened version catches it every time
  • /eggshen-config service and emoji status displays now fall back to the raw key name instead of rendering undefined when a config key has no display label

2.30.2 - 2026-09-07 ​

Fixed ​

  • A moderator approving an event request with an image often got a scheduled event with no cover image at all. resolveEventImageBuffer() returned a bare Buffer regardless of the image's real format, and discord.js's resolveImage()/resolveBase64() hardcode a raw Buffer's content-type to image/jpg when it isn't already a data: URI β€” so a pasted PNG/WEBP link, or any non-JPEG upload, got mislabeled and Discord silently dropped the cover image. (Uploads/crops made through this project's own crop tool always happen to export JPEG, which is why this wasn't universally broken.) Now builds a data:<real-mimetype>;base64,... URI explicitly, using the image's actual content-type in both cases (fetched URL's real content-type header; a stored file's real extension)

Added ​

  • The moderation-channel message now shows the actual submitted/pasted image as a thumbnail, not just a "βœ… Uploaded"/"πŸ”— Linked" text status β€” a moderator can see what was submitted at a glance, without clicking "Crop Image" or waiting until after approval. Refreshes automatically after a moderator re-crops, and after editing in the image URL via the Edit modal

Developer ​

  • src/utils/eventImageStore.js: new mimeTypeForFilePath() β€” the inverse of the existing extensionForMimeType(), recovering a stored file's real mimetype from its extension for cases (like this fix) where only a bare path is in hand
  • src/api/server.js: the initial moderation embed attaches the uploaded file directly (AttachmentBuilder + embed.setThumbnail('attachment://...')) or references a pasted URL's thumbnail directly (already public, no attachment needed); the crop-save embed-refresh path re-attaches the freshly-cropped image the same way
  • New tests pinning the original bug (tests/eventRequestApproval.test.js: "a non-JPEG uploaded image is not mislabeled as image/jpg") and the new thumbnail behavior (tests/event-request-system.test.js, both the pasted-URL and uploaded-file cases)

2.30.1 - 2026-09-06 ​

Fixed ​

  • The Jest suite was unreliable β€” 1 to 10 tests failed on almost every run, in a different suite each time. Jest runs each test file in its own worker process, but several modules default to a single fixed path under the repo root (guild_configs/, guild_tournaments/, active_timers.json, event_request_images/, pending_event_requests.json, guild_polls/). Suites that wiped those in beforeEach/afterEach were deleting each other's fixtures mid-run, so a passing suite could be failed by an unrelated one running beside it. Verified as long-standing rather than newly introduced by reproducing it on earlier commits
  • Every affected module now reads an env var and falls back to its real path when unset (the pattern movieQuotesStore.js already used for MOVIE_QUOTES_FILE), and tests/jest.setup.js gives each worker its own scratch directory keyed by JEST_WORKER_ID. Production behavior is unchanged β€” the env vars are unset outside tests, so every default resolves exactly as before
  • Tests no longer write scratch files into the repo root at all

Developer ​

  • New tests/jest.setup.js, wired in via setupFiles so it runs before any test file's static imports are evaluated β€” the env vars must be set before a module under test reads its path at import time
  • tests/README.md documents the isolation contract, including the table of env vars and the rule for adding a new module that persists to disk
  • Corrected tests/README.md's description of npm run test:simulate β€” it builds a 36-title/9-group tournament, not the 4 groups the doc claimed
  • Full suite verified green across 8 consecutive runs (974/974 each), where the same suite previously failed intermittently on most runs

2.30.0 - 2026-09-06 ​

Added ​

  • New /watchlist command β€” a persistent queue of what the server plans to watch next. Fills the gap between deciding on something and actually watching it: previously a tournament could crown a champion, or a survey could settle an argument, and someone still had to remember the result. Subcommands: add, remove, list, pick, want, clear
  • /watchlist pick chooses something to watch β€” at random (the argument-settler), by most votes, or by whatever has waited longest β€” and shows it with its poster
  • /watchlist want lets members vote for titles they want to watch; votes drive the "Most wanted" sort and the votes pick method. Running it again on the same title takes the vote back
  • /watchlist remove and /watchlist want use autocomplete against the titles already on the list, so there's no retyping and no ambiguity about which entry is meant
  • The watchlist connects to what already exists. Logging a title with /watched add β€” or letting a watch party timer log it automatically β€” removes it from the watchlist, and a finished tournament can put its champion straight on the list, tagged with the tournament it won
  • New /eggshen-config-watch-party watchlist settings group: max-size (10–500, default 100), mod-only-add, auto-add-champion (off by default), auto-remove-watched (on by default), plus a view subcommand showing current settings and list size

Fixed ​

  • Auto-closing a knockout matchup never posted its results. postMatchupResults read matchup.participants[0] and matchup.votes[0], but matchups store their entrants as movie1/movie2 and votes as votes.movie1/votes.movie2 β€” no matchup has ever had a participants array, so the function threw on every invocation and the result embed was silently dropped. It also received result.matchup, which closeKnockoutMatchup does not return
  • Auto-closing a matchup that ended in a tie no longer tries to announce a winner β€” the tiebreaker vote is posted by its own path

Developer ​

  • New src/utils/watchlistManager.js β€” per-guild JSON storage mirroring watchHistoryManager's shape. Entry identity is tmdbId + type, since TMDb ids are only unique within a media type (a movie and a show can both be id 1396)
  • New src/utils/watchlistIntegration.js β€” keeps the tournamentβ†’watchlist hook out of both watchlistManager (pure storage) and bracketManager (synchronous, while watchlist writes are async). Skips tournament types the watchlist can't represent (games, board games, books) and never throws, so a watchlist failure can't disrupt announcing a winner
  • The "remove once watched" sync lives inside saveWatchHistory rather than in each caller, so all five call sites β€” including an auto-completing watch party timer β€” stay in sync
  • /watchlist is 1781 bytes (22.3% of Discord's 8000-byte limit); the new config group brings /eggshen-config-watch-party to 4450 (55.6%). Both verified with npm run check:commands
  • New tests/command-size-limit.test.js β€” fails the suite if any command exceeds the 8000-byte limit, or if an unexpected command crosses 75% of it. The limit had previously only ever surfaced as a failed deploy
  • New tests/watchlistManager.test.js (31 tests)
  • Watchlist settings read defensively (config.watchlist || {}) because loadGuildConfig returns saved files verbatim without merging in newly-added defaults β€” existing guilds have no watchlist key

2.29.2 - 2026-09-06 ​

Fixed ​

  • Group-stage tournaments dropped every group runner-up (and any surplus wildcards) from the knockout bracket. Round 1 was built by looping over group winners only and pairing each with one entry from a shared runners-up + wildcards pool, so exactly groupCount Γ— 2 titles were seated and every leftover qualifier was silently discarded β€” no error, no warning. At 36 titles all 9 runners-up vanished; measured seating was 18/27 (36), 20/30 (40), 22/32 (44) and 24/32 (48). The knockout bracket is now built as a true single-elimination tree sized to the next power of 2, seating every qualifier, with byes for any unfilled slots
  • The knockout bracket was not a valid tournament tree. A 36-title tournament produced 9 β†’ 5 β†’ 3 β†’ 2 β†’ 1 matchups per round; rounds now halve correctly (16 β†’ 8 β†’ 4 β†’ 2 β†’ 1) all the way to a single final
  • Titles from the same group could meet in round 1. The "avoid same-group pairings" rule compared a groupId that group results never actually carried, so the comparison was always undefined !== undefined and never fired. Group results now carry their groupId and the rule works
  • More wildcards were requested than could ever exist. A 9-group tournament asked for 14 wildcards from only 9 possible third-place finishers, sizing the bracket for participants that could not exist. Wildcard count is now capped at one per group
  • Auto-closing a tied group announced final results before the tiebreaker had run. The scheduler posted a "πŸ“Š Results β€” βœ… Advances" embed and disabled voting whenever a group's deadline passed, including when the tie had just opened a tiebreaker vote. It now posts the tiebreaker vote (with working buttons) instead, and only posts results once the group is genuinely decided
  • Every auto-resolved tiebreaker was announced as "random β€” no votes cast", even when members had voted. The scheduler read the vote tallies from the wrong level of the result object, so the "was this random?" check was always vacuously true
  • Bye matchups misaligned round advancement. The three winner-advance code paths filtered out matchups lacking two participants, which shifted every subsequent winner into the wrong next-round slot once byes existed. All three now share one position-ordered implementation
  • /bracket advance-knockout could advance a partially-finished round, compacting the closed matchups and landing winners in the wrong slots; it now refuses and reports how many matchups are still open
  • A group tiebreaker at an unexpected position silently left the group closed with no result, stalling the tournament; it now returns an explicit error
  • A completed tournament set only one of winner/champion depending on which code path finished it β€” both are now always set

Developer ​

  • src/utils/bracketManager.js: new shared buildBracketTree(), propagateWinners(), resolveByes() and separateSameGroup() helpers replace four divergent copies of the bracket-building and round-advance logic across generateInitialBracket, generateKnockoutBracket, regenerateKnockoutBracket, closeKnockoutMatchup, finalizeKnockoutMatchupAfterTiebreaker and advanceKnockoutRound
  • calculateWildcardCount() is now exported and used by /bracket close-groups for the wildcard count it displays, instead of a duplicated copy of the (buggy) formula
  • Replaced sort(() => Math.random() - 0.5) with a Fisher-Yates shuffle() β€” the comparator is inconsistent and produces a biased ordering
  • npm run test:simulate has been failing at step 7 (Only 4 of 9 groups have been closed) β€” it hardcoded 4 groups against a 36-title/9-group tournament, so it never reached bracket generation. It now builds one group per group the tournament actually has, and runs to completion
  • New tests/bracket-seeding.test.js (21 tests) asserts the invariant that was missing: every qualifier reaches the bracket exactly once, across all four group sizes and all five bracket sizes, plus tree shape, bye handling, wildcard capping and a full play-through to a single champion. 15 of the 21 fail against the previous code
  • Full Jest suite green (938/938)

2.29.1 - 2026-09-05 ​

Changed ​

  • Website URL and theme moved to their own /eggshen-config-website command, out of /eggshen-config-events event-requests β€” neither setting was really event-request-specific (theme also drives quotes-admin links regardless of whether event requests are enabled, and the website itself may grow beyond just the event-request form). New subcommands: /eggshen-config-website url, theme, view

Developer ​

  • src/utils/guildConfig.js: new top-level website: {url, theme} namespace, replacing eventRequests.websiteUrl/webTheme
  • New scripts/migrate-website-config.js β€” one-time, idempotent migration moving any existing eventRequests.websiteUrl/webTheme in guild_configs/*.json into the new website namespace; safe to run more than once
  • Full Jest suite green after the move; new tests/eggshen-config-website.test.js

2.29.0 - 2026-09-05 ​

Added ​

  • Named web color themes β€” scripts/web-themes.json maps a theme name to a primary color; npm run build:web compiles one full Bootstrap CSS build per theme (public/css/themes/<theme>/bootstrap.min.css), and /eggshen-config-events event-requests web-theme name:<theme> assigns one per Discord server. Lets a self-hoster running several of their own communities from one bot process give each its own brand color, instead of one WEB_PRIMARY_COLOR for the whole deployment
  • The moderator crop page and quotes-admin links now render in the theme assigned to the guild they're for. The crop page resolves its theme server-side from the event request's own guild (no new link/token plumbing needed); quotes-admin links (/eggshen-config-quotes admin-link) bake the invoking guild's theme into the signed link itself, since the underlying quote list has no guild of its own to look one up from. Visiting /quotes-admin directly, with no link, falls back to the "default" theme
  • New scripts/domains.json + npm run deploy:domain <label> β€” a manifest tying each domain/community to a guild ID, theme, and optional logo, replacing hand-copied public/public-dev folders. Generates a complete, ready-to-serve static-file copy at domains/<label>/ (its own config.js and themed CSS) for each entry, or all of them at once with --all

Changed ​

  • EVENT_REQUEST_SETUP.md's "Multiple Servers" and "Customizing the Look" sections rewritten around named themes and domains.json, replacing the old "dev subdomain of your production domain" example β€” a second (or third) community's domain is now just another independent entry in the manifest, not assumed to be subordinate to the first

Developer ​

  • src/utils/guildConfig.js: new eventRequests.webTheme field (defaults to 'default')
  • src/utils/webThemes.js: new β€” lists/validates theme names against scripts/web-themes.json for the new slash-command subcommand
  • src/utils/quotesAdminLinkToken.js: signQuotesAdminLinkToken accepts an optional theme; new non-consuming peekQuotesAdminLinkToken reads it back safely on every page load/reload without burning the token's one real use
  • scripts/generate-palette.js/scripts/build-web-assets.js: loop the new theme manifest instead of producing one fixed-name palette/CSS file; .github/workflows/build-web-fallback.yml now only seeds the "default" theme's compiled CSS for zero-build-tooling self-hosters
  • Full Jest suite (914/914, 18 new) and Playwright e2e suite green after the change

2.28.0 - 2026-09-04 ​

Added ​

  • Self-hosters can now set one primary accent color (WEB_PRIMARY_COLOR) that mathematically derives the entire color scheme (secondary, danger, success, the whole gray scale) for the event-request form, moderator crop page, and quotes-admin β€” no need to pick 7 colors independently. Danger and success are automatically kept visually distinct from whatever primary is chosen (e.g. a red primary shifts danger toward orange instead of colliding with it). Takes effect after running npm run build:web; unset, everything looks exactly like before this existed

Changed ​

  • The 3 web pages' Bootstrap CSS/JS is now compiled from source (SCSS) rather than vendoring the full precompiled framework, and only the components/utilities these pages actually use are included. Real measured result: 108.9KB total (CSS+JS) vs. 310.7KB before β€” a 65% reduction. Concretely: only forms/buttons/nav-tabs/cards/badges/alerts are compiled in (no modal, dropdown, tooltip, popover, carousel, accordion, navbar, toast, offcanvas, breadcrumb, pagination, progress, list-group, or spinners β€” none of which any page uses); the utility-class set is trimmed to only what's referenced in markup; the JS bundle contains only the Tab component (quotes-admin's tab switcher) instead of the full bundle plus Popper
  • public/css/theme.css is gone β€” its entire purpose (manually re-specifying button/nav-tab hover/active colors, since Bootstrap bakes those in at compile time rather than reading root CSS variables) is now handled automatically by Bootstrap's own Sass color functions once real Sass variables are set before compiling, rather than working around it after the fact

Developer ​

  • New public/scss/custom.scss β€” explicit, selective @import list (not @import "bootstrap/scss/bootstrap") naming exactly the kept components; public/scss/_kept-utilities.scss trims Bootstrap's $utilities map the same way. Adding a component or utility back is a one-line change in either file β€” confirmed no real cross-dependency blocks dropping any of the unused components (a couple of harmless inert CSS selectors reference dropped classes like .btn-close/.dropdown-menu, costing nothing since those classes don't exist in this project's markup)
  • New scripts/generate-palette.js β€” derives the palette from WEB_PRIMARY_COLOR using culori's OKLCH color space (perceptually uniform, unlike raw HSL), with an explicit circular-hue-distance check that shifts danger/success away from primary's own hue when they'd otherwise collide; writes public/scss/_generated-palette.scss (gitignored, regenerated every build)
  • New scripts/build-web-assets.js (npm run build:css/build:js/build:web) β€” orchestrates palette generation, sass compilation, and an esbuild bundle of public/js-src/bootstrap-entry.js (imports only bootstrap/js/dist/tab)
  • New devDependencies: bootstrap (for its scss/js source, not just precompiled dist/), sass, esbuild, culori
  • Build-output distribution split by deploy context: this project's own deploy (DEPLOYMENT.md) now runs npm run build:web fresh every time (output gitignored) β€” but public/index.html is copied wholesale by third-party self-hosters who may have no build tooling at all, so a new .github/workflows/build-web-fallback.yml (mirrors the existing deploy-docs.yml pattern) rebuilds and auto-commits the compiled bootstrap.min.css/bootstrap.min.js back to main whenever their source changes β€” a git clone alone still gives a working, zero-build-step copy
  • EVENT_REQUEST_SETUP.md/QUOTES_ADMIN_SETUP.md: new "Customizing the Look" sections distinguishing WEB_PRIMARY_COLOR (build-time, whole-deployment) from LOGO_URL/GUILD_ID (runtime, per-domain via each domain's own config.js)
  • Full Jest suite (896/896) and Playwright e2e suite (20/20) green after the migration; visually verified the derived-palette system directly (a red WEB_PRIMARY_COLOR test build showed clearly-distinct red/orange Save/Delete buttons, not a collision)

2.27.1 - 2026-09-04 ​

Added ​

  • A favicon (derived from the Egg Shen bot's own logo) now shows in the browser tab on all 3 web pages (event-request form, moderator crop page, quotes-admin) β€” .ico plus 16px/32px PNGs and an Apple touch icon
  • The event-request form can now show an optional logo at the top, centered and capped at 200px wide β€” set LOGO_URL in public/config.js (same file GUILD_ID already lives in) to your server's own icon or whatever brand/community the form represents. Left unset, no logo is shown (unchanged from before)

Fixed ​

  • /quotes-admin (and the crop page) were serving the raw event-request form's HTML instead of Bootstrap's CSS/JS, with the browser refusing to apply it due to a text/html MIME type mismatch. Root cause: the 2.27.0 Bootstrap migration added a /shared-assets static mount inside the bot process, but the reverse-proxy config on both shudderdrivein.com and dev.shudderdrivein.com was never updated to forward /shared-assets/ to the bot β€” those requests fell through nginx's SPA-style fallback and got served index.html. Added the missing location /shared-assets/ block (mirroring the existing /crop-assets///quotes-assets/ pattern) to both domains

Developer ​

  • New public/img/ (favicon.ico, favicon-16.png, favicon-32.png, apple-touch-icon.png), added to the existing /shared-assets static mount in src/api/server.js alongside public/css/public/js
  • public/config.example.js: new optional LOGO_URL field; public/app.js reads it and shows/sizes #server-logo in index.html's header on load, no other behavior change
  • This deployment's nginx configs updated directly (both domains) β€” not something a git pull alone fixes for existing self-hosted deployments; anyone who deployed 2.27.0 behind a reverse proxy needs the equivalent /shared-assets/ block added

2.27.0 - 2026-09-04 ​

Changed ​

  • The 3 standalone web pages (event-request form, moderator crop page, quotes-admin) now use vendored Bootstrap 5.3.8 in dark mode with a custom cyan (#1ac3ff) accent and blue/green-tinted grays, instead of hand-rolled CSS styled to look like Discord's own UI. Two things prompted this: a real visual bug (quotes-admin's dynamically-added quote rows rendered as unstyled native browser inputs β€” no height matching the buttons, no padding β€” because the old CSS rule only applied inside a wrapper those rows didn't have), and a design concern that mimicking Discord's own visual identity on pages that aren't Discord surfaces reads as deceptive. All form controls, buttons, alerts, and (on quotes-admin) tabs now come from Bootstrap's real component classes; quotes-admin's hand-rolled tab-switcher JS was deleted in favor of Bootstrap's native tab component (data-driven via markup, no custom JS needed)

Developer ​

  • New public/css/bootstrap.min.css, public/js/bootstrap.bundle.min.js (vendored, not CDN β€” self-contained for self-hosters) and public/css/theme.css (the shared [data-bs-theme="dark"] color-override layer, replacing the :root block duplicated across all 3 pages' CSS files previously). Buttons and nav-tabs needed direct per-component --bs-btn-*/--bs-nav-tabs-* overrides since Bootstrap bakes literal hex into those classes at its own compile time rather than referencing root theme variables the way .form-control/.alert-* do
  • src/api/server.js: new scoped /shared-assets static mount for public/css/public/js, alongside the existing /crop-assets//quotes-assets mounts β€” does not widen either existing mount or expose public/index.html/app.js/style.css from the bot's own origin
  • Each page's style.css/crop.css/quotes-admin.css trimmed from ~300 lines to ~15-40: only what Bootstrap has no opinion on (Cropper.js container sizing, the bulk-editor's monospace font) remains
  • Found and fixed a real regression during verification: Bootstrap's .d-flex/.d-block utility classes use !important, which silently overrode two elements' JS-driven style.display = 'none' hides on the event-request form (#user-info, #discord-invite-link) β€” removed the conflicting utility classes from those two elements so the existing inline-style show/hide logic in app.js remains the single source of truth
  • tests/e2e/rate-limit-blocked.spec.js, tests/e2e/image-url-crop.spec.js, tests/e2e/golden-path-simple-mode.spec.js updated for the renamed alert-danger/alert-success classes (previously message error/message success); the golden-path test's ancestor-depth assertions (.locator('..')/.locator('../..')) needed no logic change since the new .form-check markup preserves the exact same DOM depth app.js's own traversal relies on
  • Full Jest suite (896/896) and Playwright e2e suite (20/20) verified green after the migration

2.26.1 - 2026-09-03 ​

Added ​

  • /suggest-quote is now rate-limited and capped to prevent queue/moderation-channel flooding. Tightened its rate limit to 1 request/minute (down from the generic 1-per-20-seconds every command gets by default), and added a per-user pending-suggestion cap β€” a user can have at most maxPendingPerUser (default 3) suggestions awaiting review at once, closing a gap the rate limit alone didn't cover (a patient user could otherwise still accumulate many unresolved suggestions over time). Configurable via the new /eggshen-config-quotes max-pending-per-user subcommand
  • New /eggshen-config-quotes admin-link subcommand β€” replies with a one-click Link button that opens /quotes-admin already unlocked, so an admin/moderator no longer needs to be handed QUOTES_ADMIN_SECRET to type in manually. The link is single-use and expires in 10 minutes; requires PUBLIC_BOT_URL to be set (same variable the event-request crop-image link already uses)
  • /eggshen-config-quotes's subcommands are no longer nested under a redundant quotes group β€” e.g. /eggshen-config-quotes add, not /eggshen-config-quotes quotes add. Unlike /eggshen-config-events (which groups multiple config areas), this command was always quotes-only, so the group added a layer with nothing else to group

Developer ​

  • src/utils/pendingQuotesStore.js: pending entries now track suggestedById (the Discord user's snowflake) alongside the existing display-tag suggestedBy, and a new countPendingBySuggester(guildId, suggestedById) backs the new pending cap
  • src/utils/guildConfig.js: new quoteSuggestions.maxPendingPerUser (default 3) and rateLimits.commands['suggest-quote'] (1/minute) β€” no slash command adjusts the rate limit per-server yet, only guild_configs/<guildId>.json directly
  • New src/utils/quotesAdminLinkToken.js β€” signed, single-use, short-lived tokens backing /eggshen-config-quotes admin-link, mirroring cropLinkToken.js's shape (HMAC-signed, crypto.timingSafeEqual, in-memory single-use tracking) but not tied to a specific item, since /quotes-admin is a standing surface, not a per-request page. The token itself isn't the admin secret β€” a new POST /api/quotes-admin-link/exchange route verifies+consumes it and hands back the real QUOTES_ADMIN_SECRET exactly once, keeping the long-lived secret out of the URL
  • public/quotes-admin/quotes-admin.js: reads a ?token= query param on load and auto-unlocks via the exchange route above, skipping the password prompt β€” the token is stripped from the visible URL immediately after use; matches the page's existing no-persistence security posture (nothing is stored, a refresh re-locks it)
  • src/commands/eggshen-config-quotes.js: flattened out of the quotes subcommand group; new max-pending-per-user and admin-link subcommands
  • New tests: tests/quotesAdminLinkToken.test.js; tests/pendingQuotesStore.test.js/tests/suggest-quote-command.test.js/tests/eggshen-config-quotes.test.js/tests/quotesAdminRoutes.test.js extended for suggestedById, the pending cap, and the admin-link exchange route

2.26.0 - 2026-09-02 ​

Added ​

  • Status quotes are now structured (title/quote/author) instead of plain text, and /quotes-admin is fixed and reworked. Each quote can optionally carry a title (the movie/show/game/etc. it's from) and an author (the character or real person who said it) β€” existing plain-text quotes are migrated automatically, no manual re-entry needed. /quotes-admin gained a bulk editor (paste/edit the whole list at once as Title | Quote | Author lines, validated before saving) alongside the existing one-row-at-a-time editor, plus a Suggestions tab for reviewing member-submitted quotes. Fixes the page returning 404 for self-hosters running behind a reverse proxy that wasn't forwarding /quotes-admin///quotes-assets/ β€” see QUOTES_ADMIN_SETUP.md's new reverse-proxy section
  • New /quote command β€” posts a random status quote into the channel as an embed, everyone can see it. Optional title/author filters, combined as OR (/quote title:"The Thing" author:"MacReady" matches either), title has autocomplete
  • New /suggest-quote command β€” any member can submit a candidate quote (with optional title/author) for review, rather than it going straight into the bot's status. Suggestions land in a review queue; if the server has a quote-suggestions moderation channel configured (/eggshen-config-quotes quotes moderation-channel), a message appears there with Approve/Edit/Reject buttons (mirrors the existing event-request approval flow) β€” otherwise, review via /quotes-admin's Suggestions tab instead
  • New /eggshen-config-quotes command β€” admin/moderator only. quotes add/edit/delete/list manage the live quote list directly from Discord (no code/redeploy needed, and add bypasses the review queue entirely, unlike /suggest-quote); quotes moderation-channel sets where suggestions are posted for review
  • /eggshen-help now lists /quote and /suggest-quote under a new "Status Quotes" category, respecting each command's commandPermissions toggle like every other listed command

Developer ​

  • src/utils/movieQuotesStore.js: quotes are now { title?, text, author? } objects instead of bare strings; loadQuotes() transparently migrates and persists any legacy plain-string entries; new replaceAllQuotes() validates and atomically overwrites the whole list (backs the bulk editor); file path overridable via MOVIE_QUOTES_FILE (defaults unchanged) so parallel Jest workers don't race on the same real file across test files
  • New src/utils/pendingQuotesStore.js β€” the /suggest-quote review queue, backed by a separate gitignored movie_quotes_pending.json. Entries carry a stable random id (not just an array index) so a Discord moderation button clicked after other suggestions were already processed still resolves to the right entry, mirroring the event-request system's requestId pattern; approvePending()/rejectPending() resolve by id. Also overridable via MOVIE_QUOTES_PENDING_FILE for the same test-isolation reason
  • src/handlers/buttonHandler.js: new approve_quote_/edit_quote_/reject_quote_ branches, mirroring approve_event_/edit_event_/deny_event_'s structure (permission check, embed update with buttons removed on resolution, Edit opens a pre-filled modal)
  • src/index.js: new edit_quote_modal_ submission handler β€” saving the edit modal approves the suggestion immediately with the edited values, same "edit immediately approves" UX event requests already use
  • src/utils/guildConfig.js: new quoteSuggestions.moderationChannel config block; quote/suggestQuote added to commandPermissions
  • src/api/server.js: existing /api/quotes* routes updated for the object shape; new PUT /api/quotes/bulk (validates every row before writing anything, returns which row failed on error) and GET/POST /api/quotes/pending* routes for the web-page suggestion-review path β€” same requireQuotesAdmin/quotesAdminLimiter gating as the existing routes
  • New tests: tests/pendingQuotesStore.test.js, tests/quote-command.test.js, tests/suggest-quote-command.test.js, tests/quoteSuggestionButtons.test.js, tests/eggshen-config-quotes.test.js; tests/movieQuotesStore.test.js/tests/quotesAdminRoutes.test.js/tests/presenceScheduler.test.js/tests/help-command.test.js updated for the new shape and commands

2.25.5 - 2026-09-02 ​

Developer ​

  • Fixed tests/event-request-system.test.js's long-standing flaky/failing tests β€” the full test suite is now 100% green (809 tests across 71 files, zero known failures). Root causes: several tests mocked guildConfig.js from inside individual test bodies with only a few of its real exports stubbed and no jest.resetModules() between tests, so one test's incomplete mock leaked forward through Jest's shared ESM module registry and broke every later test that touched anything depending on it, in an order-dependent way; the mock guild's channels.cache was a plain Map instead of discord.js's Collection class real code calls .filter()/.map() on; several tests were missing required fields or mock methods (guild.members.fetch, interaction.deferReply/editReply/showModal); a few assertions tested stale behavior that never matched the real code (e.g. expecting deny_event_ to delete the request immediately, when it only opens a reason modal β€” already correctly covered elsewhere); and the rate-limit test wasn't pinning a Host header, so per-request ephemeral ports defeated the host+IP-keyed limiter
  • Replaced the partial per-test guildConfig.js mocks with the real module backed by a cleaned-up guild_configs/ temp directory (same pattern already used by eventCropRoute.test.js), using saveGuildConfig to set up specific states where a test needs one; fixed all mock shapes; corrected stale assertions; stubbed OAuth env vars in a scoped beforeAll/afterAll for the OAuth Configuration Validation block, which previously asserted directly on process.env with no guard and only passed on a machine with a real .env configured

2.25.4 - 2026-09-01 ​

Added ​

  • The bot's rotating status text can now be edited from a web page (/quotes-admin) instead of code + a redeploy. Gated by a shared secret you set per deployment (QUOTES_ADMIN_SECRET, same idea as EVENT_CROP_LINK_SECRET) β€” useful since this bot is meant to be run by other server owners too, not just edited by whoever maintains the code. Add, edit, or delete status lines from a simple list; changes are picked up at the bot's next scheduled rotation, no restart needed. See QUOTES_ADMIN_SETUP.md

Developer ​

  • New src/utils/movieQuotesStore.js β€” JSON-file-backed (movie_quotes.json, gitignored) quote list with loadQuotes/setQuotes/addQuote/updateQuote/deleteQuote, mirroring guildConfig.js/eventImageStore.js's existing read/write shape; seeds itself from movieQuotes.js's array the first time the file doesn't exist yet
  • presenceScheduler.js's setRandomQuote now reads via movieQuotesStore.loadQuotes() on every hourly tick instead of a static import, so edits take effect without a restart; falls back to one built-in default if the list is ever emptied out
  • src/api/server.js: new requireQuotesAdmin middleware (checks a Bearer token or X-Admin-Secret header against QUOTES_ADMIN_SECRET, 401/403 on mismatch, 503 if unset) gating new GET/POST/PUT/DELETE /api/quotes* routes; GET /quotes-admin serves the page itself (unauthenticated at the route level β€” the API calls are what's actually gated); new /quotes-assets static mount, same pattern as /crop-assets; a dedicated rate limiter (20/min) on the admin routes
  • New public/quotes-admin/ (quotes-admin.html/.js/.css) β€” a small list-editing page modeled on public/crop/'s structure and visual style; the admin secret is kept in page memory only (no cookie/localStorage), re-entered each visit
  • New tests: tests/movieQuotesStore.test.js (13 tests), tests/quotesAdminRoutes.test.js (11 tests, auth + CRUD); tests/presenceScheduler.test.js updated for the now-async, store-backed setRandomQuote

2.25.3 - 2026-09-01 ​

Added ​

  • The bot's Discord status now rotates hourly through John Carpenter movie flavor text, referencing Big Trouble in Little China (the bot's namesake) and other Carpenter films β€” shown as a bare custom status with no "Playing/Watching" prefix

Developer ​

  • New src/utils/movieQuotes.js β€” flat array of status strings, easy to hand-edit
  • New src/utils/presenceScheduler.js β€” follows the existing initialize(client)/shutdown() scheduler pattern (same shape as timerScheduler.js, pollScheduler.js, etc.), sets an initial status immediately on startup then rotates hourly via client.user.setPresence(...) with ActivityType.Custom; avoids repeating the immediately-previous quote
  • src/index.js wires the new scheduler into the existing clientReady init sequence and graceful-shutdown path
  • New tests/presenceScheduler.test.js (7 tests) covering initial status, payload shape (state holds the text, not name), rotation timing, shutdown, and no-immediate-repeat behavior

2.25.2 - 2026-08-31 ​

Fixed ​

  • Adjusting the crop box on the event-request form no longer uploads a new image on every drag. The previous fix (2.25.1) only cleaned up the file left behind by each adjustment; the adjustments themselves still each triggered a real upload, bounded only by the rate limit β€” a user (or script) nudging the crop box could still generate many real uploads, and a legitimate user could hit the rate limit's cap after only a few adjustments. Dragging/resizing the crop box is now purely local, exactly like the moderator's crop page has always worked. The original image still uploads immediately when picked or fetched (the source of truth a moderator can re-crop from later) β€” only the cropped result is deferred, uploading exactly once, when the request is submitted, using whatever framing the crop box shows at that moment

Developer ​

  • POST /api/event-request/upload-image now serves two call shapes under one endpoint: an original-only call (right after pick/fetch) mints a fresh imageToken and uses the original as the initial, uncropped image too; a crop call at Submit reuses an existing imageToken (validated as a 32-hex-character token this endpoint itself generates, never an arbitrary caller-supplied key) to save the final crop under the same key, leaving the preserved original untouched
  • public/app.js: imageFileInput's change handler and fetchImageUrlBtn's click handler now upload the original immediately; Cropper's ready/cropend callbacks no longer upload anything; handleSubmit reads the current crop via cropper.getCroppedCanvas(...) and uploads it once, reusing the already-issued token, before creating the event request
  • Reverted the 2.25.1 previousToken supersession-delete logic (src/api/server.js, public/app.js) β€” with the crop uploading only once per submission, there's nothing left to supersede
  • tests/e2e/event-image-crop.spec.js and tests/e2e/image-url-crop.spec.js updated to assert exactly one upload at pick/fetch, zero further uploads no matter how many times the crop box is adjusted, and exactly one more at Submit; tests/eventCropRoute.test.js updated for the new original-upload/crop-upload split

2.25.1 - 2026-08-31 ​

Fixed ​

  • Repeatedly adjusting the crop box no longer piles up orphaned image files on disk. Every crop-box adjustment auto-uploads under a brand new token; previously the file behind the token it replaced was never deleted, just left as an orphan β€” the daily cleanup sweep only removes orphaned uploads once they're 8 days old, so a user (or script) nudging the crop box repeatedly could accumulate files faster than they'd ever be cleaned up. The client now tells the server which upload a new one supersedes, and the server deletes it immediately

Developer ​

  • POST /api/event-request/upload-image accepts an optional previousToken field; when present, deletes that token's cropped image and preserved original (best-effort, not blocking the response) before returning the new token
  • public/app.js's uploadImageBlob() sends the current uploadedImageToken as previousToken before it's overwritten by the new upload's response
  • New tests in tests/eventCropRoute.test.js (supersession deletes both files, first upload of a session sends no previousToken, a bogus token is a harmless no-op) and a new e2e test in tests/e2e/event-image-crop.spec.js confirming a re-crop's predecessor is gone from the manifest afterward

2.25.0 - 2026-08-31 ​

Added ​

  • Pasted image URLs can now be cropped, just like uploaded files. Previously, only an uploaded image file got the crop tool β€” a pasted URL was used as-is, with no way to adjust framing. Paste a URL and click Fetch & Crop to pull it into the same crop UI a file upload uses; the fetched image is hosted the same way an upload is, so it can be re-cropped later from the moderator crop-link page too
  • "Change Image" button lets you back out of an image selection (file or URL) and pick a different one, without reloading the page or clearing the rest of the form β€” previously there was no way to change your mind once a file was picked or a URL fetched

Developer ​

  • New POST /api/event-request/fetch-image-url endpoint server-side fetches a submitter-pasted URL (reusing the same content-type/size validation resolveEventImageBuffer already applied at approval time, now extracted into shared src/utils/fetchImageUrl.js) and returns the bytes as a data: URL β€” needed because a browser can't load an arbitrary cross-origin image into a canvas-accessible <img> for Cropper.js without CORS headers from the third-party host
  • public/index.html's image section restructured into three groups (#image-picker-group, #image-url-group, #image-crop-group) that show/hide together; public/app.js gained loadImageIntoCropper() (shared by both the file-change handler and the new fetch-URL handler) and module-scoped resetImageState() (shared by the new "Change Image" button and handleSubmit's post-submit cleanup, which previously duplicated this logic inline)
  • The new endpoint shares the existing imageUploadLimiter (5 requests/5min) with /upload-image; the test-only /api/__test__/reset-rate-limit endpoint now resets both eventRequestLimiter and imageUploadLimiter, since a test run exercising several image-related tests could otherwise exhaust the real window across tests that each look independent
  • New tests/fetchImageUrl.test.js (7 tests) and tests/e2e/image-url-crop.spec.js (5 tests, using a real throwaway local HTTP server to exercise the server-side fetch end-to-end); tests/e2e/event-image-crop.spec.js updated for the restructured markup and the new "Change Image" flow

2.24.2 - 2026-08-31 ​

Changed ​

  • Removed the per-server timezone setting for the moderator Edit modal, and added a visible timezone hint to the public event-request form instead. Discord's own Scheduled Event UI has no server-timezone concept at all β€” every event is created in the creator's own local time and shown back to each viewer in theirs automatically, the same as any Discord timestamp. The per-server /eggshen-config-events event-requests timezone setting (added in 2.24.0) was solving a problem Discord's own product doesn't try to solve, so it's been removed; the moderator Edit modal's Start/End Time fields are UTC-only again (as in 2.23.0). The real gap was on the submission side: the web form already correctly converts a submitter's typed local time to UTC (via the browser's own Date parsing), it just never showed what timezone it was assuming β€” the form now displays "Times shown in your local timezone: [detected zone]" next to the time fields so submitters can confirm it's correct

Developer ​

  • src/utils/eventTimeInput.js, src/utils/eventRequestApproval.js's applyEventTimeEdits(), and src/handlers/buttonHandler.js's Edit modal fields reverted to their 2.23.0 UTC-only shape β€” removed timeZone parameters, zonedTimeToUtc()/getZonedParts(), isValidTimeZone/ALL_TIME_ZONES exports, and the buildTimeFieldLabel() variable-width-label helper (no longer needed once every label is the fixed string "Start Time (UTC)")
  • Removed the /eggshen-config-events event-requests timezone subcommand, its autocomplete handler, and guildConfig.js's eventRequests.timezone default field
  • public/index.html gained a <small id="timezone-hint"> next to the Start Time field (reusing the existing .form-group small hint styling every other field already uses); public/app.js fills it in on load via Intl.DateTimeFormat().resolvedOptions().timeZone β€” no change to combineDateTimeToISO() or the submission payload, since the conversion itself was already correct
  • New tests/e2e/timezone-hint.spec.js (Playwright) confirms the hint shows a real detected zone name, not the static placeholder
  • Test suites for the removed timezone logic reverted to their 2.23.0 scope; tests/eggshen-config-timezone.test.js deleted entirely

2.24.1 - 2026-08-31 ​

Fixed ​

  • /eggshen-config had grown too large to redeploy. Discord caps a single slash command's serialized size at 8000 bytes on the bulk command-registration endpoint this bot uses β€” /eggshen-config's 9 subcommand groups had grown to 16748 bytes (over double the limit) across many past sessions, which silently blocked every future slash-command deploy for the entire bot the moment anyone next ran the deploy script, not just this command. Split into 5 separate commands, each with real headroom under the limit: /eggshen-config (settings, stats, commands, notifications), /eggshen-config-watch-party (watch party channels, rate limiting), /eggshen-config-ai (AI image generation), /eggshen-config-moderation (whitelist, cooldowns, auto-ban), and /eggshen-config-events (event requests, including the timezone setting added in 2.24.0). Every subcommand's name, options, and behavior are unchanged β€” only the top-level command name differs for the groups that moved

Developer ​

  • src/commands/eggshen-config.js split into 5 files: the trimmed-down original plus new eggshen-config-watch-party.js, eggshen-config-ai.js, eggshen-config-moderation.js, eggshen-config-events.js β€” command auto-discovery in index.js needed no changes since commands are already keyed by their own data.name, not filename
  • All in-bot message strings, docs/**/*.md, EVENT_REQUEST_SETUP.md, PRODUCTION_TESTING.md, .env.production-testing, public/app.js, and scripts/validate-oauth-config.js updated to reference the correct new command name for each moved group
  • tests/eggshen-config-command-refs.test.js generalized to run its schema-consistency check (every /command <group> <subcommand> reference in a file's own source must name a real group/subcommand of that command) against all 5 files independently, plus a new cross-file check confirming references between the 5 commands point at real groups on the referenced command
  • Verified all 5 files serialize well under Discord's 8000-byte cap (2866–4793 bytes each) via a direct Buffer.byteLength(JSON.stringify(data.toJSON())) check

2.24.0 - 2026-08-30 ​

Added ​

  • Moderators can now configure a per-server timezone for the event-request Edit modal's Start/End Time fields, instead of always typing UTC. /eggshen-config event-requests timezone timezone:America/New_York (autocomplete-assisted, full IANA timezone database) sets the zone once; the Edit modal's Start Time/End Time labels and pre-filled values then reflect that zone automatically, and moderators simply type normal local wall-clock time (e.g. 2026-09-02 17:30 for 5:30pm Eastern) β€” daylight saving time is handled automatically via the IANA timezone database, so the same zone correctly produces a different UTC offset in summer vs. winter. Defaults to UTC for any server that never configures this, with no behavior change

Developer ​

  • src/utils/eventTimeInput.js: parseUtcTimeInput()/formatUtcForInput() now accept an optional timeZone parameter (default 'UTC', fully backward compatible), converting via the IANA tz database (Intl.DateTimeFormat's formatToParts, a "guess then correct by offset" round trip β€” no dependency, no fixed-offset math) rather than raw UTC math when a non-UTC zone is configured. New exported isValidTimeZone()/ALL_TIME_ZONES (Intl.supportedValuesOf('timeZone'), plus an explicit 'UTC' entry since the platform list doesn't include it) shared by /eggshen-config's validation and autocomplete β€” validation is exact-match against this list, not a lenient try/catch, since the latter is case-insensitive and would let a moderator save an inconsistent casing
  • applyEventTimeEdits() in src/utils/eventRequestApproval.js takes a 5th optional timeZone parameter, threaded through from index.js's edit_event_modal_ handler via a fresh loadGuildConfig() read at the orchestration layer
  • buttonHandler.js's Edit modal now loads guild config to label Start/End Time fields with the configured zone and pre-fill using the zone-aware formatter, with a new buildTimeFieldLabel() helper that respects Discord's 45-character label cap β€” falls back to dropping the ", optional" hint for the longest real IANA zone name (America/Argentina/Rio_Gallegos), with a further ellipsis-truncation safety net for any future longer zone name
  • New /eggshen-config event-requests timezone subcommand + autocomplete export in eggshen-config.js, following the existing survey.js autocomplete pattern; new timezone: 'UTC' field in guildConfig.js's defaultConfig.eventRequests

2.23.0 - 2026-08-30 ​

Added ​

  • Moderators can now correct an event request's start/end time directly in the Edit form, instead of having to approve first and then fix the schedule in Discord's own event editor afterward. Two new fields, Start Time (UTC) and End Time (UTC, optional), are pre-filled with the request's current schedule and accept a strict YYYY-MM-DD HH:mm (24-hour) format β€” always UTC, clearly labeled, since a Discord modal has no timezone-aware picker. An unparseable value, a start time in the past, or an end time before the start time is rejected with a clear explanation and nothing is saved; leaving End Time blank clears it (no end time set)

Developer ​

  • New src/utils/eventTimeInput.js: parseUtcTimeInput()/formatUtcForInput() handle the strict UTC text format round-trip, building the instant explicitly via Date.UTC() rather than relying on new Date(string)'s implicit-local-time parsing, with a round-trip check that catches Date.UTC's silent date-rollover behavior (e.g. Feb 30 β†’ Mar 2)
  • New applyEventTimeEdits() in src/utils/eventRequestApproval.js validates and applies the edited start/end time onto requestData in place (future-start-time check, end-after-start check) β€” mutates nothing on failure. Reused by index.js's edit_event_modal_ handler, which now validates time before any other field write so a rejected edit leaves the stored request completely untouched; createScheduledEventFromRequest() is unchanged since it already just consumes whatever ISO strings are on requestData
  • buttonHandler.js's Edit modal now has 5 fields total (title, description, image URL, start time, end time) β€” at Discord's per-modal ActionRowBuilder cap
  • The moderation-channel embed's πŸ“… Start Time/⏱️ End Time fields now update in place when an edit changes the schedule, including adding/removing the End Time field when it's newly set or newly cleared

2.22.4 - 2026-08-30 ​

Fixed ​

  • Approved event requests now show the actual selected channel as the event's location, instead of a hardcoded "Discord Server." Text-channel-only events (no voice channel involved) always set the Discord Scheduled Event's location field to the literal string "Discord Server," regardless of which channel a moderator/requester actually picked β€” the real channel was only ever mentioned in the event description, never in the location Discord shows at the top of the event card. The location now reflects the real channel name (e.g. "#watch-party"), falling back to the server name only if the channel can no longer be found

Developer ​

  • createScheduledEventFromRequest() in src/utils/eventRequestApproval.js now builds the External-type event's entityMetadata.location from guild.channels.cache.get(requestData.channelId)'s name instead of a hardcoded string, truncated to Discord's 100-character location limit. Voice-channel events (entityType: 2) are unaffected β€” Discord already renders those with a real, clickable channel link
  • New tests in tests/eventRequestApproval.test.js cover the real-channel-name location, the guild-name fallback when the channel is missing from cache, and truncation of an overlong channel name

2.22.3 - 2026-08-30 ​

Fixed ​

  • Setup docs told new operators to enable Discord intents the bot doesn't use, and gave an incomplete bot-permissions list. docs/installation.md and docs/api-keys.md instructed enabling the privileged Server Members and Message Content intents β€” the bot's actual gateway intents (src/index.js) are Guilds, GuildMessages, and GuildMessageReactions only, so those two were unnecessary and never should have been requested. Separately, every setup doc's bot-invite permission list was missing Manage Events (needed to create Discord Scheduled Events on event-request approval), Attach Files (needed for /image and bracket exports), and Manage Messages (needed for legacy reaction-based poll cleanup) β€” a bot invited with only the old list would hit real "Missing Permissions" errors on those features, exactly as happened on a live server this fixes the docs for

Developer ​

  • Corrected SETUP_DISCORD_BOT.md, QUICKSTART.md, docs/installation.md (which was also missing an OAuth2 invite-link step entirely β€” added one), docs/api-keys.md, EVENT_REQUEST_SETUP.md, and docs/features/event-requests.md's troubleshooting sections to list the full, accurate permission set and correct intent guidance, plus added dedicated troubleshooting entries distinguishing channel-level permission overrides (e.g. a locked-down moderation channel) from the server-wide Manage Events permission, since they fail with different Discord error codes and need different fixes

2.22.2 - 2026-08-30 ​

Changed ​

  • Removed references to the maintainer's own production domain and Discord server from the open-source repo and docs. Example URLs and a tournament-name example previously used the maintainer's real, identifiable infrastructure β€” replaced with generic placeholders (example.com, dev.example.com, "The Ultimate Horror Cup") everywhere, including the /bracket create command's own description text

2.22.1 - 2026-08-30 ​

Fixed ​

  • Submitting an event request on one domain no longer blocks a submission on another domain. A bot process serving the event-request form for more than one domain (e.g. a dev subdomain alongside production) had its rate limiter (1 request per 5 minutes) keyed on IP address alone β€” so a submission on one domain would incorrectly block an immediate follow-up submission on the other, even though they're separate websites going to separate Discord servers. The same fix applies to the channel-lookup and image-upload limiters, which had the same gap

Developer ​

  • All three express-rate-limit instances in src/api/server.js (eventRequestLimiter, channelFetchLimiter, imageUploadLimiter) now use a shared hostAndIpKeyGenerator keyed on ${req.get('host')}:${ipKeyGenerator(req.ip)} instead of the library's IP-only default
  • The test-only /api/__test__/reset-rate-limit route (used by the Playwright e2e suite) updated to reset using the same host+IP key, so it still resets the correct bucket
  • New tests/eventRequestRateLimitByDomain.test.js verifies a submission on dev.example.com doesn't rate-limit example.com, and that two submissions on the same domain are still correctly blocked (regression guard)

2.22.0 - 2026-08-29 ​

Added ​

  • /timer start no longer traps you with a bad auto-detected title. In a watch-party channel, running /timer start with no options pulls the title from that channel's linked Discord scheduled event β€” but if that title didn't cleanly match a movie/TV/board game (no results, or several ambiguous ones), there was previously no way to correct it: a no-match silently started the timer under the wrong name, and the ambiguous-results picker only offered to proceed with that same wrong name. Both cases now offer a πŸ”Ž Search button that opens a box to type the correct title and search again β€” as many times as needed β€” alongside a ▢️ Start Timer button to proceed without a duration if you'd rather not search. A label you type yourself (label:/movie:/tv:) is unaffected β€” its existing behavior is unchanged

Developer ​

  • New exported runTitleSearchAndDecide() in src/commands/timer.js, extracted from execute()'s previously-inline episode-range/generic-search/zero-results logic, now parameterized on a wasAutoDetected flag (true only when the label came from a scheduled event, never for label:/movie:/tv:) β€” gates both the new zero-results recovery screen and the extra Search button on the existing ambiguous-results picker
  • New timer_retype_${theme} button (src/handlers/buttonHandler.js) opens a modal (timer_retype_modal_${theme}) to capture a retyped title; its submission handler (new branch in src/index.js's modal-submit dispatcher, mirroring the existing timer_extend_modal_ branch) re-runs runTitleSearchAndDecide() with wasAutoDetected: true, so the recovery screen can reappear indefinitely until a search resolves or "Start Timer" is chosen
  • New timer_skip_noauto_${theme} button starts the timer with no duration from the new zero-results screen, reusing the existing fallback-duration mechanism (guildConfig.maxTimerDurationMinutes, default 360) unchanged
  • The existing picker's "⏭️ Skip - Start Timer Without Duration" select-menu option relabeled "▢️ Start Timer (No Duration)" everywhere (including the unaffected label:/movie:/tv: path), since "Skip" implied a search the user may not know happened
  • New tests: tests/timer-retype-flow.test.js, tests/timer-retype-button.test.js, tests/timer-retype-modal.test.js, tests/timer-skip-noauto-button.test.js

2.21.1 - 2026-08-29 ​

Changed ​

  • The event-request web form's Guild ID no longer lives in a file tracked by git. Previously public/app.js's GUILD_ID constant was hardcoded directly in a committed file, so every deployment's real value had to be preserved around each git pull (stash/pull/pop) or it would get clobbered by the repo's placeholder β€” and vice versa, a real edit always showed up as a dirty working tree. It now lives in public/config.js, a new gitignored file (copy public/config.example.js to create it) loaded before app.js β€” the same pattern this project already uses for .env and other server-local state. A plain git pull now never touches it

Developer ​

  • New public/config.example.js (committed template) sets window.EGG_SHEN_CONFIG.GUILD_ID; public/app.js reads window.EGG_SHEN_CONFIG?.GUILD_ID as a fallback below the existing e2eGuildId query-param override (unchanged, still takes priority β€” the Playwright e2e suite needed no changes)
  • .gitignore gained public/config.js; every "edit GUILD_ID in public/app.js" instruction across EVENT_REQUEST_SETUP.md, docs/features/event-requests.md, docs/commands/configuration.md, scripts/validate-oauth-config.js, and the /eggshen-config event-requests command's own reply messages (get-link, view, website-url) updated to reference config.js instead

2.21.0 - 2026-08-29 ​

Added ​

  • The event-request web form can now be deployed on more than one domain from a single bot process, with OAuth login working correctly and independently on each. Previously the OAuth redirect target was a single static value (OAUTH_REDIRECT_URI/FORM_URL), so a login started on a second domain would incorrectly bounce the user back to whichever domain happened to be configured β€” now the bot derives the correct redirect target from the actual domain each login started on

Changed ​

  • The "you're not a member" rejection message is clearer about why it's happening. Previously read "You must be a member of [server] to submit event requests," which a legitimate member of a different Discord server could misread as a general account problem β€” now explicitly states the page is dedicated to one specific server, so it's clear the restriction is about which site you're on, not your Discord account

Developer ​

  • GET /api/auth/discord now derives its redirect_uri from the incoming request's own Host/protocol instead of the static OAUTH_REDIRECT_URI env var, and embeds the originating domain in Discord's state param (alongside the existing guildId)
  • GET /api/auth/discord/callback decodes that domain back out of state and uses it for both the token-exchange redirect_uri (must exactly match the authorize step, per OAuth2 spec) and the post-login redirect β€” replacing the previously-static FORM_URL. Falls back to the env vars only when a request's domain genuinely can't be determined
  • Both the OAuth-callback and submission-time "not a member" messages reworded for consistency
  • EVENT_REQUEST_SETUP.md's "Multiple Servers" section rewritten β€” it previously described a ?guild=ID query-param multi-tenancy model that doesn't match this codebase's actual architecture (one hardcoded GUILD_ID per static deployment); now documents the real per-domain deployment pattern with a worked dev/prod subdomain example

2.20.2 - 2026-08-29 ​

Fixed ​

  • The moderator crop page still showed an already-cropped image after adjusting the crop box before submitting. The previous fix (2.20.1) correctly preserved the original, but only attached it to the first upload call β€” every subsequent re-crop (the debounced re-upload that fires after dragging the crop box) uploaded under a brand new token with no original attached, and that later token was the one that actually ended up in the submitted request. The original is now sent with every re-crop upload, not just the first, so whichever upload ends up being submitted always has its original correctly preserved

Developer ​

  • public/app.js's uploadImageBlob() now sends the current selection's original file on every call instead of clearing it after the first β€” renamed the tracking variable (pendingOriginalFile β†’ currentOriginalFile) to reflect that it persists for the whole file selection, not just its first upload
  • Fixed a race in the new tests/e2e/event-image-crop.spec.js original-preservation test itself (page.waitForResponse was registered after page.goto() had already resolved the request) β€” the corrected test now genuinely exercises the drag-then-submit path that reproduced this bug

2.20.1 - 2026-08-29 ​

Fixed ​

  • The moderator crop page now loads the submitter's true, uncropped original β€” not a re-crop of their already-cropped result. The submission form only ever uploaded the cropped output (used for the Discord event), so the moderator crop page had nothing else to load and was re-cropping an already-cropped image. The raw original is now preserved separately on upload (both from the submission form and when a moderator uploads a new source image on the crop page) and is what the crop page pre-loads, while the Discord event still uses the cropped result as before

Developer ​

  • New saveOriginalImage()/getOriginalImagePath() in eventImageStore.js, storing the original under a -original suffixed sibling key so it's tracked, renamed, and retained/pruned on the same lifecycle as the cropped copy (renameImageKey() and recordEventDate() now propagate to the original entry too)
  • POST /api/event-request/upload-image and POST /crop/:requestId/save now accept two multipart fields (image for the cropped result, optional original for the raw source) via upload.fields() instead of upload.single() β€” also caught and fixed a latent multer limits.files cap of 1 that would have rejected any two-file upload
  • GET /crop/:requestId/current-image now prefers the preserved original, falling back to the cropped copy for older/mod-added images with no separate original on file

2.20.0 - 2026-08-29 ​

Fixed ​

  • Uploaded event images now actually attach to the created Discord event. The previous release's image-upload feature worked correctly in every isolated test, but nginx's default 1MB request body limit was silently rejecting real-sized image uploads before they ever reached the bot β€” confirmed and fixed on the production deployment (nginx's client_max_body_size raised to 10MB for the API route), with the requirement documented so other deployments don't hit the same silent failure. The upload form now also shows a clear message instead of a confusing raw error if a proxy rejects an upload before the bot's own response can come back

Added ​

  • Uploaded event images can now be cropped. Selecting a file on the web form shows an in-browser crop tool, pre-framed to Discord's 16:9 event-cover shape β€” submit right away or drag to adjust first. Applies to uploaded files only (pasted image URLs submit as before)
  • Moderators can crop or replace an event's image directly from Discord. Each request in the moderation channel now has a "πŸ–ΌοΈ Crop Image" link button β€” no login needed, the link itself (signed, single-use, expires in ~30 minutes) is what authorizes access. Opens a small page to adjust the submitter's image or upload and crop a different one, including adding an image to a request that didn't have one
  • The moderation-channel embed now shows an πŸ–ΌοΈ Image status field (βœ… Uploaded / πŸ”— Linked (URL) / ❌ None) so moderators can see at a glance whether a request has an image before approving
  • The web form's Event Title field now hints at including the release year in parentheses (e.g. "Tragedy Girls (2017)"), which helps the bot's title-matching correctly identify the exact movie, show, or game later on

Developer ​

  • New src/utils/cropLinkToken.js β€” dependency-free HMAC-SHA256 signed tokens (via Node's built-in crypto, using crypto.timingSafeEqual for comparison) scoping the moderator crop page to one request, with in-memory single-use tracking
  • Three new routes in server.js: GET /crop/:requestId (the crop page), GET /crop/:requestId/current-image (streams the existing image for pre-loading), POST /crop/:requestId/save (saves the cropped result) β€” gated by the signed token rather than the public per-IP rate limiter, and served from a newly-added but narrowly-scoped /crop-assets static mount (public/crop/) rather than exposing all of public/ from the bot's own origin
  • New required env vars for this feature: EVENT_CROP_LINK_SECRET (signing key) and PUBLIC_BOT_URL (the bot API's own externally-reachable origin, distinct from the separately-hosted form's FORM_URL) β€” both optional; the "Crop Image" button is simply omitted if unset
  • Fixed two latent gotchas in eventImageStore.js's overwrite path while building the crop-save route: replacing an image under a different file extension no longer leaves the old file orphaned on disk (deleteImage() before saveUploadedImage()), and recordEventDate() is now re-called after an overwrite so a freshly-cropped image doesn't lose its retention-sweep eligibility
  • Cropper.js v1 (CDN, no new dependency) powers both the submission-form and moderator crop UIs; the submission form uploads on the initial auto-crop and again (debounced ~800ms) on further adjustment, refactored into a shared uploadImageBlob() helper in app.js

2.19.0 - 2026-08-28 ​

Added ​

  • Event requests can now include a cover image. On the web form, submitters can upload an image file (PNG, JPEG, GIF, or WebP, up to 8MB) or paste an image URL β€” uploading happens immediately on file selection, before the rest of the form is submitted, so problems surface right away. Whichever image is provided becomes the created Discord Scheduled Event's cover image
  • Moderators can set or override the event image when approving a request. The existing Edit-before-approve modal gained an optional Image URL field β€” entering a URL there always wins over whatever the submitter provided (upload or URL), letting moderators add an image to a request that had none, or replace one they don't like. Leaving it blank never clears an existing image; it's an override, not a "remove image" control
  • Uploaded event images are automatically deleted ~90 days after their linked event's date has passed (or sooner if the request is never approved), so storage doesn't grow unbounded

Developer ​

  • New src/utils/eventImageStore.js β€” local-disk image storage with a JSON manifest tracking each stored image's linked event date, following this codebase's existing flat-file persistence convention (no database). Images are uploaded under a client-generated placeholder token before a request exists, then renamed to the real request ID once submission succeeds
  • New POST /api/event-request/upload-image endpoint in server.js, backed by multer (new dependency) with in-memory buffering, an 8MB/1-file limit, and a PNG/JPEG/GIF/WebP mimetype allowlist; rate-limited to 5 uploads per 5 minutes per IP
  • POST /api/event-request gained optional imageToken/imageUrl fields, mutually exclusive (an uploaded file wins if both are somehow present)
  • New resolveEventImageBuffer() in eventRequestApproval.js resolves the final image (mod-set URL > uploaded file > user-submitted URL > none) into a Buffer before event creation β€” required because scheduledEvents.create()'s image option accepts a Buffer/base64 data URI, not a raw URL
  • New src/utils/eventImageCleanupScheduler.js β€” a daily (not per-minute, unlike the other schedulers) sweep pruning expired and orphaned uploaded images, wired into index.js's existing scheduler init/shutdown lifecycle

2.18.2 - 2026-08-27 ​

Added ​

  • /timer start gained movie and tv options as an alternative to label β€” an explicit way to say "this is a movie" or "this is a TV show" instead of typing free text into label and letting the bot search movies, TV, and board games all at once. This skips the ambiguous merged search entirely (useful when a movie and a TV show happen to share a name) and makes the decisive-match auto-selection more reliable, since a single-type search doesn't need to wait on the other types coming back empty first. tv also accepts episode-range notation (e.g. tv:"Tales from the Crypt S5E5-E8"), resolved exactly the same way typing that into label already works. Only one of label/movie/tv may be used at a time β€” providing more than one is rejected with a clear error. label itself is completely unchanged

Developer ​

  • New explicit-type branch in timer.js's start handler, inserted before the existing channel-auto-detect/episode-range/general-search blocks and gated to run only when movie/tv was provided (those blocks are otherwise untouched, since they only ever fire when label is set β€” which stays empty when movie/tv is used instead)
  • Reuses hybridSearch, pickLandslideWinner, parseEpisodeRange, and resolveEpisodeRangeDuration as-is; the resulting picker option values (timer_movie_<id>_<theme>, timer_tv_<id>_<theme>, and the range-suffixed shape) match what selectHandler.js's existing timer_select_runtime dispatch already understands, so no handler changes were needed

2.18.1 - 2026-08-25 ​

Added ​

  • New /eggshen-config settings timer-control setting lets a server allow any member to pause, resume, or stop a timer, instead of only the timer's starter plus admins/mods (still the default). Useful for servers where the person who started a watch party timer isn't always around to react β€” pausing for a break or stopping when something goes wrong shouldn't have to wait on one specific person. /timer adjust and /timer autostop are unaffected by this setting and always stay restricted to the starter or an admin/mod, since changing a timer's duration or auto-stop configuration is a more consequential action than just pausing/stopping it

Developer ​

  • Added canControlTimerPauseStop(timer, userId, member, guildConfig) to timerManager.js β€” a single shared permission check now used by /timer stop/pause/resume, replacing three (of five) previously-duplicated inline isAdmin-or-starter checks in timer.js. adjust/autostop keep their existing inline checks unchanged, deliberately not routed through the new helper
  • Added allowAnyonePauseStopTimer: false to guildConfig.js's defaultConfig

2.18.0 - 2026-08-25 ​

Added ​

  • /movie, /tv, and /episode now skip the "which one did you mean" picker when a search result is a decisive, clear-cut match, using the AI semantic-ranking score the bot already computed internally but never acted on. Ambiguous or close results (e.g. a movie and its same-named remake) still show the picker as before β€” this only kicks in when one result is unmistakably the best match
  • The picker now cross-checks IMDb's release year against TMDB's for the first few results, showing both when they disagree (e.g. It (1990 (IMDb: 1991))) β€” addresses the "the year doesn't match what I remember from IMDb" confusion, without adding the cost of checking every row (capped at the top 5 candidates)
  • /episode's show-name lookup now uses the same AI-powered ranking /movie and /tv already had (previously a plain keyword search with no semantic ranking or alternate-title matching at all)
  • /timer start now understands multi-episode watch parties. A label like "Tales from the Crypt - S5: E5 - E8" is recognized as one watch party spanning episodes 5 through 8 of season 5 β€” the bot resolves the show, sums each episode's actual runtime, adds the usual 10-minute buffer, and shows a full breakdown (not just a final number) so you can sanity-check it before the timer starts. Supports S5E5-E8, S5: E5 - E8, S05E05-E08, Season 5 Episode 5-8, Season 5, Episodes 5-8, and single-episode notation (S3E1, 3x11)
  • /timer start's general movie/TV auto-detection also gets the decisive-match auto-selection described above β€” a clear single-type winner (with nothing competitive in the other search types) starts the timer directly instead of always showing a picker for 2+ combined results

Developer ​

  • Added pickLandslideWinner(results) to aiService.js β€” reads the semanticScore hybridSearch's re-ranking already attaches to every result but that nothing previously consumed. Requires both a high absolute score and a clear lead over the runner-up (launch constants, logged on every decision for future tuning from real usage)
  • Added src/utils/episodeRangeParser.js (parseEpisodeRange) β€” a new, self-contained parser that scans a free-text label for embedded season/episode-range notation and returns both the structured range and the show name with the notation stripped out. Distinct from episode.js's existing parseSeasonEpisode (anchored, single-episode-only, built for a dedicated episode field)
  • Added sumEpisodeRuntimes() to tmdbService.js, consuming getSeasonDetails()'s existing one-call-per-season episode list. Falls back to the show's average episode runtime for any episode TMDB lacks a specific runtime for, flagging it estimated rather than silently undercounting
  • Added attachImdbYearCrossCheck() to embedBuilder.js, called from the picker-rendering path only (never on the auto-select fast path, which renders no picker rows)
  • /timer start's general auto-detection now runs its movie/TV sub-searches through hybridSearch() (previously plain searchMovies/searchTVShows) and checks each type independently for a landslide winner β€” never comparing a movie's semantic score to a TV show's, sidestepping a cross-type ranking-comparison problem
  • The timer_select_runtime picker's option-value encoding gained an optional _range_<season>_<epStart>_<epEnd> suffix for range-picker selections, extending (not replacing) the existing timer_<type>_<id>_<theme> shape β€” mirrors the timer_extend_<channelId> precedent of embedding round-trip state directly in the customId/value string

2.17.3 - 2026-08-24 ​

Fixed ​

  • A timer with no set/detected duration no longer shows a fake-looking "Total Duration" in /timer status or the start confirmation. Since v2.17.2, a timer with nothing typed and nothing auto-detected got a real internal 6-hour fallback duration (so it could still auto-stop and warn) β€” but that number was also being displayed as if it were a genuine detected or user-set duration, which it wasn't. The fallback still auto-stops and still warns an hour before, exactly as before; it's just invisible in /timer status/the start embed now, matching how a truly durationless timer used to look. A real duration (typed manually, auto-detected from a movie/TV/board game runtime, or set via /timer adjust) still displays normally

2.17.2 - 2026-08-20 ​

Changed ​

  • Timers no longer have a default duration cap. In v2.17.1, /eggshen-config settings max-timer-duration clamped every timer duration β€” explicit, auto-detected, or otherwise β€” down to 6 hours by default. That wasn't the intent: a deliberately long timer (an explicit duration:500, or an auto-detected 8-hour concert film) should run for its real length. Timers with a real duration now always run exactly as long as requested, with no maximum, unless a server explicitly opts into one
  • max-timer-duration now only controls the fallback duration used when a timer has no real duration at all β€” no duration typed, no label matched anything, or "Skip" was chosen from the title-selection menu. That fallback still defaults to 6 hours and is still server-configurable (minutes:<n> / unlimited:true), and the expiry warning + "Extend Timer" button still only ever fires for these fallback-duration timers, never for a timer with a real duration
  • New /eggshen-config settings timer-ceiling lets a server optionally cap real (explicit/detected) durations too, for those who want the old always-clamp behavior back. Off by default β€” enabled:true minutes:<n> turns it on

Developer ​

  • timerManager.js's clampTimerDuration reworked: previously always clamped using maxTimerDurationMinutes/maxTimerDurationUnlimited; now only clamps when guildConfig.timerCeilingEnabled is true, against timerCeilingMinutes. maxTimerDurationMinutes/maxTimerDurationUnlimited keep their names but now mean "fallback duration" / "no fallback" rather than "cap" / "no cap"
  • startTimer() gained an isFallbackDuration flag, set whenever startTimerCountdown() (in timer.js) had to substitute the server's fallback duration because no real duration existed. Threaded through adjustTimerDuration() too β€” clearing the flag on any explicit adjust/extend, since that's now a real, informed value
  • timerScheduler.js's expiry-warning sweep now skips any timer where isFallbackDuration isn't true, so a long real duration never triggers the warning
  • selectHandler.js's timer_select_runtime handler now loads guild config and passes it into startTimerCountdown(), so the "Skip" and no-runtime-found paths get the same fallback-duration treatment as /timer start's own zero-results case

2.17.1 - 2026-08-20 ​

Added ​

  • Timers now have a default 6-hour (360 min) safety cap, configurable per server. Previously a timer with no duration set (or a very long one) could keep running indefinitely if nobody noticed β€” this is what happened when a timer started on a Saturday was still running the following Tuesday. Applies to /timer start, /timer adjust, and /timer autostop enable; a requested duration over the cap is automatically reduced, with the reply noting it was capped
  • A warning now posts in-channel ~1 hour before a timer with a duration is about to auto-stop, mentioning whoever started it, with an "Extend Timer" button. Clicking it opens a short form to add more minutes β€” handy for watch parties that run long
  • New /eggshen-config settings max-timer-duration lets administrators/moderators raise, lower, or fully disable the cap for their server (minutes:<1-1440> and/or unlimited:true) β€” useful for servers that regularly run overnight or marathon sessions
  • /timer start's runtime auto-detection now tells you explicitly when it finds nothing, instead of silently starting a durationless timer with no indication anything was searched β€” this silent case was the actual root cause of the stuck-timer incident above

Developer ​

  • Added clampTimerDuration(durationMinutes, guildConfig) to timerManager.js β€” a single pure function used by every duration-setting call site (start, adjust, autostop enable, and the new extend-modal handler) so the unlimited override only needs to be checked in one place
  • Added src/utils/timerScheduler.js, a 1-minute interval sweep for timers approaching their auto-stop time, following the same init/shutdown pattern as tournamentScheduler.js/pollScheduler.js, with its own sentWarnings Map (keyed by channel, cleared on extend) to avoid re-warning every tick
  • Added a timer_extend_ button branch to buttonHandler.js (opens a modal for additional minutes) and a timer_extend_modal_ branch to index.js's modal-submission dispatcher β€” same starter-or-admin/mod permission check already used by /timer stop//timer adjust
  • Raised /timer start//timer adjust//timer autostop's Discord-side option max from 600 to 1440 minutes β€” the real enforcement now happens server-side via clampTimerDuration against the guild's configured cap, so a server can configure any cap up to 24h (or go unlimited) without a schema change

2.17.0 - 2026-07-21 ​

Added ​

  • New /announce command generates AI-written watch party announcement text for moderators/admins to copy and post themselves. Takes one or two titles (movies or TV episode ranges, with flexible notation like S3E9-E12, Season 3 Episode 9, etc.), a start time, and an optional host and tone (Funny/Scary/Dramatic/Wholesome/Mysterious, or a free-text custom-tone). Looks up each title's real plot and streaming availability from TMDB so the generated flavor text is actually about the movie/show, not generic filler. The reply is private and formatted as a copy-paste-ready code block β€” the bot never posts the announcement anywhere on its own, and nothing about it is configurable or persisted; it's a one-shot text generator

Developer ​

  • Added generateAnnouncementText() to src/services/aiService.js β€” the bot's first chat-completion call (gpt-4o-mini), separate from the existing embedding-only OpenAI usage
  • Exported normalizeProviders from embedBuilder.js so /announce can reuse the same streaming-provider-name cleanup /movie//tv already use, instead of duplicating it

2.16.1 - 2026-07-16 ​

Changed ​

  • /timer status and /timer check now default to private, only visible to whoever ran the command. Most people check the timer just to glance at their own progress, and showing that to the whole channel every time flooded it with messages nobody else needed to see. Add public:true to announce the current status to the whole channel instead β€” the opposite default from the search commands, chosen deliberately for this specific command's actual usage pattern

2.16.0 - 2026-07-15 ​

Changed ​

  • Search results are now visible to the whole channel by default. /movie, /tv, /episode, /episode-list, /game, /boardgame, /book, /soundtrack, /watched add, and /image all used to defer their reply as private (ephemeral) and never made it public again β€” even the final answer, not just the "which one did you mean" picker. Since these are shared lookup commands meant for the whole channel to see, results now post publicly by default. Add private:true to any of these commands to keep a result to yourself, exactly like before

Developer ​

  • Added src/utils/interactionResponse.js (deliverResult, encodePrivateFlag/decodePrivateFlag) as the single shared mechanism for "post this result publicly, unless the user asked to keep it private" β€” used by every affected command's single-result path and by selectHandler.js's picker-selection branches, so the same logic doesn't get duplicated (and drift) across 10+ call sites
  • The multi-result picker step always stays ephemeral regardless of the private flag (no channel clutter while narrowing down results) β€” the flag is encoded into the picker's select-menu option values so it survives to the later selection interaction, which decides the final result's visibility
  • Fixed /episode-list's picker-selection path, which β€” unlike the equivalent flow in /movie, /tv, /game, etc. β€” never posted publicly at all, even before this change

2.15.2 - 2026-07-11 ​

Changed ​

  • Survey results now visually highlight the leading option. A sole leader gets a πŸ† crown, bold text, and a solid progress bar, while every other option uses a dimmer shaded bar (β–“ instead of β–ˆ) β€” since Discord embed text can't be colored, this gives the leader real visual weight using only text/Unicode. No crown is shown when the top spots are tied or before any votes are cast

2.15.1 - 2026-07-11 ​

Changed ​

  • New surveys now use buttons to vote instead of emoji reactions. With reactions, the number badge under each option always showed one more than the real vote count, since Discord counts the bot's own setup reaction alongside everyone else's β€” the embed's own "Total votes" text was always correct, but the badge looked wrong at a glance. Buttons don't have this artifact: clicking one records your vote, updates the survey message live, and sends you a private confirmation. Surveys already in progress when this ships keep working exactly as before (reactions); only newly created surveys get buttons

Developer ​

  • Added poll.votingMethod ('buttons' for every poll going forward; absent/'reactions' on anything created before this change) so the reaction handlers, closePollAndAnnounce, and the embed footer text all know which UI a given poll uses
  • Added castSingleVote/toggleVote to pollManager.js (single-select overwrite vs. multi-select toggle-on/off) and buildSurveyButtons (chunks up to 10 options into 2 rows of 5, matching Discord's per-row button limit)
  • Added a survey_vote_ branch to src/handlers/buttonHandler.js's dispatcher and a handleSurveyVote function, mirroring the existing tiebreaker-voting button pattern (handleTiebreakerVote) already used elsewhere in this bot
  • closePollAndAnnounce now disables buttons (rather than calling reactions.removeAll()) when closing a button-based poll, keeping the same "closed but still shows what the options were" convention tiebreaker voting already uses

2.15.0 - 2026-07-11 ​

Added ​

  • Survey messages now update live as votes come in, showing current vote counts and percentages without anyone needing to run /survey results. Applies whether a vote is added or changed
  • /survey create now takes an optional duration (in minutes, up to 14 days) to auto-close voting after a set time. When it expires, the survey closes and posts results automatically β€” the exact same close flow /survey close already uses β€” with no need for a moderator to close it manually. Surveys created without a duration keep working exactly as before (open until manually closed). The embed shows a "Voting Ends" countdown for any survey with a duration set
  • /survey results, /survey close, and /survey delete now autocomplete the poll_id parameter β€” start typing a survey's question and Discord suggests matching surveys with their live vote counts, instead of needing to copy an ID from /survey list. close/delete only ever suggest surveys you're actually allowed to manage (your own, or any if you're an admin/mod), so picking a suggestion never leads to a permission error

Developer ​

  • Moved createPollEmbed from survey.js into pollManager.js so it can be shared by the command, the reaction handlers, and the new auto-expiry scheduler without duplicating rendering logic (still re-exported from survey.js for backward compatibility)
  • Extracted the close-poll-and-post-results flow (previously inline in /survey close) into a shared closePollAndAnnounce() in pollManager.js, used by both /survey close and the new pollScheduler.js, so a survey closes identically whether a moderator closes it or it expires on its own
  • Added src/utils/pollScheduler.js, a 1-minute interval sweep for expired surveys, following the same init/shutdown pattern as the existing tournamentScheduler.js
  • Added the bot's first slash-command autocomplete handler (isAutocomplete() dispatch in src/index.js, autocomplete() export in survey.js)

2.14.1 - 2026-07-11 ​

Fixed ​

  • /survey votes could silently fail to register. The bot was missing the GuildMessages gateway intent and message/reaction partials it needs to resolve a reaction on a survey message it hasn't personally sent or fetched during the current run β€” in practice, this meant reacting to any survey created before the bot's last restart (or any survey message that aged out of its short-lived cache) did nothing at all, with no error shown to the voter. The bot now requests GuildMessages and registers the required partials, with the message cache capped at 50 entries (rather than accepting the default, larger per-channel cache) since message content itself is never read
  • Closing a survey no longer marks it closed before confirming the original message still exists. If the survey message had been deleted, /survey close used to close the poll in storage first and only then fail trying to update a message that was gone β€” leaving the poll permanently closed with a misleading "failed, please try again" error and no results ever posted. The message is now fetched first, before anything is persisted

Developer ​

  • Added tests/discord-client-config.test.js asserting the bot's gateway intents, partials, and message-cache cap directly against a real discord.js Client, to catch a regression if this config drifts back to the broken state

2.14.0 - 2026-07-11 ​

Added ​

  • /movie and /tv now find titles TMDB stores under an original or foreign name, even when the query is the far more recognizable title it was actually distributed/reissued under. For example, the 1978 film Day of the Woman β€” TMDB's title of record β€” is what's actually known and distributed as I Spit on Your Grave; searching for that name previously buried or missed the correct result entirely. Search now checks TMDB's alternate-title (AKA) data when the top keyword result doesn't look like a good match for the query, and promotes the correct result if one of its AKAs matches. Also applies to /soundtrack and adding a title to a /bracket
  • Result screens now show an "Also Known As" field when the primary TMDB title differs from a well-known alternate title, so it's clear you found the right movie/show even if the on-screen title looks unfamiliar

2.13.0 - 2026-07-10 ​

Added ​

  • Approving or denying an event request now posts a new announcement message to the moderation channel, in addition to updating the original request's embed in place. Silently editing an old message was easy for other moderators to miss β€” the new message shows who approved/denied it, a link to the created event (if approved), and the reason (if denied), so the whole team can see what happened without noticing an old message changed. Applies to every approval path: the Approve buttons, picking a channel after Approve, and saving an edit
  • New /eggshen-config event-requests announce-decisions enabled:<true/false> setting to turn the above off if you'd rather only the original request message update, with no separate announcement (on by default)

Developer ​

  • Consolidated a third, previously-untouched copy of the event-creation logic (the "pick a channel, then create" flow) to use the same shared eventRequestApproval.js module the other two approval paths already use, rather than adding a third inline duplicate

2.12.0 - 2026-07-10 ​

Changed ​

  • Saving an edited event request now immediately approves it, instead of just updating the title/description and leaving it pending for a separate Approve click. If a text channel is already known, the event is created right away (using a voice channel too if one was requested); if not, saving shows the same channel-selection step Approve already uses, so the moderator picks a channel and the event is created from there. To review an edit without approving, deny the request and ask the submitter to resubmit instead

Developer ​

  • Extracted the event-creation logic (guild.scheduledEvents.create() + approved-embed building + request cleanup) that previously lived only inside the Approve button handler into a shared src/utils/eventRequestApproval.js module, so the edit-save auto-approve path could reuse it instead of duplicating it a third time

2.11.0 - 2026-07-10 ​

Added ​

  • /eggshen-help is now guild-aware. It only lists commands actually enabled on the server it's run in β€” if /game is disabled via /eggshen-config commands toggle, it no longer appears in the help list, and if AI image generation is disabled for the server, /image disappears from the "AI Image Generation" category (while /potion stays, since it isn't affected by that setting). A category is only omitted entirely once every command in it is disabled; otherwise it stays with just the remaining enabled commands

Fixed ​

  • /eggshen-config's "Bracket Command" toggle did nothing. commandPermissions.bracket existed in the config and was toggleable through /eggshen-config commands toggle, but /bracket never actually checked it β€” disabling it for regular users had zero effect. /bracket now respects the toggle like every other gated command, including its read-only subcommands (not just the admin/moderator-only management ones)
  • Removed an unused, misleading import in similar.js (canUseCommand, imported but never called β€” /similar has never had a corresponding toggle to check)
  • docs/commands/configuration.md's list of /eggshen-config commands toggle settings was missing game, boardgame, book, soundtrack, survey, and bracket β€” only movie/tv/episode were documented even though all nine have worked (or now work, for bracket) since earlier changes this release cycle

2.10.0 - 2026-07-10 ​

Added ​

  • /timer pause and /timer resume. Pause a timer if something comes up β€” a break, a technical issue β€” without losing elapsed time or restarting from scratch. Pausing freezes elapsed/remaining time and cancels the pending auto-stop; resuming picks up exactly where it left off, and time spent paused never counts against the timer's set duration. A paused timer survives a bot restart and stays paused until explicitly resumed. /timer adjust now requires resuming first if the timer is currently paused

Developer ​

  • Deduplicated the auto-stop scheduling/notification logic in timerManager.js, which was previously copy-pasted across startTimer, restoreTimerTimeouts, and adjustTimerDuration, into shared helpers β€” fixed a latent inconsistency along the way where a timer that expired while the bot was offline was auto-logged to watch history without its starter's user ID (all other auto-stop paths already passed it correctly)

2.9.0 - 2026-07-10 ​

Fixed ​

  • /timer start label:<text> didn't trigger runtime auto-detection. The search-and-confirm flow (movie/TV lookup, the "Confirm Title" selection menu, auto-filling duration from runtime) only ran when the label was auto-detected from a Discord scheduled event β€” a manually-typed label skipped it entirely and the timer just started with no duration. It now runs the same way regardless of whether the label was typed or auto-detected

Added ​

  • Board games now participate in /timer runtime auto-detection, alongside movies and TV shows. BoardGameGeek's listed playing time is used the same way TMDB's runtime/episode length already was (+ a 10-minute buffer). Each source is capped at 8 results (down from 10) so the combined selection menu never exceeds Discord's 25-option limit even with three sources contributing

2.8.0 - 2026-07-10 ​

Added ​

  • Edit event requests before approval. A new ✏️ Edit button on the moderation-channel embed opens a form pre-filled with the submitter's title and description, letting a moderator correct either before creating the event. The moderation embed updates immediately, and the eventually-created event uses the edited values. Schedule (start/end time) editing was intentionally left out β€” Discord's own event editor (linked from the approval confirmation) already has a real, timezone-aware date/time picker, so there's no need to rebuild that in a text form
  • Deny with a reason. Clicking ❌ Deny now opens a form for an optional reason. Whatever's entered is shown on the moderation embed for other moderators and sent as a DM to the person who submitted the request, so they're not left wondering what happened. A closed-DMs submitter doesn't block the denial β€” it completes either way, with a note if the notification couldn't be delivered

2.7.2 - 2026-07-10 ​

Fixed ​

  • Selecting a channel while approving an event request always resulted in "This interaction failed." The text/voice channel pickers in that flow use discord.js's ChannelSelectMenuBuilder, a different component type from the StringSelectMenuBuilder used everywhere else in the bot. The main interaction dispatcher only checked interaction.isStringSelectMenu() before routing to the select-menu handler, so a channel-select interaction matched no branch at all and was silently dropped β€” Discord then showed its generic failure message after a few seconds with nothing logged, since the handler never ran. The dispatcher now also checks interaction.isChannelSelectMenu()

2.7.1 - 2026-07-10 ​

Fixed ​

  • Event request approval could fail with "Please select a text channel before creating the event" even when a channel was clearly selected in the UI. The moderator's channel selection (global.eventChannelSelections) was kept only in memory with no disk persistence, unlike the underlying event request itself. If the bot restarted between selecting a channel and clicking "Create Event" β€” a redeploy, a PM2 restart, a crash β€” the selection silently vanished while the request survived, and the resulting error message pointed at the wrong cause. Channel selections are now persisted to disk and restored on startup, the same way pending event requests already are, and the error message now explains what actually happened when a selection genuinely can't be found
  • A cancelled channel-selection flow (Cancel button) now also cleans up its in-memory/on-disk selection state, instead of leaving it around indefinitely

2.7.0 - 2026-07-09 ​

Fixed ​

  • Guild-specific command permission toggles were silently ignored for /game, /boardgame, /book, /soundtrack, and /bracket β€” canUseCommand() only recognized movie/tv/episode/survey and fell through to "allowed" for everything else, regardless of what an admin had configured. updateCommandPermission() had the same hardcoded-list problem, so toggling /soundtrack or /bracket via /eggshen-config failed outright with "Failed to update command permissions." Both functions now check against the config's own keys instead of a hardcoded list, so newly-added command types stay covered automatically
  • /eggshen-config commands toggle was missing Game/Board Game/Book Command as selectable options in its dropdown, compounding the bug above
  • A more severe latent bug in loadGuildConfig(): its no-saved-config-yet fallback returned a shallow copy ({ ...defaultConfig }), which shares every nested object (commandPermissions, services, rateLimits, moderation, etc.) by reference across every guild that hasn't saved a config yet. The first update*()/toggle*() call for any not-yet-configured guild would mutate the shared default in memory, silently changing the "default" seen by every other guild for the life of the process. Fixed with a real deep clone
  • aiImageTracker.js had the same missing-await bug across canGenerateImage(), recordImageGeneration(), getGuildImageStats(), and getUserImageStats() β€” all four called the async loadGuildConfig() without awaiting it, so guild-specific AI image settings (enabled/disabled, permission level, cooldowns, daily limits, whitelist, cost-per-image) were always ignored in favor of hardcoded defaults
  • /survey's poll embed builder had a dead await-less loadGuildConfig() call whose result (guild emoji config) was never actually used β€” poll option emoji always came from a fixed list regardless. Removed the dead code
  • Ephemeral error messages after /watchparty remind and /timer remind's public defer were posted publicly instead of privately β€” editReply({ ephemeral: true }) can't change an already-deferred public reply's visibility, so "no scheduled event found" and generic error messages were visible to the whole channel. Both now delete the public placeholder and re-send the error via an ephemeral followUp()
  • /timer stop, /timer adjust, and /timer autostop had no ownership check β€” any user could stop, retime, or change auto-stop settings on a timer someone else started. Now requires being the timer's starter or an admin/moderator, matching the existing check on the "Log to Watch History" button
  • /random game and /random boardgame showed a generic "An error occurred" message when no results matched the filters, instead of the friendly "try adjusting your filters" message /random book already gave β€” both discovery services throw rather than return null on empty results, and neither call site had a catch for it
  • /image silently dropped the prompt option when combined with message β€” generateFromMessage() never accepted or used a supplementary prompt, unlike the title1/title2 and matchup modes, which both append extra detail via "Additional details: ...". Combining message + prompt now works the same way
  • A bad/nonexistent message ID passed to /image message: leaked a raw Discord.js error (❌ Failed to generate AI image: Unknown Message) instead of a clear "could not find that message" reply
  • The "Log to Watch History" button was fully implemented but never wired up β€” handleWatchHistoryButton() existed and was exported but the button dispatcher never routed log_watched_* custom IDs to it, so clicking the button after /timer stop (when no label was set) always failed silently with Discord's generic "This interaction failed"
  • Corrected ~19 in-bot messages in /eggshen-config that referenced pre-reorganization command syntax (e.g. /eggshen-config rate-limit-toggle β†’ /eggshen-config rate-limit toggle, /eggshen-config whitelist-add-role β†’ /eggshen-config moderation whitelist-add-role) β€” these were missing their subcommand-group prefix from when the command was reorganized into groups
  • Corrected stale references in /eggshen-help: removed a mention of /timer help (doesn't exist), fixed /potion's description (flavor-text responses, not image generation), fixed /watchparty's description (announces an existing scheduled event, doesn't schedule one), and fixed /random's filter list (decade, not year) and subcommand list (now mentions all six: movie/TV/episode/game/board game/book)

Developer ​

  • 9 new test files covering every fix above (permission gating, the shared-reference config bug, the aiImageTracker await bug, ephemeral error handling, timer ownership checks, random.js fallback messaging, image.js prompt combining and error handling, and a structural test that validates every /eggshen-config command reference in the file against its real schema)

2.6.0 - 2026-07-09 ​

Added ​

  • /bracket resize, /bracket edit-name, /bracket list-groups, /bracket regenerate β€” re-enabled after being silently disabled due to Discord's 25-subcommand limit. These were never removed features; the docs described them as live the whole time. /bracket now sits at 22/25 subcommands

Changed ​

  • AI image generation consolidated into one command. /image, /versus-image, and /bracket image overlapped significantly (/bracket image's title-search mode was a near-duplicate of /versus-image). All three are now just /image, with four modes: freeform prompt, generate from a Discord message, a title1/title2 versus battle (with smart search across movies/TV/games/board games/books), and a tournament-matchup-aware versus battle. /versus-image no longer exists as a separate command
  • Removed a hardcoded production domain from the default CORS allow-list and from several example/config files, replacing them with generic placeholders β€” no behavior change for deployments that already set ALLOWED_ORIGINS (which production deployments should always do)
  • delete-guild-commands.js now requires GUILD_ID to be set rather than silently falling back to a hardcoded guild ID

Removed ​

  • /bracket open-knockout, /bracket close-knockout, /bracket open-region β€” these were dead code left over from before the v2.0.0 smart-command consolidation: verified line-for-line duplicates of /bracket open//bracket close//bracket open-matchup, and already listed as removed in this changelog. open-region additionally still had stale 2-region math from before the bracket moved to 4 regions

Fixed ​

  • resolve-tiebreaker was a duplicate, unreachable case in /bracket's command dispatch (dead code, no behavior impact, but confusing)
  • 8 in-bot messages referenced removed/renamed commands (/bracket add-title, /bracket vote-group, /bracket open-knockout, /bracket remove-title) left over from before the v2.0.0 smart-command consolidation β€” all updated to the current command names, or to describe button-based voting where no command exists anymore

Developer ​

  • src/commands/bracket.js reduced from 3,879 to 3,258 lines; removed several imports that were only used by the deleted code
  • 21 new command-layer tests covering the four re-enabled subcommands

2.5.0 - 2026-07-09 ​

Added ​

  • Tiebreaker Button Voting (2026-07-08)
    • When a group or knockout tiebreaker is created, the bot now posts a dedicated voting embed with clickable buttons for each tied option
    • Members click a button to cast their tiebreaker vote β€” no command needed
    • Live vote counts update in real time (progress bars show current standings)
    • Users receive a private ephemeral confirmation after voting and can change their vote any time before the deadline
    • Tiebreaker ID is always visible in the embed footer for admin reference

Improved ​

  • /bracket resolve-tiebreaker β€” winner parameter is now optional
    • With winner: Admin manually picks the winning option (unchanged behavior)
    • Without winner: Tallies the current tiebreaker votes and resolves immediately β€” useful for ending voting early once a clear majority exists
    • On resolution, the voting embed is automatically disabled and stamped with the result and vote breakdown
  • Tiebreaker auto-resolution β€” The tournament scheduler now checks tiebreaker deadlines every minute. When a tiebreaker expires it is automatically resolved by vote count (random fallback if no votes were cast), the embed is updated to show the winner, and a results notification is posted

Fixed ​

  • Multiple simultaneous group ties β€” Fixed a bug where closing multiple groups at once (e.g., /bracket close-groups groups:A,B,C,D) could silently discard groupResults for earlier groups when later groups also had ties. All group results are now preserved correctly regardless of how many tiebreakers are created in a single call
  • Scheduler loop re-processing already-tied groups β€” The tournament scheduler could repeatedly re-detect the same tie and create a new tiebreaker roughly every minute for a group already awaiting one, instead of waiting for it to resolve. Left unattended this created dozens of duplicate tiebreakers and Discord messages for the same tie
  • result.tournament.groups typo in auto-close β€” The scheduler read the wrong property when posting results after an automatic group close, causing it to crash instead of announcing the result
  • votingOpen not reset after tiebreaker resolution β€” Finalizing a group via tiebreaker marked it closed but left votingOpen: true, leaving a stale flag around (a separate deadline check prevented it from being exploitable, but it's now fully consistent with the normal close path)
  • deploy-commands.js hanging instead of exiting β€” the script never called process.exit(), so it could hang indefinitely after finishing its one-shot command deploy instead of returning control to the shell/process manager that invoked it

Testing ​

  • Added Jest unit tests for the entire service layer (src/services/*.js), previously untested
  • Added command-layer tests for /bracket close-groups and /bracket resolve-tiebreaker, covering the tiebreaker finalization bug above
  • Added a Playwright end-to-end suite for the Event Request web form (tests/e2e/)

2.4.1 - 2026-07-05 ​

Improved ​

  • Event Request Form Date/Time UX
    • Auto-Update End Time: When start date/time is set, end date/time automatically updates to match start time + 10 minutes
      • Reduces manual input for users
      • Ensures minimum 10-minute event duration by default
      • Users can still manually increase end time as needed
    • Smart Validation: End date/time cannot be earlier than start date/time
      • Auto-corrects if user tries to set end time before start time
      • Minimum 10-minute gap enforced between start and end times
      • Real-time validation prevents invalid submissions
    • Dynamic Constraints: End date minimum automatically updates when start date changes
    • Benefits:
      • Faster form completion - less typing required
      • Prevents common user errors (end before start)
      • Smarter defaults reduce cognitive load
      • Still allows full manual control when needed

2.4.0 - 2026-07-05 ​

Added ​

  • Event Request Guild Membership Validation
    • Double Security Check: Validates user is a server member both at login and form submission
    • OAuth Validation: When users log in via Discord, system checks if they're a member of the target server
      • Non-members are redirected with friendly error message
      • Shows server name and invite link (if configured)
      • Prevents unauthorized form access immediately
    • Submission Validation: Revalidates membership when form is submitted
      • Handles edge case: user logs in successfully but leaves server before submitting
      • Returns 403 error with invite link
      • Frontend displays error with clickable invite link
    • User Experience:
      • Clear error messages: "You must be a member of [Server Name] to submit event requests"
      • Automatic invite link display when configured by admins
      • HTML-formatted error messages with clickable links
    • Security Benefits:
      • Prevents spam from non-members
      • Ensures only community members can request events
      • Double-check architecture prevents circumvention
    • Implementation:
      • New helper function: checkGuildMembership(guildId, userId) uses Discord.js member fetch
      • OAuth callback checks membership before creating session
      • Form submission endpoint revalidates before accepting request
      • Session tokens now include guildId for validation
      • Frontend handles not_member error with invite links

2.3.0 - 2026-07-05 ​

Added ​

  • Event Request Simple vs Advanced Mode
    • Simple Mode (Default): Users submit basic event details only (title, description, time). Moderators assign channels during approval.
      • Form shows: Title, Description, Start/End Time, Frequency
      • Form hides: Location, Voice Channel selectors
      • User experience: "Submit event idea, moderators handle logistics"
      • Moderator responsibility: Choose appropriate channels when approving
    • Advanced Mode (Opt-In): Users select specific channels from whitelists during form submission.
      • Form shows: All fields including Location and Voice Channel selectors
      • Requires channel whitelisting to be configured (see v2.2.0)
      • User experience: "Full control over event setup"
    • New Command:
      • /eggshen-config event-requests allow-user-channel-selection allow:<true/false> - Toggle between simple and advanced mode
      • Default: false (simple mode)
    • How It Works:
      • Simple mode: Form submission doesn't include channelId/voiceChannelId. Embed shows "Moderator will assign during approval" placeholder. Moderators select channels when approving.
      • Advanced mode: Form shows channel selectors filtered by allowed lists. Users must select location channel. Embed shows user's channel selections.
    • Use Cases:
      • Simple mode: New communities, servers with dedicated event coordinators, reducing user decision fatigue
      • Advanced mode: Power users, servers with established channel structures, community-driven events
    • Benefits:
      • Simplifies event requests by default
      • Reduces barrier to entry for casual users
      • Gives moderators control over channel assignments
      • Optional advanced mode for experienced communities
    • Documentation: Configuration commands documented in /eggshen-config event-requests section

2.2.0 - 2026-07-05 ​

Added ​

  • Event Request Channel Whitelisting
    • Control Which Channels Appear in Form: Admins can specify exactly which text and voice channels users can select from
    • New Commands:
      • /eggshen-config event-requests set-allowed-text-channels channel-ids:"123,456,789" - Whitelist specific text channels
      • /eggshen-config event-requests set-allowed-voice-channels channel-ids:"123,456" - Whitelist specific voice/stage channels
      • Use channel-ids:"all" to allow all channels (default behavior)
    • How It Works:
      • Empty lists (default) = all channels available in dropdowns
      • Populated lists = only those specific channel IDs appear in dropdowns
      • Form automatically filters based on server configuration
    • Use Cases:
      • Limit events to dedicated watch party channels
      • Exclude announcement or admin-only channels
      • Simplify channel selection for users
      • Prevent accidental selection of inappropriate channels
    • Benefits:
      • Granular control over channel selection
      • Reduces user confusion with large channel lists
      • Protects certain channels from event requests
      • No UI changes needed - just configure and it works
    • Documentation: Configuration commands documented in /eggshen-config event-requests section

2.1.0 - 2026-07-05 ​

Added ​

  • Event Request Voice Channel Opt-In & Granular Approval
    • Clearer Labels: "Location" instead of "Coordination Channel" (text channel is always required)
    • Voice Channel Opt-In: Checkbox to optionally include voice/stage channel for events
      • Only shows when checkbox is checked
      • Reduces form clutter, makes intent clearer
    • Server Control: New /eggshen-config event-requests allow-voice-requests command
      • Enable/disable voice channel requests server-wide
      • When disabled, checkbox is hidden and all events are text-only
      • Useful for servers that primarily use text chat for watch parties
    • Granular Moderator Approval:
      • Voice requests: βœ… Approve Both | πŸ’¬ Text Only | ❌ Deny
      • Text-only requests: βœ… Approve & Create Event | ❌ Deny
      • Moderators can modify requests during approval (e.g., remove voice channel)
    • Benefits:
      • Server admins control voice channel availability
      • Moderators have flexibility to adjust requests
      • Clearer terminology and streamlined form UX
      • Accommodates different community preferences
    • Important: Event requests are disabled by default - admins must enable with /eggshen-config event-requests toggle enabled:true
    • Documentation: See Event Requests Setup for complete configuration guide

2.0.0 - 2026-07-04 ​

Added ​

  • Comprehensive Help Commands

    • Updated /eggshen-help - Complete command list covering all bot features
      • Movies & TV Shows: movie, tv, episode, episode-list, similar, watched
      • Games & Entertainment: game, boardgame, book, soundtrack
      • Random & Discovery: random with filters
      • Tournaments & Polls: bracket, survey
      • AI Image Generation: image, versus-image, potion
      • Watch Party Tools: timer, watchparty, stats
      • Admin commands shown conditionally for moderators
    • Updated /bracket help - Comprehensive tournament guide
      • Valid tournament sizes clearly listed (2, 4, 8, 16, 32, 36, 40, 44, 48)
      • Explains bracket vs group mode auto-detection
      • Documents smart commands (/bracket open, /bracket close)
      • Interactive selector tips
      • Voting instructions for both modes
      • Pro tips and duration syntax
    • Benefits:
      • New users can discover all bot features in one place
      • Context-specific help for complex features (tournaments)
      • Links to full documentation at eggshenbot.com
      • Admin commands hidden from regular members
  • Tournament Size Validation

    • What changed: The max-titles parameter now only accepts specific valid tournament sizes
    • Valid bracket sizes: 2, 4, 8, 16, 32 (powers of 2 for balanced single-elimination)
    • Valid group sizes: 36, 40, 44, 48 (multiples of 4 for complete groups of 4 entries each)
    • Why: Prevents awkward tournament structures like 7-title brackets (requiring many byes) or 35-title group stages (leaving incomplete groups)
    • User experience: Discord command shows dropdown with 9 labeled choices (e.g., "8 titles (Quarterfinals)", "36 titles (9 groups)")
    • Benefits:
      • Ensures clean, professional tournament structures
      • No confusing bracket layouts with excessive byes
      • Complete groups in group stage mode (all groups have exactly 4 entries)
      • Clear labeling helps admins choose the right size
  • Streamlined Tournament Commands with Smart Phase Detection

    • What changed: Reduced from 25 to 18 subcommands by consolidating and adding intelligence
    • New smart commands:
      • /bracket open - Automatically detects tournament phase and opens next round (groups or knockout)
      • /bracket close - Automatically detects phase and closes current round with tiebreaker support
      • /bracket manage-titles action:[add|remove] - Unified title management command
    • Removed commands:
      • Replaced add-title and remove-title with manage-titles
      • Replaced open-knockout, close-knockout with smart open/close
      • Removed open-quarters, close-quarters, open-semis, close-semis, open-finals, close-finals (use smart open/close instead)
      • Temporarily removed regenerate (rarely used, hit Discord's 25 command limit)
    • Benefits:
      • Easier for admins: No need to remember which round-specific command to use
      • Fewer commands to learn: Bot figures out context automatically
      • Cleaner command list in Discord
      • Opens more room for future features
    • Granular control still available: Use open-groups, close-groups, open-matchup, close-matchup for specific control
  • Automatic Tiebreaker Voting System

    • What it does: Automatically creates dedicated voting rounds when ties occur during tournaments
    • Applies to:
      • Group stage: 1st and 2nd place ties
      • Knockout rounds: All matchup ties (Round of 32, Round of 16, Quarterfinals, Semifinals, Finals)
    • How it works:
      1. When closing voting with tied results, bot creates tiebreaker round
      2. Users vote in short tiebreaker round (configurable duration, default 1 hour)
      3. Tiebreaker winner advances automatically
      4. If tiebreaker also ties, random selection used (prevents infinite loops)
    • Configurable duration:
      • Group stage: /bracket close-groups groups:[A,B,C] tiebreaker-duration:[1h]
      • Knockout: /bracket close-matchup matchup:[1A] tiebreaker-duration:[30m]
      • Format: "1h", "30m", "2h", etc. (5 min - 7 days)
    • Manual resolution:
      • New command: /bracket resolve-tiebreaker tiebreaker-id:[id] winner:[1-based index]
      • Who can use: Tournament creator, Admins, Moderators
      • Use case: Manually decide winner if needed instead of waiting for tiebreaker vote
      • Fallback: If tiebreaker creation fails, random selection used
    • Benefits:
      • Fair resolution of ties through community voting
      • Prevents arbitrary random selections for important decisions
      • Flexible duration allows quick tiebreakers for fast tournaments
      • Admin override available when needed
  • Tournament Validation Before Knockout

    • What changed: /bracket advance-knockout now validates all groups are closed before generating bracket
    • Prevents: Creating incomplete brackets (e.g., 12 matchups instead of 16 for Round of 32)
    • Error message: Shows which groups still need to be closed
    • Also checks: Active tiebreakers must be resolved before advancing
  • Timer Duration Adjustment

    • New command: /timer adjust duration:[minutes]
    • What it does: Adjusts the total duration of an active timer while it's running
    • Smart calculation: Bot automatically calculates elapsed time and reschedules auto-stop based on the new total duration
    • Use case: Started timer with auto-detected runtime (e.g., 1h 48m) but watching director's cut (2h 20m) - adjust mid-watch without stopping
    • Protection: Won't allow setting duration shorter than elapsed time (prevents instant auto-stop)
    • Feedback: Shows new total duration, time elapsed, and time remaining
    • Example: Timer running for 45 minutes, adjust to 140 minutes = 95 minutes remaining
  • Timer Auto-Stop Control

    • New command: /timer autostop autostop:[enable|disable] duration:[minutes]
    • Actions:
      • Disable: Removes auto-stop from timer with duration - timer continues until manually stopped
      • Enable: Adds auto-stop to timer without duration - requires duration parameter
    • Use cases:
      • Disable: Timer scheduled to auto-stop but content is running longer than expected
      • Enable: Started timer without duration, realized you want automatic stopping
    • Benefits:
      • Toggle auto-stop mid-timer without restarting
      • Preserves elapsed time and watch history data
      • Flexible solution for unpredictable runtimes
    • Examples:
      • /timer autostop autostop:disable - removes auto-stop, continue indefinitely
      • /timer autostop autostop:enable duration:180 - adds 3-hour auto-stop to running timer
  • 4-Region Knockout Tournament System

    • What changed: Knockout tournaments now use 4 regions (March Madness style) instead of 2 regions
    • Regional labeling: Matchups labeled 1A-4D based on position (e.g., "1A", "2C", "3B", "4D")
    • New /bracket open-matchup parameters:
      • region: Open all matchups in a specific region (1-4)
      • matchup: Open specific matchup(s) by label (e.g., "1A", "2B,3C")
      • duration: Voting duration (default 24h)
    • Smart selector behavior:
      • >5 matchups: Shows region selector (4 buttons for regions 1-4)
      • ≀5 matchups: Shows individual matchup selector
      • Example: Round of 32 (12 matchups) = 3 matchups per region
    • Benefits:
      • Respects Discord's 5 ActionRow limit per message
      • Voting dashboards work for all knockout rounds
      • Admin can directly specify region:2 instead of using buttons
      • Matches real tournament bracket structure
    • Migration: Existing 2-region tournaments will automatically use new 4-region labels on next round
  • Matchup Reset on Reopen

    • What changed: Closing and reopening matchups now fully resets the matchup state
    • Clears: Previous votes, winner selection, vote counts, closed timestamps, and results cache
    • Benefits:
      • Easy testing workflow: close β†’ fix code β†’ reopen β†’ test
      • Fresh slate for fixing broken voting rounds
      • No stale data from previous voting sessions
    • Applies to: /bracket open-matchup, /bracket open-knockout, regional opening
  • Vote Confirmation Spam Removed

    • What changed: Individual "βœ… Voted for [Title] in Group X!" messages removed from group voting
    • New feedback: Button color change (purple = selected, gray = unselected) provides instant visual confirmation
    • Benefits:
      • Clean channels without 3+ messages per vote
      • Faster voting experience
      • Still see vote reflected immediately in dashboard
    • Note: Personal voting dashboard still updates in real-time with vote counts
  • Smart Tournament Warning Timing

    • What changed: "Voting Closing Soon!" warnings now appear at intelligent times based on total voting duration instead of a fixed 1-hour-before threshold
    • New warning schedule:
      • < 30 min votes: Warning after 5 minutes (e.g., 30-min test = warn at 5 min)
      • 30 min - 2 hour votes: Warning after 10 minutes
      • 2-6 hour votes: Warning 1 hour before deadline
      • > 6 hour votes: Warning 2 hours before deadline
    • Benefits:
      • Short test tournaments get early warnings with plenty of time remaining
      • No more 29-minute warnings for 30-minute votes
      • Longer tournaments still get appropriate late warnings
      • Scales automatically based on chosen duration
    • Applies to: Both group stage and knockout voting periods
  • Manual Watch History Button for All Timers

    • What changed: "Log to Watch History" button now appears on ALL timer completion messages (not just errors)
    • For timers WITH labels:
      • Automatic logging happens first (searches TMDB, logs first result)
      • Button appears for manual override if wrong title was detected
      • Can add custom notes or log a different title
    • For timers WITHOUT labels:
      • Button lets you add what you watched after the fact
      • Opens modal with title search field and notes
      • Searches TMDB when you submit
    • Permissions:
      • βœ… Timer starter can always use the button
      • βœ… Server administrators can use it
      • βœ… Server moderators (Manage Guild or Moderate Members) can use it
      • ❌ Other users get error message if they try
    • Benefits:
      • Flexibility to override auto-detection
      • Can log generic timers after completion
      • Prevents unauthorized logging by non-starters
      • Maintains automatic convenience while allowing manual control
    • Implementation: Button customId includes starter's user ID for permission checking
  • Automatic Watch History Logging for Timers

    • What changed: Timers with titles now automatically log to server watch history when they complete
    • How it works:
      1. Timer completes (manual /timer stop or auto-stop after duration)
      2. Bot searches TMDB for the timer's title/label
      3. Finds best match (uses first result)
      4. Automatically saves to server watch history
      5. Shows confirmation embed with poster, title, year, type, duration
    • Information logged:
      • Movie/TV show title from TMDB
      • Year and type (movie/TV)
      • Date watched
      • Timer duration as notes (e.g., "Watch party timer: 3:02:15")
      • Channel where watched
      • Who started and stopped the timer
      • Poster image
    • Benefits:
      • No button to click - completely automatic
      • Tracks all watch parties without manual intervention
      • Creates comprehensive server watch history
      • Works for both manual stops and auto-complete timers
      • Shows rich TMDB details (poster, year, type)
    • Fallback: If title not found on TMDB, shows button for manual logging
    • Note: Only timers with labels/titles trigger auto-logging; blank timers show button for manual logging
  • Tatsu-Style Tournament UI Enhancements

    • Inspired by: tatsu.gg Discord bot's polished voting system with visual progress bars and gamification
    • Visual Progress Bars:
      • Vote counts now display as visual bars: β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘ 12 votes (60%)
      • Real-time progress visualization in all leaderboards
      • Percentage displays alongside vote counts
      • Makes results immediately clear at a glance
    • Participation Tracking & Gamification:
      • Track each user's total votes across tournament
      • Voting streak system (consecutive rounds participated)
      • Tournament-wide statistics (unique voters, most active voter)
      • Personal stats shown in voting dashboard: πŸ”₯ Streak: 5 rounds | πŸ“Š Total votes: 12
      • First-time voter detection with welcoming message
      • Encourages consistent participation
    • Bot Avatar Branding:
      • Egg Shen avatar appears in top-right corner of all tournament cards
      • Professional, polished look matching Tatsu's style
      • Consistent branding across status, results, leaderboards, dashboards
    • Enhanced Emoji Usage:
      • πŸ”₯ for leaders in matchups
      • 🀝 for ties
      • πŸ₯‡πŸ₯ˆπŸ₯‰ for rankings
      • πŸ“Š for stats
      • ⚑ for streaks and active status
      • Consistent visual language throughout
    • New Utility Module: src/utils/tournamentUI.js
      • Progress bar generators
      • Streak visualizations
      • Status emoji helpers
      • Ranking badges
      • Reusable UI components
    • Benefits:
      • More engaging, game-like experience
      • Tatsu-level polish and professionalism
      • Progress bars make results instantly clear
      • Gamification encourages participation
      • Bot branding consistency
      • Minimal chat spam (already using ephemeral messages)
  • Round-Specific Command Aliases

    • New memorable commands for each knockout round:
      • Opening: /bracket open-quarters, /bracket open-semis, /bracket open-finals
      • Closing: /bracket close-quarters, /bracket close-semis, /bracket close-finals
    • Generic fallbacks still available: /bracket open-knockout, /bracket close-knockout work for any round
    • Improved help text: /bracket help now documents all round-specific aliases
    • Smart next-step guidance: After closing a round, bot suggests exact command to run next (e.g., "Run /bracket open-semis to start voting!")
    • Benefits:
      • More intuitive than generic "open-knockout" for admins
      • Clear progression through tournament rounds
      • Reduces need to check documentation
      • Better discovery of available commands
    • Commands restored: /bracket regenerate added back (critical for fixing bracket structure issues)
    • Commands removed to stay under 25 limit: list-groups (status shows this info)
  • Discord Timestamp Auto-Timezone Conversion

    • Issue: All timestamps displayed in server time (UTC), causing confusion for users in different timezones
    • Fix: Replaced all toLocaleString() calls with Discord's <t:timestamp:f> format
    • Affected areas:
      • Footer deadlines on matchup opening messages
      • "Voting Closing Soon!" reminder notifications (scheduler)
      • Voting extension confirmation messages
    • Result: Each user automatically sees times in their own timezone based on Discord settings
    • Example: "10:14 PM" (your timezone) instead of "2:14 AM" (UTC)
  • Personal Voting Dashboards for Knockout Rounds

    • How it works: Users get their own private voting dashboard (completely separate from shared messages)
    • Flow:
      1. Admin opens matchups β†’ Summary card appears with "Start Voting" button
      2. User clicks "Start Voting" β†’ Gets ephemeral voting dashboard
      3. Dashboard shows ALL open matchups with buttons labeled by matchup ID (e.g., "1A: The Exorcist", "1A: The Witch")
      4. User's selections show as purple buttons (only they see their own states)
      5. Click any button to vote or change vote β†’ Dashboard updates instantly
    • Shared matchup cards: Show title matchups and vote counts, but NO voting buttons
    • Personal dashboard: Each user has their own with ALL voting buttons
    • Benefits:
      • TRUE per-user button states (no cross-user pollution)
      • Users can't see each other's selections on buttons
      • Simple, clean voting experience
      • All matchups visible in one dashboard
      • Buttons clearly labeled with matchup IDs
      • Easy to understand what you're voting for
    • Applies to all media types: Movies, TV shows, video games, board games, books, and any future additions (episodes, music, etc.)

Changed ​

  • Simplified Knockout Voting Confirmations
    • Previous: Attempted to maintain persistent "Your Votes" dashboard for knockout rounds
    • Issue: Ephemeral messages from followUp() cannot be fetched and edited, causing duplicate cards on each vote
    • Root cause: Discord's ephemeral messages are client-side only and not retrievable via API after creation
    • New approach: Simple "βœ… Vote recorded for [Title]!" confirmation message
    • Rationale: Public "All Votes" leaderboard already shows all voting information, so persistent personal dashboard is redundant
    • Result: Clean voting experience with no duplicate cards
    • What you see now:
      • Public "All Votes" leaderboard (updates in place, everyone sees)
      • Simple vote confirmation (ephemeral, disappears quickly)
      • No more dashboard spam!

Fixed ​

  • Discord 5-Row Limit Error in Knockout Voting Dashboard
    • Issue: Clicking "Start Voting" in rounds with many matchups (Round of 32, Round of 16) failed with "Invalid Form Body - data.components[BASE_TYPE_MAX_LENGTH]: Must be 5 or fewer in length"
    • Root cause: Dashboard tried to display ALL matchups at once (1 row per matchup), exceeding Discord's 5 ActionRow limit per message
    • Example: Round of 32 = 32 matchups = 32 rows (limit is 5!)
    • Fix: Implemented pagination system - shows 10 matchups per page (20 buttons in 4 rows + 1 navigation row)
    • Features:
      • Previous/Next buttons to navigate between pages
      • Page indicator showing current page (e.g., "Page 1/4")
      • Vote counts preserved across pages
      • Purple highlighting for user's selections maintained across navigation
    • Result: All knockout rounds now work regardless of size, users can browse and vote on all matchups
  • "No voting matchups found" Error on Knockout Start Voting Button
    • Issue: Clicking "Start Voting" button in knockout rounds returned "No voting matchups found for this round" error
    • Root cause: Button handler was incorrectly parsing the round from button customId using split('_') which broke round_of_32 into just 'round'
    • Example: Button customId start_knockout_voting_round_of_32 β†’ split by _ β†’ ['start', 'knockout', 'voting', 'round', 'of', '32'] β†’ extracted index [3] = 'round' (wrong!)
    • Fix: Changed to extract everything after start_knockout_voting_ prefix to preserve full round name like round_of_32, quarter_finals, etc.
    • Also fixed: Added missing votingStarted timestamps to all knockout matchup opening functions so smart warning timing works correctly
    • Result: Personal voting dashboards now load correctly for all knockout rounds
  • Knockout Channel Flooding with Individual Matchup Cards
    • Issue: When opening knockout rounds, bot was flooding channels with one card per matchup (e.g., 32 cards for Round of 32) in addition to the main announcement
    • Root cause: All knockout opening functions were looping through matchups and sending individual cards to the channel
    • Affected commands: /bracket advance-knockout, /bracket open-knockout, /bracket open-quarters, /bracket open-semis, /bracket open-finals, /bracket open-matchup, /bracket open-region
    • Fix: Removed individual matchup card loops from all opening functions - now only the main announcement with "Start Voting" button appears
    • Result: Clean, spam-free channel with single announcement. Users click "Start Voting" to see all matchups in their personal dashboard
    • Benefits:
      • No more channel spam/flooding
      • Cleaner tournament experience
      • Personal dashboard remains the single source for voting
      • Maintains all functionality while reducing visual clutter
  • Group Stage Open Command Error
    • Issue: /bracket open-groups command failed with "An error occurred" when trying to display voting announcement
    • Root cause: Code referenced undefined variable leaderboardEmbed instead of the embeds array that was built earlier in the function
    • Fix: Changed embeds: [leaderboardEmbed] to embeds: embeds on line 1350 of bracket.js
    • Result: Group stage voting now opens correctly with announcement embed, leaderboards, and "Start Voting" button
  • Knockout Dashboard Still Creating Multiple Cards
    • Issue: Dashboard was still creating new cards on each vote instead of updating the existing one
    • Root cause: Fallback logic was too aggressive - if message fetch failed for ANY reason, created new message with followUp() instead of updating
    • Fix:
      • Simplified dashboard update logic with proper try/catch around message fetch
      • Only delete dashboard from cache and create new one if message truly doesn't exist
      • Added console logging to track dashboard creation vs updates
      • Changed from nested if/else to single try/catch for cleaner error handling
    • Result: Dashboard now consistently updates in place on subsequent votes, only creates new message on first vote or if previous message was deleted
  • Knockout Voting Interaction Error Fixed
    • Issue: All knockout votes failed with "An error occurred while processing your vote" after dashboard flooding fix
    • Root cause: Double-defer - interaction.deferUpdate() at button handler level + interaction.deferReply() in knockout handler = "InteractionAlreadyReplied" error
    • Fix:
      • Removed deferReply() from handleKnockoutVote (interaction already deferred as update)
      • Changed all editReply() calls to followUp() for ephemeral dashboard messages
      • Kept deferUpdate() at top level for button message updates
    • Result: Voting works again with proper dashboard tracking
  • Knockout Voting Dashboard Flooding Fixed
    • Issue: Each knockout vote created a NEW ephemeral "Your Votes" card, flooding the channel with multiple cards (user reported 5+ cards stacking up as they voted)
    • Root cause: Interaction wasn't properly deferred before trying to update dashboard, causing followUp() calls to create new messages instead of updating existing ones
    • Fix:
      • Added proper interaction.deferReply({ ephemeral: true }) at the start
      • Changed dashboard creation to use interaction.editReply() for first message
      • Dashboard updates now properly edit the existing message instead of creating new ones
      • Personal dashboard persists and updates in place as user votes
    • Result: Users now see ONE "Your Votes" card that updates as they vote, not a flood of cards
    • Public tally: The public matchup voting messages (visible to everyone) continue to update with real-time vote counts
    • Benefits:
      • Clean UX - only one personal dashboard per round
      • Real-time progress tracking without clutter
      • Public voting cards show live vote totals for all users
  • Multiple Matchup Voting Buttons Separated
    • Issue: When opening multiple matchups (via comma-separated input or region selector), all voting buttons were bundled together at the bottom, making it difficult to understand which buttons corresponded to which matchup
    • Fix: Each matchup now posts as a separate message with its own voting buttons directly below its card
    • Impact: Users can now clearly see which buttons belong to each matchup, significantly improving voting UX
    • Affected commands:
      • /bracket open-matchup (when opening multiple matchups like "1A,1B,1C")
      • /bracket open-region (opens all matchups in a region)

Changed ​

  • Bracket Visualization Poster Opacity Increased to 50%
    • Previous: Started at 30%, increased to 40%, now at 50% opacity
    • Progression: 30% β†’ 40% β†’ 50% (iterative improvements based on user feedback)
    • Reason: User tested 40% and confirmed posters could be even more prominent
    • Impact: Posters now significantly more visible in bracket visualizations while still maintaining excellent text readability
    • Sweet spot: 50% provides strong visual presence without overwhelming text content

Added ​

  • Public "All Votes" Leaderboard for Knockout Rounds
    • Real-time public leaderboard - Single card visible to everyone showing current vote tallies across all matchups in the round
    • Updates in place - Leaderboard refreshes automatically after each vote, not repeated
    • Live statistics:
      • Each matchup with current vote counts (e.g., "1A: Jaws (5) vs Kwaidan (3) πŸ”₯")
      • Leader indicator: πŸ”₯ for matchup with more votes, 🀝 for tied matchups
      • Total votes cast across all matchups
      • Number of matchups currently open
    • Persistent tracking - One leaderboard per guild per round, cached and updated in place
    • Benefits:
      • PRIMARY voting feedback for knockout rounds - shows all matchup tallies in one place
      • Users see real-time competition without voting
      • Transparent vote counts visible to all
      • Creates excitement and engagement around close matchups
      • No chat spam - single card updates silently
    • Simple confirmation system:
      • Vote button clicked β†’ Simple "βœ… Vote recorded!" confirmation (ephemeral)
      • "All Votes" leaderboard updates automatically (public, persistent)
      • Clean UX with no dashboard spam
  • Interactive Region Selector for open-region
    • /bracket open-region with no parameter - Shows 2 buttons: Region 1 (Left Side) and Region 2 (Right Side)
    • Simple button selection - Just 2 options with directional emoji arrows (⬅️ ➑️)
    • Shows matchup counts - See how many matchups in each region before opening
    • Consistent with other interactive selectors - Same UX pattern as open-matchup and close-matchup
    • Benefits:
      • Faster than typing "1" or "2"
      • Visual clarity with emoji indicators
      • See matchup distribution before opening
      • Consistent button-based workflow
  • Interactive Matchup Selectors
    • /bracket open-matchup with no parameter - Shows buttons for all pending matchups
    • /bracket close-matchup with no parameter - Shows buttons for all open matchups
    • Visual selection interface:
      • Open selector: Blue buttons showing matchup label and movie titles (e.g., "1A: Jaws... vs Night of...")
      • Close selector: Red buttons showing matchup label, titles, and current votes (e.g., "1A: Jaws(5) vs Night of...(3)")
    • Multi-select supported - Click multiple buttons to open/close several matchups at once
    • 15-minute button timeout - Buttons expire after 15 minutes to keep UI clean
    • Benefits:
      • No need to remember/type matchup labels
      • Visual overview of all available matchups
      • Faster admin workflow
      • See current vote counts before closing
      • Reduces typing errors
  • Multi-Matchup Support
    • Comma-separated matchup lists - Open or close multiple matchups with one command
    • /bracket open-matchup matchup:"1A,1B,2C" - Open multiple matchups at once
    • /bracket close-matchup matchup:"1A,1B,2C" - Close multiple matchups at once
    • Individual error handling - Each matchup processed separately with success/error tracking
    • Smart response formatting:
      • Single matchup: Detailed embed with region name and results
      • Multiple matchups: Summary embed listing all successes and errors
    • Auto-advance tracking - Shows which winners were automatically placed in next round
    • Round completion detection - Notifies when all matchups in round are closed
    • Benefits:
      • Faster tournament management
      • Less command spam in channels
      • Clear feedback for batch operations
      • Backwards compatible with single matchup commands
  • Prominent Matchup Labels in Voting
    • Clear matchup identification - Every voting embed shows "1A: Vote for your pick!" in description
    • Consistent across all contexts:
      • advance-knockout (auto-open first round)
      • open-knockout (region-based opening)
      • open-matchup (individual/multi matchup opening)
      • Interactive button handlers
    • Benefits:
      • Users immediately see which matchup they're voting for
      • Reduces confusion when multiple matchups displayed
      • Better accessibility and clarity
      • Regional labels impossible to miss
  • Persistent Ephemeral Voting Dashboard
    • Real-time personal dashboard - Each user gets their own voting tracker (only they can see it)
    • Updates as you vote - Dashboard refreshes instantly with checkmarks (βœ…) for selected titles
    • Color-coded status - Gray (no votes), Blue (partial), Green (complete)
    • Works for BOTH phases:
      • Group Stage: Shows all titles with βœ… for selected, ⬜ for not selected
      • Knockout Rounds: Shows all matchups with βœ… for voted, ⬜ for not voted
    • Progress indicators - "1 of 2 selected" or "All matchups voted!" with guidance
    • Persistent across clicks - Same message updates instead of creating new messages
    • Smart cleanup - Old dashboards auto-deleted after 1 hour
    • Benefits:
      • Users see their vote state in real-time across ALL matchups
      • No confusion about which buttons they clicked (especially important in large knockout rounds)
      • Reduces channel clutter (one persistent message vs many)
      • Works perfectly with Discord's ephemeral system
      • No cross-user pollution (each dashboard is private)
      • Track progress in Round of 32 (16 matchups), Round of 16 (8 matchups), etc.
  • Consolidated Tournament Warning Messages
    • Grouped by deadline - Multiple groups with same deadline = ONE warning message
    • Before: 4 groups closing = 4 separate warning messages
    • After: 4 groups closing = 1 consolidated message: "Groups I, J, K, L"
    • Works for both phases - Group stage AND knockout matchups
    • Benefits:
      • Reduces notification spam (4 messages β†’ 1 message)
      • Clearer communication (see all closing at once)
      • Less channel clutter
      • Better tournament pacing visibility
  • Auto-Start Knockout Voting
    • /bracket advance-knockout now auto-opens voting
    • Customizable duration - /bracket advance-knockout duration:"24h" (default: 24h)
    • One command workflow - Generate bracket AND start voting immediately
    • Sends voting buttons - All matchups ready to vote right away
    • Stores message IDs - Scheduler can track and auto-close
    • Fallback handling - Clear error if voting fails to open
    • Benefits:
      • Eliminates extra /bracket open-knockout step
      • Better tournament flow and momentum
      • Natural expectation met (knockout starts = voting starts)
      • Fewer commands to remember
  • Comprehensive Logging System (Drupal Watchdog-style)
    • File-based logging - All events logged to logs/ directory in JSON format
    • 8 severity levels - EMERGENCY (0) to DEBUG (7) following syslog standards
    • Categorized logs - system, command, button, select, modal, scheduler, bracket, timer, survey, api, database, performance, security
    • Daily rotation - New log file each day: [category]-YYYY-MM-DD.log
    • Size-based rotation - Files rotate when exceeding 10MB
    • Auto-cleanup - Logs older than 30 days automatically deleted
    • Performance tracking - Slow operations logged with duration
    • Crash diagnosis - Uncaught exceptions and unhandled rejections captured
    • Discord errors - Client errors and warnings logged
    • /eggshen-logs stats - View log statistics (file count, total size)
    • /eggshen-logs errors - View recent errors (EMERGENCY to ERROR levels)
    • /eggshen-logs category - View logs by category
    • Benefits:
      • Diagnose production crashes and CPU issues
      • Track command execution and performance
      • Monitor button interaction failures
      • Historical data for troubleshooting
      • Easy log viewing from Discord
      • No more diagnostic blackouts
  • Automatic Voting Closure System
    • Auto-close scheduler - Background service checks voting deadlines every minute
    • 1-hour warnings - Automatic reminder sent when voting closes in less than 1 hour
    • Auto-close at deadline - Groups and matchups automatically close when deadline passes
    • Results posted automatically - Final vote counts and winners announced when voting ends
    • Visual closure - Voting messages updated to show "πŸ”’ CLOSED" status
    • Disabled buttons - All voting buttons disabled when voting ends
    • Graceful shutdown - Clean restart handling ensures no lost state
    • Benefits:
      • No manual intervention needed to close voting
      • Members get advance warning to cast votes
      • Immediate results when voting ends
      • Consistent tournament pacing
      • Reduces admin workload
  • Tournament Export System
    • /bracket export format:json - Export full tournament data as JSON
      • Complete tournament structure, all groups, matchups, votes
      • Perfect for archival or data analysis
      • Can be imported back into bot (future feature)
    • /bracket export format:markdown - Export formatted results as Markdown
      • Group stage results with vote counts
      • Knockout bracket progression
      • Tournament statistics (voter participation, total votes)
      • Ready to paste into announcements or documentation
    • Use cases:
      • Archive completed tournaments
      • Share results in Discord or other platforms
      • Analyze voting patterns
      • Document tournament history
  • Tournament Management Improvements
    • /bracket edit-name - Change tournament name after creation
      • Admin/moderator only
      • Update tournament branding mid-tournament
      • Fix typos in tournament name
    • Enhanced /bracket status - Real-time tournament dashboard
      • Shows active voters count for each open group/matchup
      • Displays time remaining with ⚠️ warning when <1 hour left
      • Shows current leaders in each active vote
      • More detailed progress tracking
      • Live vote counts for all active voting
  • Robust Error Handling
    • Comprehensive try-catch blocks around all async operations
    • Detailed error logging with context (guild ID, user ID, custom ID)
    • Graceful degradation for API failures
    • User-friendly error messages instead of generic failures
    • Button interaction safety - Prevents "interaction failed" errors
    • Message update protection - Vote recorded even if message update fails
    • Benefits:
      • More reliable button voting
      • Better debugging for production issues
      • Improved user experience during errors
      • Prevents crashes from unexpected failures
  • Button-Based Group Voting
    • Interactive voting - Members vote by clicking buttons instead of typing commands
    • Visual feedback - Selected titles highlight in green
    • Real-time updates - Vote counts update live on the voting message
    • Simplified UX - No commands needed for members to vote
    • Two-click voting - Click 2 buttons to cast your vote
    • Flexible changes - Deselect by clicking again, change anytime before deadline
    • Clear constraints - Maximum 2 selections enforced automatically
    • Benefits:
      • Eliminates need to remember movie positions (1-4)
      • No typing required - just point and click
      • Consistent with knockout voting (already button-based)
      • More accessible and intuitive for all users
      • Reduces voting errors and confusion
  • Improved Tournament Help & Button UX
    • /bracket help - New comprehensive tournament guide command
      • Quick start steps for admins (8-step tournament flow)
      • How to vote instructions for members (group + knockout stages)
      • Common commands organized by role (Everyone vs Admin/Mod)
      • Auto-features overview (auto-close, warnings, live counts)
      • Pro tips (duration syntax, wildcards, custom images, exports)
    • Enhanced button voting feedback
      • Group voting: Visual embeds showing selection progress (1 of 2, 2 of 2)
      • Knockout voting: Confirmation embeds with trophy emoji and selected title
      • Color-coded embeds: Blue for partial selection, green for complete
      • Numbered steps in instructions with emojis (1️⃣ 2️⃣ 3️⃣ 4️⃣)
    • Clearer voting instructions
      • Opening messages with numbered how-to-vote steps
      • Prominent tips about changing votes before deadline
      • Updated footer text to guide users ("πŸ‘‡ Click a button...")
      • Simplified group embed descriptions
    • Benefits:
      • New users can learn tournament system with /bracket help
      • Better visual feedback reduces confusion
      • Consistent emoji-based UI throughout
      • Ephemeral responses keep channels clean
      • More intuitive voting experience
  • Timer Reminder - New /timer remind subcommand
    • /timer remind - Announce timer is starting right before watch party begins
    • Auto-detects Discord scheduled events - Finds event linked to current channel
    • TMDB integration - Shows poster, runtime, year, and overview
    • Smart buttons - "View on TMDB" and "Join Voice Channel" (if applicable)
    • Custom host message - Add personal message (e.g., "Everyone ready?")
    • Role mentions - Ping specific groups
    • Pre-timer announcement - Use right before running /timer start
    • Use case: Already announced watch party 1-2 hours ago? Use this for final "timer starting now" notice
    • Integrated with timer - All timer-related functions in one command
  • Regional Bracket Identification System
    • Regional labels for all knockout matchups (e.g., "1A", "2B", "1C")
    • Region 1 (Left Side): Matchups labeled 1A, 1B, 1C, 1D...
    • Region 2 (Right Side): Matchups labeled 2A, 2B, 2C, 2D...
    • Regional labels shown in:
      • Command parameters (use "1A" instead of "1")
      • All voting embeds and displays
      • Generated bracket images
      • Result announcements
    • New /bracket open-region command
      • Open all matchups in one region (left or right side)
      • Perfect for splitting rounds across multiple days
      • Example: Open Region 1 Monday, Region 2 Wednesday
    • Three opening options for maximum flexibility:
      • /bracket open-knockout - Opens entire round (all matchups, both regions)
      • /bracket open-region region:1 - Opens all left side OR right side matchups
      • /bracket open-matchup matchup:1A - Opens one specific matchup
    • Benefits:
      • Easier to reference specific matchups ("open 1A" vs "open matchup 1")
      • Visual clarity in bracket structure (left vs right side)
      • Flexible pacing options (by round, by region, or by matchup)
      • Better organization for large tournaments
      • Clear regional narratives ("East vs West", "Old School vs New School")

Changed ​

  • BREAKING: Tournament Bracket Command Structure
    • Replaced /bracket add-group with /bracket add-title for precise title selection
    • Old workflow: Add 4 titles at once, auto-select first match when multiple results found
    • New workflow: Add one title at a time with selection menu for disambiguation
    • Why: Aligns with bot's core principle of letting users select the exact title they want
    • Migration: Instead of /bracket add-group group:A type:movie title1:X title2:Y title3:Z title4:W, now run /bracket add-title group:A type:movie title:X four times
    • Benefits:
      • Selection menu shows all matches with years and descriptions (like /movie, /tv, etc.)
      • Progress tracking shows "2/4 titles" so you know how many more needed
      • Prevents duplicate titles in same group
      • More flexibility - can add 1-4 titles per group before opening voting
  • Tournament Creation Privacy
    • /bracket create is now ephemeral (only visible to admin/moderator who created it)
    • Regular members no longer see setup process
    • Use /bracket announce when ready to share tournament with server
    • Keeps channel clean during tournament setup

Removed ​

  • /bracket vote-group command - No longer needed
    • Group stage voting is now button-based
    • Members vote by clicking buttons on the voting message
    • Eliminates 21 command parameters (group, choice1, choice2) that users had to remember
    • Simpler, more intuitive user experience

Improved ​

  • Tournament Bracket Visualization (/bracket view)
    • Complete redesign - Now displays a proper tournament bracket tree layout (like March Madness)
    • Clear matchup pairing - Each matchup has two participant boxes grouped together with container border
    • Proper bracket tree structure - Connector lines show which matchups feed into next round
    • Minimum 1200px width - Wide, spacious, professional appearance
    • Landscape participant boxes - 240px wide Γ— 50px tall for optimal readability
    • Left-aligned text - Easier to read than centered text
    • Smart truncation - Ellipsis for titles that don't fit
    • Visual flow - Clear progression from left to right through rounds
    • Matchup spacing - 140px vertical spacing with 10px gap between paired participants
    • Better connector lines - 40px horizontal extension then vertical connection to next round
    • Winner indicators - Green highlighting and checkmarks on the right side of boxes
    • Full bracket tree generation - All rounds (Round of 32 β†’ Round of 16 β†’ Quarterfinals β†’ Semifinals β†’ Finals) are generated upfront with "TBD" placeholders
      • Previously only showed current round with completed matchups
      • Now displays complete tournament structure from start to finish
      • Future matchups show as "TBD vs TBD" until winners are determined
      • Provides clear visual roadmap of entire tournament progression
  • Group Voting Display (/bracket open-groups)
    • Even distribution - Groups now display in balanced rows (e.g., 4 groups = 2x2 grid instead of 3-1)
    • Dynamic layout - Automatically calculates optimal groups per row:
      • 4 or fewer groups: 2 per row
      • 5-9 groups: 3 per row
      • 10+ groups: 4 per row
    • Better visual balance - Cleaner, more symmetrical presentation when opening group voting

Added ​

  • Tournament Brackets
    • NEW: Knockout Voting System - Complete interactive voting for tournament bracket matches
      • /bracket open-knockout - Opens current round matchups for member voting
        • Button-based voting interface for each matchup
        • Real-time vote count updates
        • One vote per user per matchup (can change vote anytime)
        • Displays all matchups for current round with VS presentation
      • /bracket close-knockout - Closes round and advances winners automatically
        • Determines winner for each matchup (higher votes win, random if tied)
        • Auto-advances winners to next round matchups
        • Auto-updates tournament phase when round completes
        • Shows detailed results with vote counts
        • Detects tournament completion after finals
      • Interactive Button Voting
        • Click title buttons to vote in matchups
        • Ephemeral confirmation when vote recorded
        • Vote updates immediately in message
        • Can change vote by clicking different option
        • Ensures one vote per matchup per user
      • Automatic Winner Advancement
        • Winners automatically populate next round matchups
        • Tournament phase advances seamlessly (Round of 16 β†’ Quarterfinals, etc.)
        • Finals completion marks tournament as complete with champion
        • Bracket visualization updates with results
      • Complete Workflow: Generate bracket β†’ Open voting β†’ Members vote β†’ Close round β†’ Winners advance β†’ Repeat until champion
    • NEW: Customizable Voting Timeframes - Control voting duration for group stage and knockout rounds
      • duration parameter added to /bracket open-groups and /bracket open-knockout
        • Default: 24 hours
        • Range: 5 minutes (5m) to 30 days (30d)
        • Format: "5m", "2h", "24h", "3d", "7d", etc.
        • Examples: duration:48h for 2 days, duration:3d for 3 days, duration:1h for 1 hour
      • Voting deadline display - Shows time remaining and exact deadline in embeds
      • /bracket extend-voting - Extend or change voting deadline after opening
        • For group voting: extend specific groups by letter
        • For knockout: extends all matchups in current round
        • Admins and moderators can modify deadlines as needed
        • Shows updated time remaining and exact deadline after extending
      • Flexible tournament pacing - Run quick tournaments (30-minute rounds) or slow-burn events (week-long voting)
      • Per-server control - Tournament admins set duration based on their community's needs
      • Deadline enforcement - Voting automatically blocked after deadline expires
        • Users receive clear error message with time expired ("ended 2 hours ago")
        • Prevents late votes while allowing admins to extend if needed
        • Lazy evaluation approach - validates on vote attempt, no background jobs needed
        • Encourages admins to close voting or extend deadlines proactively
    • NEW: /bracket my-votes - View your personal voting status and history
      • Private voting dashboard (ephemeral response - only you can see it)
      • Groups voted - See which groups you've voted in and your exact choices
      • Available votes - Lists groups/matchups you haven't voted in yet
      • Time remaining - Shows countdown for each active vote
      • Knockout history - View all matchup votes you've cast
      • Never miss a vote - Easy way to track what's left to vote on
      • Deadline awareness - See when each vote expires
      • Perfect for large tournaments - Essential when managing 8-12 groups with staggered voting
    • NEW: Individual Matchup Control - Open and close specific matchups for granular tournament pacing
      • /bracket open-matchup - Open a single matchup for voting instead of entire round
        • Specify matchup number (1-32 depending on round)
        • Set custom duration per matchup
        • Perfect for spacing out matchups over days
        • Build suspense by featuring one battle at a time
        • Example: /bracket open-matchup matchup:1 duration:24h
      • /bracket close-matchup - Close individual matchup and determine winner
        • Closes specific matchup by number
        • Winner immediately placed in next round slot
        • When ALL matchups in round close, tournament auto-advances
        • Flexible staggered closing
        • Example: /bracket close-matchup matchup:1
      • Use Cases:
        • One matchup per day for maximum engagement
        • Spotlight important matchups individually
        • Mix batch mode (whole round) with individual mode
        • Feature matchups with custom timings
        • Build community discussion around each battle
      • Auto-Advancement Intelligence:
        • Each matchup winner advances immediately to their next round slot
        • Tournament phase advances when last matchup in round closes
        • Seamless progression whether using batch or individual mode
        • Combine approaches within same tournament (batch some rounds, individual others)
    • NEW: Custom Image Support - Upload images or provide URLs when adding titles to brackets
      • Optional image parameter in /bracket add-title (attachment option)
      • Upload images directly from your device (PNG, JPG, GIF, WebP)
      • Or paste image URLs from Discord CDN, Imgur, or any direct image link
      • Custom images override API posters in matchups and voting screens
      • Perfect for servers that don't want AI-generated images
      • Useful for custom artwork or specific promotional posters
      • Falls back to API poster if no custom image provided
    • NEW: /bracket remove-title - Remove titles from groups during setup phase
      • Remove by position (1-4) from any group
      • Only available before voting starts
      • Automatic re-indexing of remaining titles
      • Confirmation embed shows removed title with thumbnail
      • Useful for fixing mistakes before opening group voting
    • NEW: /bracket resize - Dynamically expand or contract tournaments during setup
      • Change group count from 4-12 groups (16-48 total titles)
      • Expanding: Adds new groups (shows which new groups are available)
      • Contracting: Validates that groups being removed are empty
      • Smart Validation: Provides detailed actionable guidance when resize fails
        • Lists which groups have titles that would be removed
        • Shows exactly how many titles need to be moved or removed
        • Suggests minimum group count needed to keep all current titles
        • Offers 3 clear options: move titles, remove titles, or adjust target size
      • Perfect for tournaments that grow larger than initially planned
    • NEW: /bracket announce - Public tournament announcements with custom messaging
      • Share tournament details with the entire server
      • Optional custom message parameter
      • Optional tournament banner/image attachment
      • Shows tournament type, groups, entry count, and current status
      • Can be used at any phase (setup, group voting, knockout)
      • Perfect for generating hype and informing members when voting opens
    • NEW: /bracket list-groups - Simple text display of all groups and titles
      • Shows all groups with their titles in a clean, easy-to-read format
      • Displays voting status indicators (πŸ—³οΈ for open voting, βœ… for closed)
      • Shows empty groups with clear indication
      • Group completion progress counter
      • Available to all members (not just admins/mods)
      • Perfect for quick overview of tournament lineup without generating images
      • Useful during setup to see which groups need more titles
  • AI Image Generation System
    • NEW: /image command - Generate AI images from text prompts or Discord messages
      • Text-to-image: /image prompt:"A dragon flying over a castle"
      • From messages: /image message:username (finds recent message from user)
      • From message ID: /image message:1234567890123456789
      • Square format (1024x1024), standard quality
      • 10-30 second generation time
      • Cost: $0.04 per image
    • NEW: Server-wide Feature Toggle
      • Completely disable AI image generation per server
      • /eggshen-config ai-images feature-toggle enabled:false to disable
      • Separate from rate limiting (can disable feature OR just limit usage)
      • When disabled, all AI commands show clear error messages
    • NEW: Permission Level Controls
      • Control who can use AI image commands per server
      • /eggshen-config ai-images set-permissions level:[everyone|moderators|admins]
      • everyone (default) - All members can generate images
      • moderators - Only moderators and admins
      • admins - Only server administrators
      • Combined with rate limiting for fine-grained control
    • Comprehensive Rate Limiting System
      • Per-user cooldown: 5 minutes (configurable 60-3600 seconds)
      • Per-user daily limit: 10 images (configurable 1-100)
      • Per-server daily limit: 50 images (configurable 1-500)
      • Admin/moderator cooldown bypass (optional, respects daily limits)
      • Whitelisted users: Unlimited generation for contributors/premium users
      • Cost tracking and statistics
      • Prevents excessive API costs (default: $60/month max)
    • Configuration Commands (/eggshen-config ai-images)
      • view - See settings, server stats, and personal usage
      • feature-toggle - Enable/disable AI image generation entirely
      • set-permissions - Control who can use the commands
      • toggle - Enable/disable rate limiting
      • user-cooldown - Set cooldown between generations
      • user-daily-limit - Set max images per user per day
      • guild-daily-limit - Set max images per server per day
      • admin-bypass - Toggle admin cooldown bypass
      • whitelist-add - Grant unlimited access to specific users
      • whitelist-remove - Remove unlimited access
      • whitelist-list - View whitelisted users
      • reset-user - Reset user's usage
      • reset-guild - Reset server's usage
    • Enhanced /bracket image Command
      • NEW: prompt parameter - Add custom details to matchup images
      • Smart Search Validation - Validates titles through TMDB, RAWG, BGG, Google Books
      • Disambiguation Menu - Shows selection when multiple matches found (like /movie)
      • Cross-Type Support - Compare movies vs games, TV vs books, etc.
      • Strict Left-Right Layout - Title 1 always left, VS center, Title 2 always right
      • Content Policy Compliance - "Inspired by themes" prompts avoid replication
      • Rich Metadata - Uses overviews and descriptions for better prompts
      • Rate Limited - Same limits as /image command
      • Wide format (1792x1024) for split-screen compositions
    • OpenAI Model Update - Updated to gpt-image-2 (latest image generation model)
    • Usage Tracking - Logs all generations with cost, user, guild, and metadata
    • Cost Protection - Prevents runaway costs ($60/month default max vs $2,400/month unlimited)
  • Tournament Bracket Visualizations
    • /bracket view command generates March Madness-style bracket images
    • Shows full tournament tree with all rounds and matchups
    • Visual highlighting for winners (green backgrounds with checkmarks)
    • Displays VS indicators, connector lines, and round labels
    • Champion trophy display when tournament complete
    • Discord dark theme styling, PNG format
    • Uses @napi-rs/canvas for fast image generation
    • Only available during knockout phase
  • Tournament Bracket System (/bracket command)
    • Host comprehensive tournaments with flexible sizing (16-48 entries)
    • NEW: Smart search integration - /bracket add-group searches TMDB/RAWG/BGG/Google Books
    • NEW: Multi-type support - Movies, TV shows, video games, board games, or books
    • NEW: Rich metadata storage - IDs, years, poster URLs, ratings stored per entry
    • NEW: Type validation - Prevents mixing different types in same tournament
    • Configurable group count: 4-12 groups (default 8), each with 4 entries
    • Group stage: Members vote for top 2 in each group
    • Dynamic wildcard system: Automatically calculates wildcards needed (0-8) to reach power-of-2 bracket
    • Knockout stage: Single elimination with dynamic round naming (Semifinals β†’ Quarterfinals β†’ Round of 16 β†’ Round of 32)
    • 10 subcommands: create, add-group, open-groups, close-groups, vote-group, advance-knockout, status, view, image, cancel
    • Admin/moderator management controls, all members can vote
    • Random tiebreaker for fair vote resolution
    • Vote change support (users can change votes before close)
    • Complete tournament state persistence in JSON format
    • Configurable via /eggshen-config commands toggle
    • Perfect for community competitions like "The Ultimate Horror Cup"
  • Spotify Premium Detection and Graceful Fallback
    • Detects when Spotify API requires Premium subscription (403 errors)
    • Automatically disables Spotify features when Premium detected
    • Bot continues working with iTunes-only for soundtrack searches
    • Clear warning logs explain why Spotify is unavailable
    • No errors exposed to users when Spotify unavailable
    • Graceful degradation ensures /soundtrack command always works
  • Spotify Integration for Soundtrack Search
    • Added Spotify API support alongside iTunes for /soundtrack command
    • Displays results from both iTunes and Spotify when both are configured
    • Shows album artwork, artist, track count, and release dates from both services
    • Includes clickable links to both platforms for listening and purchasing
    • Falls back to iTunes-only if Spotify not configured (backwards compatible)
    • Free Spotify Developer API with unlimited requests
    • OAuth 2.0 Client Credentials flow for server-to-server authentication
  • Soundtrack Search (/soundtrack command)
    • Search for movie and TV show soundtracks via iTunes Search API
    • TMDB title verification ensures correct soundtrack matching
    • Displays album artwork, artist/composer, track count, release date, genre, and price
    • Direct iTunes links for listening and purchasing
    • No API key required - uses free iTunes Search API
    • Follows same selection pattern as other search commands for consistency
  • Survey/Polling System (/survey command)
    • Create interactive surveys with up to 10 options
    • Real-time vote tracking via reaction emojis (1️⃣-πŸ”Ÿ)
    • Single or multiple vote modes
    • View live results with progress bars showing percentages
    • Comprehensive management: /survey list, /survey results, /survey close, /survey delete
    • Permission system: creator, administrators, and moderators can manage surveys
    • Persistent storage in JSON format per-guild
    • Configurable via /eggshen-config commands toggle (can be enabled/disabled per server)
    • Alternative to /poll and /vote commands that may be provided by other bots

Fixed ​

  • CRITICAL: Knockout Bracket Generation Bug
    • Issue: Tournaments with more than 4 groups had incomplete knockout brackets. For example, a 12-group tournament only created 4 first-round matchups instead of 12, leaving 8 qualified movies without matchups. Visualization showed sparse Round of 32/16 with mostly TBD placeholders.
    • Root cause: Code used opponent.index to track which runners-up had been matched. The index property represents position within each group (0=1st, 1=2nd, 2=3rd, 3=4th), NOT a unique identifier. Multiple runners-up across different groups share the same index value (e.g., all second-place finishers have index=1). After matching one opponent with index=2, ALL other runners-up with index=2 were incorrectly marked as "used", even though they were different movies from different groups.
    • Example: In a 12-group tournament, runners-up had indices Counter({0: 2, 1: 4, 2: 3, 3: 3}). After matching 4 winners (one per unique index value 0-3), the code thought all opponents were used, leaving 8 winners with no matchups.
    • Fix: Changed to use unique key title + groupId instead of index to track used opponents. Now properly creates matchups for ALL qualified movies.
    • Impact: Affects all tournaments with 5+ groups. Existing broken tournaments can be fixed with /bracket regenerate.
    • Benefit: Complete, properly populated knockout brackets with all qualified movies receiving first-round matchups.
  • Button Selection Cross-User Pollution Bug
    • Issue: When User A voted, their button selections (green buttons) appeared as selected for ALL users (User B, C, D, etc.)
    • Root cause: Discord messages are shared, not per-user. When buttonHandler edited the message to highlight buttons (ButtonStyle.Success), those style changes applied globally to everyone viewing the message.
    • Fix: Removed button style updates from shared voting messages entirely. Buttons stay gray (ButtonStyle.Secondary) for everyone. Users see their selection feedback only in their private ephemeral dashboard.
    • Benefit: No more confusion about seeing other people's votes highlighted on your screen.
  • Bracket Visualization Layout Issues
    • Issue: Round of 32 had overlapping titles, missing TBD rectangles, inconsistent spacing
    • Root cause: Used dynamic spacing based on canvas height instead of fixed MATCHUP_SPACING constant, causing overlaps with many matchups
    • Fix: Now uses fixed MATCHUP_SPACING (140px) for consistent positioning, proper canvas height calculation, all matchups positioned at precise intervals
    • Benefit: Clean, properly spaced brackets with no overlapping text and all TBD rectangles visible

1.0.0 - 2026-06-21 ​

Added ​

  • Production-ready release πŸŽ‰
  • Timer duration parameter with auto-stop
    • Optional duration parameter (1-600 minutes) for /timer start
    • Timer automatically stops and announces completion when duration expires
    • Prevents issue of users forgetting to stop timers after content ends
    • Duration persists across bot restarts with restored auto-stop timeouts
    • Shows remaining time in /timer status when duration is set
  • Runtime auto-detection from TMDB
    • When timer auto-detects label from Discord event, searches TMDB for runtime
    • Automatically adds 10-minute buffer to cover setup time and credits
    • Shows selection menu when multiple TMDB matches found (movies and TV shows)
    • Users select correct title before timer starts
    • Includes "Skip" option to start without duration
    • Improves UX by preventing incorrect runtime detection
  • Conditional command registration based on API availability
    • /game and /boardgame commands won't appear in Discord if API keys aren't configured
    • Prevents users from seeing unavailable commands
    • Cleaner UX - only shows what's actually available
  • Comprehensive API Keys Guide
    • New documentation page with step-by-step instructions for all APIs
    • Includes Discord Bot, TMDB, OMDB, Trakt, RAWG, and BoardGameGeek
    • Registration time estimates, rate limits, and troubleshooting
    • Security best practices for API key management
  • BoardGameGeek API integration completed
    • /boardgame command now fully functional with BGG_CLIENT_ID
    • /random boardgame supports category and rating filters
    • /similar includes board game recommendations
    • Requires BGG_CLIENT_ID environment variable
  • API key validation for all commands
    • Commands gracefully fail with helpful error messages if required API keys are missing
    • /game requires RAWG_API_KEY
    • /boardgame requires BGG_CLIENT_ID
    • /random validates keys for game/boardgame subcommands
    • /similar skips unavailable media types when API keys are missing
  • Timer countdown theme option
    • Modern theme (default) - Colorful animated countdown with visual blocks
    • Classic theme - Sequential text countdown matching original bot behavior
    • Usage: /timer start theme:classic or /timer start theme:modern
    • Helps users transition from old bot with familiar countdown style
  • Professional documentation site
    • Hosted at https://eggshenbot.com
    • Custom domain with HTTPS
    • WCAG 2.1 AA accessibility compliance
    • Custom cyan theme (#2AB5E5) with proper contrast ratios
    • Favicon and Apple touch icons
    • Complete command reference
    • Installation and configuration guides

Changed ​

  • License changed from MIT to CC BY-NC-SA 4.0
    • Requires attribution for all uses
    • Prohibits commercial use
    • Requires derivative works to use the same license
    • See LICENSE file for full details
  • Documentation updated to accurately reflect actual command implementations
    • Fixed /timer start parameters (no duration/description - uses label and theme)
    • Corrected /watched commands syntax
    • Updated /eggshen-config documentation to match real subcommand structure
    • Removed fictional moderation commands that don't exist
    • Fixed all search command examples to use correct parameter syntax
  • Documentation home page features updated
    • Replaced "Smart Notifications" with "Smart Auto-Detection" feature
    • Updated logo to transparent version with reduced file size

Fixed ​

  • Critical interaction handling bugs preventing production crashes
    • Added defensive checks before all interaction.reply and deferReply calls
    • Wrapped error handler in try-catch to prevent cascading failures
    • Fixed timer command timeout issues (3-second interaction expiry)
    • Applied fixes to movie, tv, game, boardgame commands and select handlers
  • Ghost timer persistence issue resolved

Pre-1.0 Development Releases ​

0.4.0 - 2026-06-21 ​

Added ​

  • Complete VitePress documentation site with GitHub Pages deployment
  • Custom cyan theme (#2AB5E5) with tinted grays for brand consistency
  • Comprehensive documentation covering all features and commands
  • Bot logo integration in documentation navbar
  • Documentation sections:
    • Getting Started guide
    • Complete installation instructions
    • Configuration guide
    • Search commands reference
    • Watch party commands reference
    • Admin configuration reference
    • Moderation commands reference
    • Rate limiting system documentation
    • Moderation tools guide
    • Watch history feature documentation
    • Statistics feature documentation
    • Notifications feature documentation (planned)
    • API reference for developers
    • Changelog (this file)

Changed ​

  • README now includes documentation badge and link
  • README simplified with documentation site as primary reference

0.3.0 - 2026-06-19 ​

Changed ​

  • [BREAKING] Watch history saves are now PUBLIC instead of ephemeral
    • Removed ephemeral flag from watch history modal submissions
    • Watch history entries now appear publicly in the channel
    • Changed display text from "Added by" to "Saved by" for clarity
  • [BREAKING] Watch history now tracks server-level viewing, not personal ratings
    • Removed rating field from watch history modal
    • Removed rating from all watch history displays
    • Removed rating parameter from /watched add command
    • Watch history is now a community feature, not personal tracking

Added ​

  • Channel tracking for watch history entries
    • channelId field stores where content was watched
    • channelName field displays human-readable channel name
    • Watch history displays now show channel links
    • Useful for tracking which channels host watch parties

Fixed ​

  • Watch history save button permission system updated
    • Button now uses timer starter's userId instead of button clicker
    • Permission check allows timer starter OR moderators/admins
    • Only users with proper permissions see "Log to Watch History" button functionality
    • Prevents unauthorized users from logging watch history

0.2.0 - 2026-06-15 ​

Added ​

  • Comprehensive rate limiting system (7 layers):
    • Per-user cooldowns (3 seconds between commands)
    • Guild-wide rate limits
    • Pattern detection for abuse
    • Abuse logging with mod channel integration
    • Auto-ban threshold (temporary bans after violations)
    • Manual cooldown controls for moderators
    • Whitelist mode for emergency situations
  • Moderation commands:
    • /cooldown add - Apply manual cooldowns to users
    • /cooldown remove - Remove cooldowns early
    • /cooldown list - View active cooldowns
    • /cooldown status - Check specific user's cooldown status
    • /ban-status - Check user's auto-ban status
    • /ban-remove - Remove auto-bans
    • /ban-history - View ban/violation history
    • /abuse-log - View recent abuse incidents
    • /abuse-patterns - Analyze user behavior patterns
    • /whitelist-mode - Enable/disable emergency mode
    • /whitelist add/remove/list - Manage whitelisted users
  • Abuse logging system:
    • Configurable per-server
    • Logs to dedicated mod logs channel
    • Tracks violations, auto-bans, and manual actions
    • Pattern detection algorithms
  • Guild configuration system:
    • Per-server settings storage
    • Persistent configuration across bot restarts
    • Admin-only configuration access
  • Statistics tracking:
    • Command usage statistics
    • User activity tracking
    • Watch party statistics
    • Content popularity metrics
    • Rate limit effectiveness metrics

Changed ​

  • /eggshen-config command expanded with new settings:
    • rate-limiting - Enable/disable rate limiting
    • abuse-logging - Enable/disable abuse logging
    • mod-logs-channel - Set dedicated moderation logs channel
  • Permission system clarified:
    • Watch history saves require timer starter OR admin/mod permissions
    • Configuration requires Administrator or Manage Server permissions
    • Moderation commands require Moderate Members or Administrator permissions

0.1.0 - 2026-06-01 ​

Added ​

  • Initial release of Egg Shen Bot
  • Core search commands:
    • /movie <title> - Search for movies
    • /tv <title> - Search for TV shows
    • /episode <show> <season> <episode> - Search for specific episodes
    • /episode-list <show> <season> - List all episodes in a season
    • /game <title> - Search for video games
  • Watch party timer system:
    • /timer start - Start countdown timer
      • Optional label parameter for what you're watching
      • Optional duration parameter (1-600 minutes) for auto-stop
      • Optional theme parameter (modern or classic)
    • /timer stop - Stop active timer
    • /timer status - Check timer status
    • Interactive "Log to Watch History" button on completion
  • Watch history tracking:
    • /watched add - Add to watch history manually (searches movies and TV)
      • title parameter (required)
      • notes parameter (optional)
    • /watched history - View server watch history with filters
    • Integration with timer system for automatic logging
  • Utility commands:
    • /random <type> - Get random movie/TV show suggestion
    • /similar <type> <title> - Find similar content
    • /stats [type] - View bot statistics
    • /help [command] - Get help with commands
  • Admin commands:
    • /eggshen-config <setting> <value> - Configure bot for server
    • /eggshen-config view - View current configuration
    • /eggshen-config reset - Reset to defaults
  • External API integrations:
    • TMDB (The Movie Database) - Required, primary data source
    • OMDB (Open Movie Database) - Optional, additional ratings
    • Trakt.tv - Optional, watch tracking integration
    • RAWG - Optional, game database
  • Rich embeds with:
    • Movie/TV show information
    • Ratings from multiple sources (IMDB, Rotten Tomatoes, Metacritic)
    • Cast and crew information
    • Streaming availability via JustWatch
    • Genre, runtime, release dates
    • Poster images and thumbnails
  • Discord.js v14 features:
    • Slash commands
    • Button interactions
    • Select menus
    • Modal forms
    • Ephemeral messages (later changed for watch history)
  • Environment variable configuration
  • Error handling and logging
  • ES module architecture

Technical Details ​

  • Built with Node.js 20+
  • Discord.js v14.14.1
  • ES modules (ESM) architecture
  • In-memory data storage with optional persistence
  • RESTful API integration with external services

Versioning Guide ​

Version Number Format: MAJOR.MINOR.PATCH ​

  • MAJOR - Incompatible API changes, breaking changes
  • MINOR - New features, backwards-compatible functionality additions
  • PATCH - Backwards-compatible bug fixes

Change Categories ​

  • Added - New features
  • Changed - Changes to existing functionality
  • Deprecated - Soon-to-be-removed features
  • Removed - Removed features
  • Fixed - Bug fixes
  • Security - Security vulnerability fixes

How to Update This Changelog ​

For AI Assistant / Contributors:

When making changes to functionality, always update this changelog following these guidelines:

1. Determine Change Type ​

  • Added - Created new feature, command, or capability
  • Changed - Modified existing behavior, updated functionality
  • Fixed - Corrected bugs or errors
  • Removed - Deleted features or commands
  • Security - Fixed security issues

2. Write Clear Descriptions ​

Good:

markdown
### Changed
- **[BREAKING]** Watch history saves are now PUBLIC instead of ephemeral
  - Removed ephemeral flag from modal submissions
  - Entries now visible to all server members
  - Changed "Added by" to "Saved by" for clarity

Bad:

markdown
### Changed
- Changed watch history
- Updated some things

3. Mark Breaking Changes ​

Use **[BREAKING]** prefix for changes that require user action or change expected behavior:

markdown
### Changed
- **[BREAKING]** Rating field removed from watch history

Group related changes under the same category:

markdown
### Added
- Watch history channel tracking
  - Added channelId field
  - Added channelName field
  - Updated displays to show channel links

5. Update Version Number ​

Follow semantic versioning:

  • Breaking changes β†’ Increment MAJOR (1.0.0 β†’ 2.0.0)
  • New features β†’ Increment MINOR (1.0.0 β†’ 1.1.0)
  • Bug fixes β†’ Increment PATCH (1.0.0 β†’ 1.0.1)

6. Add Date ​

Use format: YYYY-MM-DD

markdown
## 1.3.0 - 2026-06-21

7. Update Unreleased Section ​

Move items from Unreleased to the new version section when releasing.


Egg Shen β€” A Discord bot for movie, TV, video and board gaming, and book communities.