Skip to content

release: v0.4.0 — facelift completo + 6 comandos complementarios#63

Merged
Xiza73 merged 20 commits into
masterfrom
dev
May 8, 2026
Merged

release: v0.4.0 — facelift completo + 6 comandos complementarios#63
Xiza73 merged 20 commits into
masterfrom
dev

Conversation

@Xiza73

@Xiza73 Xiza73 commented May 8, 2026

Copy link
Copy Markdown
Owner

Release v0.4.0

Hito grande: los 18 slash commands originales pasaron por el facelift completo, y se agregan 6 comandos nuevos. Bot va de 18 → 24 comandos.

Highlights

Branding consistente en los 18 comandos

  • Paleta Discord-native: info azul (#5865F2), fun amarillo (#FEE75C), mod rojo (#ED4245)
  • Sin setAuthor (decisión post-revisión: el header del bot ya identifica al bot, no hace falta repetirlo)
  • Footer estándar Xiza Bot vX.Y.Z
  • Convención de emojis distintos entre title y field names
  • Convención private: opt-in (default público para casi todo, excepto info/admin commands)

Highlights por comando

  • /help — autocomplete, dropdown, botón "Volver al listado"
  • /love — persistencia en Mongo + subcomandos owner para curar overrides (set/reset)
  • /clear — DM al moderador con recap de mensajes eliminados (texto + archivos), evita duplicar con el listener messageDelete
  • /poke — color por tipo del Pokémon, autocomplete, error handling
  • /shuffle — animación de sorteo cuando se piden ganadores; helper shuffle() ahora es puro
  • /ahorcado — solo play, dedup de letras repetidas, eliminación por palabra-completa errada
  • /ruleta — embed alive branded, idle timeout, validación de duplicados

6 nuevos comandos

  • /about (info) — versión, stack, link al repo
  • /uptime (info) — tiempo activo + relative `<t:X:R>`
  • /changelog (info) — hardcoded source con autocomplete por versión
  • /feedback (info) — DM al owner con `anonymous:` opt-in
  • /team (fun) — divide lista en N equipos balanceados (round-robin)
  • /cum (mod, owner-only) — dispara manualmente el cron de cumples del día

Otros refactors técnicos relevantes

  • Static manifests (PR refactor: replace dynamic loaders with static manifests #50) — los loaders dinámicos con `readdirSync` + `import()` reemplazados por arrays estáticos. Compatible con `tsx watch` y file watchers.
  • Ephemeral migration — todos los `ephemeral: true` migrados a `flags: MessageFlags.Ephemeral` (silencia el deprecation warning de discord.js v14)
  • Helper compartidos — `formatUptime`, `nextBirthdayDate`, `shuffle` puro
  • i18n — strings del bot en español neutral (tuteo)

Métricas

Test plan

  • CI verde en cada PR del milestone
  • `pnpm test` → 284/284
  • `tsc --noEmit` → limpio
  • Validado en Discord comando por comando durante el desarrollo

Xiza73 and others added 20 commits May 6, 2026 19:23
Vitest es ESM-only y rompe cuando el loader hace require() en runtime
sobre los .test.ts/.test.js que viven al lado del codigo fuente.
El filtro `isLoadable` ahora excluye esa extension ademas de .d.ts.
Replace voseo (vos/tenes/usa) with tuteo (tu/tienes/usa) in Discord-facing strings across slash commands.

Excluded by design:

- words.ts hangman vocabulary (hermano is literal brother in a family-words set; not slang)

- goodMorning.ts Friday easter egg (intentional group humor, not a functional message)
…d identity (#46)

* feat(branding): add brand constants + autocomplete + examples plumbing

Sets up the shared infrastructure for the slash command facelift
that the next commit will apply to /help (and that subsequent
PRs will apply to the rest of the bot).

New module — src/shared/constants/branding.ts:
- BOT_BRAND_NAME = "Xiza Bot" (replaces ad-hoc "Botito" / dynamic
  client.user?.username strings in user-facing branding)
- BOT_VERSION read from package.json at runtime (so /help and
  similar commands pick up version bumps automatically)
- CATEGORY_COLOR map (Discord-native palette: blurple/yellow/red
  for info/fun/mod) — colorForCategory() helper falls back to
  blurple for unknown categories
- CATEGORY_EMOJI map (ℹ️ 🎉 🛡️) — emojiForCategory() helper
- capitalize() lifted out of /help for reuse

Type extensions — src/shared/types/main.ts:
- ISlashCommand.examples?: string[] (optional, for command-specific
  usage examples shown in /help detail view)
- ISlashCommand.autocomplete?: (client, interaction) => Promise
- SlashCommandsOptions.autocomplete?: boolean (the Discord API flag)

Dispatcher — src/events/client/interactionCreate.ts:
- Routes interaction.isAutocomplete() interactions to
  command.autocomplete if declared. Best-effort try/catch around
  the call (autocomplete has no error reply channel, so we just
  swallow). Returns early; non-autocomplete path unchanged.
- 3 new tests in interactionCreate.test.ts covering: routing to
  the handler, unknown command short-circuit, command without
  handler short-circuit.

Brand rebrand — src/slashCommands/mod/say.ts:
- "Botito repite lo que dices" → "Xiza Bot repite lo que dices"
  (the only hardcoded brand name in src/; everything else either
  uses client.user?.username dynamically or didn't reference the
  bot name).

Suite size: 131 → 134 tests, all passing in ~27s.

* feat(help): full facelift — ephemeral default, autocomplete, options, branding

Rewrites /help with the 8 functional + 7 aesthetic improvements
agreed in the planning round.

Functional:
1. Detail view now shows the command's options (name, required
   flag, description) in a dedicated field. Previously omitted —
   the most useful info for users was hidden.
2. Autocomplete handler for both 'command' and 'category' options.
   Substring match against the visible registry, capped at Discord's
   25-choice limit.
3. ownerOnly commands are filtered from non-owners' listings AND
   non-owners' detail-view lookups (returns the same "no existe"
   ephemeral notice — doesn't leak the command's existence).
4. New 'public' boolean option. Default ephemeral so /help doesn't
   spam the channel; opt-in to public via public:true.
5. New 'category' option to filter the listing to one category.
6. Footer hint "/help para ver todos" in detail view; "usa /help
   solo para ver todos" in filtered listing.
7. examples[] from the new ISlashCommand field shown when present.
   Populated for /help itself; remaining commands will fill it in
   as they get their own facelifts.
8. Footer carries BOT_VERSION (read from package.json at runtime,
   so version bumps propagate without code edits).

Aesthetic:
A. Brand color per category — info blurple, fun yellow, mod red.
   Listing uses info-color when unfiltered; filtered listings and
   detail views inherit the focused category's color.
B. Categories prefixed with their emoji + Capitalize().
C. Bot avatar as embed thumbnail in the listing.
D. Footer with "Xiza Bot vX.Y.Z" + a contextual tip.
E. Author block with "Xiza Bot" + bot icon on every embed.
F. Listing vs detail differentiated by structure (description block
   for the command in detail, hint description in listing).
G. Command names rendered as **`/name`** — bold + monospace stand
   out from the surrounding text in field values.

Tests:
- 7 new tests in help.test.ts covering: ephemeral default,
  public:true opt-in, category filter, ownerOnly hidden from
  non-owner listing AND detail view, ownerOnly visible to owner,
  options/examples shown in detail view, autocomplete substring
  match, autocomplete owner-aware filtering, autocomplete for
  category option.
- Existing 3 tests still pass (the rewrite preserves their
  contract: reply called, embed shape, ephemeral on unknown).

Suite size: 134 → 142 tests, all green.

* fix(help): pin package version, conditional footer, add select menu

- bump package.json from default 1.0.0 to 0.3.2 so footer reflects real version
- only show '/help solo para ver todos' hint when filtering by category
- add StringSelectMenuBuilder dropdown to the listing with 60s collector
  so users can drill into a command's detail without retyping /help

* feat(help): add back button + migrate to MessageFlags.Ephemeral

- replace one-shot awaitMessageComponent with persistent
  createMessageComponentCollector that handles both select and back-button
- detail view now shows '← Volver al listado' button to restore the listing
  in-place without retyping /help
- bump collector timeout to 120s now that interaction is multi-step
- migrate 'ephemeral: true' → 'flags: MessageFlags.Ephemeral' in help.ts
  to silence the v14 deprecation warning (rest of codebase deferred to its own PR)
- update help.test.ts assertions to check payload.flags accordingly

* chore(deprecation): migrate ephemeral → MessageFlags.Ephemeral

discord.js v14.13+ deprecated the 'ephemeral: true' shorthand in favor of
'flags: MessageFlags.Ephemeral'. Migrate every remaining occurrence across
the slash commands and the interactionCreate event handler so the bot no
longer logs the deprecation warning at runtime.

source files:
- events/client/interactionCreate.ts
- slashCommands/info/gmi2.ts
- slashCommands/mod/{clear,cums,nextcum,register,say}.ts
- slashCommands/fun/{ahorcado,imc,poke,ruleta}.ts

test assertions updated to check payload.flags against MessageFlags.Ephemeral.
#47)

- replace channel.send + delete pattern with deferReply + editReply
  (works in DMs/threads, doesn't need Send/Manage Messages on the channel)
- description in Spanish ("Mide la latencia del bot")
- new 'private' boolean opt-in (default: public so the whole channel sees it,
  inverse of /help which is ephemeral by default since its listing is noisy)
- four metric fields with emoji: 🏓 Round-trip, 📡 API, ⏰ Uptime, 📦 Versión
- Xiza Bot author + thumbnail + footer (${BOT_BRAND_NAME} ${BOT_VERSION})
- info-category color (#5865F2 blurple) instead of "Random"
- clamp ws.ping to >= 0 (it returns -1 right after connect)

new helper: formatUptime(seconds) → "3d 4h 12m" / "15m 7s" / "42s"
mocks: added createdTimestamp to discord-mocks for round-trip math
)

- replace plain-text reply with branded embed (info color, Xiza Bot author/footer)
- show 5 metadata fields with emoji: 📁 Canal, 🆔 ID (in backticks for tap-to-copy),
  🏷 Tipo (friendly label per ChannelType), 📂 Categoría (only when parent exists),
  🔞 NSFW (only when flagged true)
- new 'public' boolean opt-in (default: ephemeral, same as /help — channel-id is
  typically a personal lookup, not a group broadcast)
- defensive fetch: try client.channels.fetch first, fall back to interaction.channel,
  ephemeral notice if neither is available

mocks: client.channels.fetch added to createMockClient with a default
GuildText shape so other channel-fetching tests inherit it for free.
Reemplaza los 3 loaders dinamicos (readdirSync + import()) por manifests estaticos para que tsx watch y cualquier file watcher detecten cambios en commands/events/routes automaticamente.

Manifests creados:

- src/slashCommands/index.ts (18 commands)

- src/events/index.ts (4 events)

- src/api/routes/index.ts (2 routes)

Cambios adicionales:

- shared/types/main.ts: agrega tipo IBotEvent

- handlers/index.ts: simplifica loadEvents/loadSlashCommands (-86 lineas)

- api/router.ts: arregla race condition latente al reemplazar la IIFE async que montaba rutas en background contra app.listen

- Cast TypeScript en handlers para exponer deuda preexistente que el manifest revelo (ISlashCommand vs ApplicationCommandDataResolvable)
- remove setAuthor from /help (list + detail), /ping, /channel-id embeds.
  Discord already shows the bot account name above each embed and the
  footer already says 'Xiza Bot vX.Y.Z' — repeating the name in setAuthor
  was a third copy that read as egocentric.
- /ping: '🏓 Round-trip' → '📶 Round-trip' so the title's 🏓 (Pong!) icon
  isn't repeated next to the first field.
- buildDetailEmbed (help) and buildEmbed (channel-id) no longer take the
  client param — it was only used to pull the avatar for the author block.

Convention going forward (for the remaining 15 slash commands):
no setAuthor on embeds. Footer-only. Distinct emojis between title and
field names.
…#51)

- description and labels in Spanish: 'Tira una moneda al aire', '👑 Cara' / '🛡 Cruz'
- title '🪙 Moneda al aire' (distinct from body emojis per branding convention)
- color colorForCategory('fun') (#FEE75C) instead of 'Random'
- footer 'Xiza Bot vX.Y.Z' (no setAuthor, no 'Requested by …')
- new 'private' boolean opt-in (default: public — fun stuff is meant to share)
- aggregate totals line when multiple coins ('Total: 3 caras · 2 cruces')
- ephemeral notice when coins < 1 instead of silently clamping to 1
- option type tightened: Number → Integer (you can't flip half a coin)
- description in Spanish: 'Tira dados (con imagen para d4, d6 y d12)'
- title '🎲 Dados', color colorForCategory('fun') (#FEE75C)
- footer 'Xiza Bot vX.Y.Z' (no setAuthor, no 'Requested by …')
- new 'private' boolean opt-in (default: public)
- description always set (was empty when an image was attached):
  - single roll → 'Sacaste un X (d6)'
  - multiple → '4d20: 12, 7, 18, 3 — Total: 40'
- ephemeral notices when quantity < 1 or sides < 2 (was silently clamping to 1)
- option types tightened: Number → Integer
- internal cleanup: composeDiceImage extracted, % math instead of nested col/row counter,
  IMAGE_AVAILABLE_SIDES typed as readonly tuple
* feat(shuffle): facelift + fix shuffle helper mutation

shuffle command:
- description in Spanish: 'Mezcla aleatoriamente una lista de palabras o números'
- titles with emoji distinct per case: '🔀 Lista aleatoria', '🏆 Ganador',
  '🏆 Ganadores (N)', '🔀 Números (1–N)'
- color colorForCategory('fun'), footer 'Xiza Bot vX.Y.Z' (no setAuthor)
- list parser now accepts comma, semicolon or whitespace as separators
  (was: whitespace only — 'ana, bob' parsed as ['ana,', 'bob'])
- enumerate output with bold numbers ('**1.** ana')
- new 'private' opt-in on both subcommands (default: public)
- validations with ephemeral notices (was silent clamp):
  * words: list needs >= 2 items, winners >= 1, winners auto-caps to list length
  * numbers: quantity >= 1, max raised from 20 → 50
- removed local shuffle() copy — now uses the shared helper

helpers.shuffle:
- BUG FIX: was mutating the input array via in-place swap loop. Now returns a
  new array (copy + Fisher-Yates on the copy). The /shuffle command was the
  only caller relying on the bug — it had a private non-mutating implementation
  to work around it. /ahorcado was the other caller; it doesn't reuse the input
  after shuffling so behavior is unchanged.
- new tests: explicit non-mutation assertion + new-reference assertion

* feat(shuffle): animated raffle reveal for /shuffle words winners:N

When 'winners' is set, the command now defers the reply, edits 3 teaser frames
showing successive shuffles of the list (~600ms apart), then edits one final
time with the winners embed. Total wait ≈ 1.8s for the dramatic reveal.

The teaser uses title '🎰 Sorteando...' so it's visually distinct from the
final '🏆 Ganador(es)' reveal. Animation respects 'private:true' — the defer
carries the Ephemeral flag so the whole sequence runs ephemerally if requested.

Other paths unchanged:
- /shuffle words (no winners) → instant reply
- /shuffle numbers           → instant reply

Tests use vi.useFakeTimers() + vi.runAllTimersAsync() to drive the sleeps,
so the suite still completes in milliseconds.
#54)

* feat(love): facelift — embed, compat %, heart bar, self-ship, deterministic

- description in Spanish: 'Shippea a dos personas y calcula su compatibilidad'
- replaces plain-text reply with a branded embed:
  * title '💘 Shipping', fun-category color (#FEE75C), brand footer (no setAuthor)
  * description: '<@U1>  +  <@u2>'  (suppressed pings via allowedMentions)
  * '💯 Compatibilidad' field with a 10-heart progress bar + percentage
  * '💌 Veredicto' field with a phrase chosen from a bucket table
- compatibility is DETERMINISTIC for a given pair: hash([id1,id2].sort().join('-'))
  modulo 101. Same pair always lands on the same %, regardless of argument order.
  This avoids the 'roll till you like the result' anti-pattern.
- self-ship special case: if user1 === user2, force 100% with the phrase
  'El amor propio es el mejor amor.'
- new 'private' opt-in (default: public — it's for sharing)
- 8 verdict buckets ranging from 0-10% ('mejor sigan siendo conocidos') to
  100% ('casamiento ya, compre los anillos')

* feat(love): persist compatibilities + owner subcommands set/reset

Adds Mongo-backed persistence so the owner can edit awkward results.

Architecture:
- src/api/models/Love.ts — schema with pairKey (sorted-id-pair, unique index),
  user1, user2, percentage, verdict, isOverride, setBy, timestamps
- src/api/dao/love.dao.ts — getOrCreatePair (auto-populate on first call with
  the deterministic hash), setOverride (upsert with isOverride=true), resetPair
  (delete row → next call re-auto-populates)
- src/shared/services/love.service.ts — unwraps DAO ResponseData/ErrorHandler
  into plain ILove documents for the slash command

Slash command becomes subcommand-based:
- /love ship user1 user2 [private]
    Public default. Reads from DB; first call for a pair auto-populates with
    the hash-derived percentage. Self-ship (user1 === user2) skips the DB and
    returns 100% with the self-love phrase.
- /love set user1 user2 percentage [verdict] [private]
    Owner-only. Upserts with isOverride=true and setBy=invoker. Optional verdict
    overrides the bucket-based phrase.
- /love reset user1 user2 [private]
    Owner-only. Deletes the row so the next /love ship recomputes the hash.

Conventions established:
- Owner-only subcommands default to ephemeral (private:true unless explicitly
  passed false) — saved to engram as facelift/owner-ephemeral-default.
- Override status is INTENTIONALLY invisible in the embed; users can't tell if
  a result came from the hash or from owner intervention.

Tests: 207/207 (was 187, +20). DAO tested against in-memory mongo, slash command
mocks the service layer.
…55)

- description: 'Calcula tu IMC' → 'Calcula tu Índice de Masa Corporal'
- title with leading emoji: '🩺 Índice de Masa Corporal'
- brand footer 'Xiza Bot vX.Y.Z' (no setAuthor)
- new 'Peso ideal' field showing the BMI 18.5–24.9 weight range derived
  from the user's height (e.g. '56.7–76.3 kg' for 1.75m)
- 'private' opt-in (default: public)
- DELIBERATE color exception: keeps the data-driven red/yellow/green color
  per BMI bucket (signal-carrying) rather than the fun-category yellow.
  Convention bends when the color encodes meaning, not just brand.
- minor refactor: extracted findRange and idealWeightRange helpers
… handling (#56)

- color is now driven by the Pokémon's first type (fire→#ee8130, water→#6390f0,
  grass→#7ac74c, etc.) — same signal-carrying exception as /imc, vs the old
  hardcoded blue 0x0099ff that ignored the species
- title prefixed with 🔴 (Pokéball)
- footer 'Xiza Bot vX.Y.Z' on both pokemon and types-list embeds
- autocomplete on /poke type name: typing 'fi' suggests fire and fighting
- private opt-in on all three subcommands (default: public)
- defensive fetch wrapper: PokéAPI failures now reply with a friendly
  'no respondió bien, intentá de nuevo' instead of throwing into errorHandler
- refactor: inline embed objects → EmbedBuilder, helpers extracted
  (pickSprite, colorForFirstType, fetchJson)

note on error visibility: the friendly error inherits the visibility chosen
at deferReply time. editReply can't change ephemerality after defer, so a
public /poke that fails will show the error publicly.
…death (#57)

* feat(ruleta): facelift — branded kickoff, idle timeout, signal-color death

- kickoff is now a branded embed (fun yellow) listing every player and the
  starting turn, replacing the plain-text 'Empieza @user' message
- death embed: hardcoded 0xED4245 (mod-red) instead of 'Random' — death/
  game-over carries a signal, same convention exception as /imc
- death and abandoned embeds get the standard brand footer
- collector now has a 5-min idle timeout (`idle: IDLE_TIMEOUT_MS`); on idle
  the bot announces 'Juego abandonado' instead of leaking a dangling collector
  forever when the channel goes silent
- titles: '🔫 Ruleta rusa' kickoff, '💀 Los soplones, pum pum pum…' on death,
  '⌛ Juego abandonado' on idle
- alive messages kept verbatim — they're the on-brand humor of this bot

Tests: kickoff embed shape + branding, players-list rendering, collector idle
option presence, filter behavior, abandoned path is exercised via the 'idle'
end reason.

* fix(ruleta): solo play, embed for alive updates, reject duplicate players

3 issues caught in review:

1. Solo play restored: player2 is now optional (was required: true). With no
   extra players the kickoff embed says '<user> juega solo' and the title field
   shows '👤 Jugador' singular. Description and examples updated.
2. Alive updates were plain text — now they're a fun-yellow branded embed with
   the standard footer, matching the rest of the game embeds. New helper
   buildAliveEmbed(); in solo mode the 'Turno de' line is omitted so we don't
   repeat the player's name twice per roll.
3. Duplicate player rejection: if the same user appears in 2+ slots (or the
   initiator lists themself as player2), we bail with an ephemeral notice
   before fetching users or registering the collector.

Tests: +4 (solo kickoff shape, duplicate rejection, self-as-player2, alive
embed branding).

* fix(i18n): purge voseo from user-facing strings missed during facelift

The 'español neutral' convention (engram: i18n/spanish-neutral) was set in
the v0.3.x i18n refactor — bot-emitted strings stay in tuteo, no rioplatense.
Recent facelift PRs slipped in a bunch of voseo. Sweep:

- ruleta.ts: 'escribí' → 'escribe' (×3), 'jugás solo' → 'juegas solo',
  'No podés' → 'No puedes'
- love.ts:   'Probá de nuevo' → 'Prueba de nuevo'
- poke.ts:   'Intentá de nuevo' → 'Intenta de nuevo'
- imc.ts:    'si pasás un valor' → 'si pasas un valor'
- help.ts:   'elegí un comando' → 'elige un comando' (description),
             'Elegí un comando' → 'Elige un comando' (select placeholder)
- register.ts: 'Necesitás permiso' → 'Necesitas permiso'
- 'solo a vos' → 'solo a ti' across all 'private:'/'public:' option
  descriptions in flip, imc, love, poke, roll, shuffle, channel-id, help, ping

Code comments and rioplatense in chat replies left alone — convention is
only about strings the bot emits to Discord users.
…e colors (#58)

* feat(ahorcado): facelift — branding, solo play, idle timeout, win/lose colors

- title prefixed with 🎯 (distinct from /ruleta's 🔫); end-state title swaps
  to 🏆 on win and 💀 on loss
- color: fun yellow during play; signal-carrying green (#57F287) on victory
  and mod red (#ED4245) on defeat — same convention exception as /imc, /ruleta
- footer 'Xiza Bot vX.Y.Z' on every embed
- inline embed objects → EmbedBuilder, helpers extracted (buildKickoffEmbed,
  buildTurnEmbed, buildEndEmbed, buildAbandonedEmbed)

functional fixes:
- player2 now optional. Solo play allowed only when bot_picks_word:true
  (otherwise you'd be guessing your own word — rejected with ephemeral notice)
- duplicate player rejection (covers 'player2 == player3' and 'player2 == self'),
  matching the /ruleta validation
- collector idle timeout 5min, then '⌛ Ahorcado abandonado' embed —
  fixes the same dangling-collector issue we hit in /ruleta
- explicit collector.stop('end') on win/lose so 'idle' end reason is clean

other:
- description neutral Spanish: 'Tú eliges' → 'Eliges' (drops the explicit
  pronoun — both are neutral but Eliges flows better)
- WORD_PICK_TIMEOUT_MS named constant for the DM word-picking phase

tests: +4 (solo allowed, solo+pick=false rejected, duplicate rejection,
self-as-player2)

* fix(ahorcado): default bot_picks_word to true

The DM-with-menu word-picking flow was a friction wall to start a game. Most
plays just want to roll into the round; flipping the default makes `/ahorcado`
single-tap to launch and `/ahorcado bot_picks_word:false` the explicit opt-in
for the DM-picks path.

- default: false → true
- description and examples updated (`/ahorcado` now lists '/ahorcado',
  '/ahorcado player2:@bob', '/ahorcado player2:@bob bot_picks_word:false')
- bot_picks_word option description spells out the new default
- solo+false rejection text updated ('No pongas bot_picks_word:false')
- tests: solo no-args path renamed and exercises the default; explicit
  bot_picks_word:false test exercises the rejection branch

* fix(ahorcado): solo play needs >= 2 player IDs at the library level

death-games' Hangman constructor throws 'Debes proporcionar mínimo 2 ID's
en la opcion jugadores' when given a single-element array. Solo play with
the new bot_picks_word=true default crashed at runtime.

Workaround: when solo, duplicate the player's ID for the library
(libPlayerIds = [id, id]) while keeping the user-facing display as a single
player. Turn rotation alternates between two slots that resolve to the same
user, so the collector filter (msg.author.id === game.turno) keeps matching
and the player just gets every turn — which is what solo play means.

Tests passed because the death-games mock skips the validation; only real
Discord runtime hit the throw.

* fix(ahorcado): dedup repeats, full-word elimination, fix turn-tracker semantics

Three fixes in one pass — the user reported (1) and (2), and I caught (3)
while rewriting the turn loop.

1. Repeated letter no longer consumes a life
   The death-games library decrements 'vidas' every time find() sees a letter
   that's already in letrasUsadas (covers BOTH correct and incorrect repeats —
   library code lines 56-62 and 83-87). Adding the dedup check on our side
   before calling find() means typing 'b' twice no longer eats a second life.
   The repeating player keeps their turn and gets a friendly notice instead.

2. Wrong full-word guess eliminates the player
   The library treats multi-char input as just letra[0] (line 52: 'letra =
   letra[0]'), so a wrong full-word guess silently became a single-letter
   guess of the first character. Now we intercept full-word guesses ourselves:
   correct → win path (revealing every letter); wrong → eliminate that player
   (Set<string>, ignored by collector filter, skipped in turn rotation).
   When everyone is eliminated, game ends with a dedicated all-eliminated
   embed (red, 💀).

3. Turn-tracker semantics swap (latent bug from the facelift)
   My earlier refactor pre-rotated 'turn' after the kickoff and used it as the
   current-turn index in the filter — so kickoff said 'Empieza A' but the
   filter expected B's input. Solo play hid this because libPlayerIds are
   duplicated; multi-player would have stalled on the very first guess.
   Now 'turn' = the CURRENT player whose turn it is. Kickoff uses turn=0,
   collect handler uses turn for the filter, advances after processing.

Helpers added:
- buildSkipEmbed   — 'X ya probó la letra Y, sigue su turno'
- buildEliminationEmbed — '☠️ Jugador eliminado' with the failed guess
- buildAllEliminatedEmbed — '💀 Todos eliminados' end embed
- peekNextTurn(from) — returns the next non-eliminated index, or loops if
  every slot is eliminated (caller checks aliveUnique.size beforehand)

Tests: +1 (filter matches the current-turn player, not pre-rotated). The
dedup/elimination paths are integration-tested; the unit-test mocks would
need a major rewrite to capture the constructed Hangman instance and
trigger the collect handler in isolation. The user validates in Discord.
Behavior change: channel.send fires BEFORE the ephemeral confirmation. If the
bot lacks Send permission in the channel and channel.send throws, the catch
fires and errorHandler can still reply because the interaction hasn't been
replied to yet. The original ordering replied first, so a failed send left
the user with 'Listo.' but no actual message in the channel.

Cosmetic:
- description: 'Xiza Bot repite lo que dices' → 'Repite un mensaje en el canal'
- option message: 'Texto a repetir' → 'Texto a publicar'
- option as_embed: 'Mandarlo como embed' → 'Publicar como embed (default: false)'
- confirmation: 'Listo.' → '✅ Mensaje enviado.'
- examples added

Branded as_embed embed intentionally keeps the white color and NO footer —
this command is a moderator amplifying a message, not a bot-branded
announcement. The brand convention applies to bot-voice embeds, not to
content the bot is repeating on someone's behalf.

Tests: send-before-reply ordering, embed-shape (no footer/author), existing
perm-rejection + ManageMessages check kept.
* feat(mod): facelift /clear, /who, /register — branding + bug fixes

Batch facelift for the destructive/admin half of mod (the birthday-listing
half, /cums and /nextcum, comes in a second PR).

/clear:
- branded result embed (mod red, footer 'Xiza Bot vX.Y.Z'): '🧹 Chat limpiado'
  ephemeral to the moderator AND public auto-deleting in the channel
- friendly error embed when bulkDelete throws (eg. >14d old messages, missing
  bot perms): '❌ No se pudo limpiar el chat'
- removed dead code: triple parseInt, unreachable 'if (!amount)' branch (since
  amount > 0 was already validated)
- removed duplicate permission check that re-checked the SAME member's perms
  with a 'Maybe the bot can't delete messages' comment that didn't match the
  code — the actual bot-perm check belongs at Discord's Send/Manage gate or
  the bulkDelete throw, not a redundant member check
- amount default 1, capped at config.maxDeleteMessages, rejected if <= 0
- description tightened: 'Limpia el chat' → 'Limpia mensajes del canal en bloque'

/who:
- branded embed (mod red, footer): title kept as '🎂 CUMpleaños' (the wordplay
  is the charm)
- field name was a single space ' ' as a layout hack — now '📅 Fecha'
- description shows the Discord mention AND the real name
- new 'private' opt-in (default: public — birthday lookups are usually shared)

/register:
- branded result embed (mod red, footer): '✅ Miembro registrado' on success,
  '❌ Error al registrar' on DAO non-200
- range validation BEFORE the DAO call: day 1–31, month 1–12 → ephemeral
  notice, prevents bad data sneaking into Mongo
- '👤 Miembro' and '🎂 Cumpleaños' fields, with allowedMentions: { users: [] }
  so the embed doesn't ping the registered user
- new 'private' opt-in (default: public — admin op visible to the team)

Tests: clear (+5: bulk-delete embed shape, branding, singular phrasing,
bulkDelete-throws path, ttl-delete via fake timers), who (+2: branded embed
shape, public/private toggle), register (+3: error title path, day range,
month range, private opt-in).

* feat(mod): /clear deletion recap with re-uploaded attachments, richer /who

Two follow-ups on the mod-simple PR after owner review:

/clear:
- Replied-only recap embed (📋 Eliminados (N)) lists each deleted message
  with author, relative timestamp and content. Helps the moderator review
  what got nuked without blasting it back into the channel.
- Image (and other) attachments are downloaded BEFORE Discord drops the
  CDN URL and re-uploaded as fresh files in the recap reply. The user
  asked specifically for files, not embedded URLs — embedded image URLs
  can break in seconds once the source message is deleted; uploaded files
  stay alive.
- Caps: ≤ 10 attachments per Discord's per-message limit, ≤ 3.8 KB recap
  text per the 4096-char embed description budget. Truncation footer when
  hit.
- Empty-result branch: bulkDelete returning 0 messages now shows a friendly
  '🧹 Nada para limpiar' embed (Discord can't bulk-delete >14d messages).
- Public auto-deleting count notice in the channel kept as before — it's
  for context, the recap is for the moderator.

/who:
- Real name is now the embed title (was '🎂 CUMpleaños'); the wordplay
  was cute but buried the actual identity, which is what the moderator
  is looking for.
- Description is the Discord mention.
- Fields: 👤 Discord (mention + tag), 🎂 Cumpleaños, 📅 Próximo cumple
  (Discord <t:UNIX:R> relative time), 📆 Cuenta creada. When in a guild,
  also: 🎉 En el servidor desde, ✨ Roles principales (top 3 by position,
  excluding @everyone).
- Helper next-birthday calc rolls into next year if the day already passed.
- allowedMentions: { parse: [] } so the embed mention/role tags don't ping.

Tests: clear (+2: recap-shape with multiple deleted messages, fetch-and-
re-upload of attachments, empty-result fallback), who (+2: title-is-real-
name, guild-only fields when guild is present).

* fix(clear): deliver recap via DM, not ephemeral

The recap is an internal log of what got nuked — meant to persist in the
moderator's DM history regardless of who runs the command. Ephemeral was
wrong: it expires and feels like a UI affordance instead of a record.

- Recap goes to interaction.user.createDM().send({ embeds, files })
- Ephemeral reply is now a brief '✅ Listo. Te envié el detalle por DM.'
  confirmation
- DM-fails fallback: if createDM throws (DMs disabled by the moderator),
  reply ephemerally with the recap inline + a 'no te pude enviar DM' note,
  so the moderator never loses the data
- Public auto-deleting count notice in the channel unchanged

Test mock: createMockInteraction now provides a default user.createDM that
returns a stubbed DMChannel. Tests rewritten to assert: (a) DM gets the
recap with embeds+files, (b) ephemeral reply is the brief confirmation,
(c) DM-fails path falls back to inline ephemeral, (d) attachment re-upload
shows up in the DM payload.

* fix(clear): defer first, fetch+download before bulkDelete, drop confirmation

Owner reported three bugs in the DM-recap version:
1. 'Unknown interaction' on multi-message clears
2. Image attachments lost from the DM (only the first message arrived)
3. Pointless 'Te envié el detalle por DM' ephemeral cluttering the channel

Root cause for (1) and (2): the work was sequential and bulkDelete ran
BEFORE we read the messages or downloaded their attachments. With multi-
message clears, the parallel(ish) attachment fetches blew past Discord's
3s acknowledgement window. And once bulkDelete fired, the CDN URLs the
recap relied on were already invalid for any attachment that hadn't been
read yet.

New flow:
1. Acknowledge instantly via deferReply({ ephemeral }) so we have the
   15-min interaction token instead of the 3s reply window.
2. channel.messages.fetch({ limit: amountToDelete }) — capture the
   messages while they're still alive.
3. Download every attachment in parallel via Promise.all on the still-valid
   CDN URLs.
4. channel.bulkDelete(messageIds, true) — delete by ID, since we already
   have the full Message objects we need for the recap.
5. dm.send the recap with re-uploaded files.
6. Public auto-deleting count notice in the channel (unchanged).
7. interaction.deleteReply() removes the deferred ephemeral entirely on
   the happy path — no '✅ Listo' confirmation surfaces in the channel.
   The recap is in DM, the count notice is in the channel; both already
   give all the feedback the moderator needs.

Edge cases preserved:
- DM disabled → editReply with the recap inline + 'no te pude enviar DM'
  (the only path that surfaces an ephemeral, since otherwise the recap
  would be lost).
- bulkDelete returns 0 → editReply with '🧹 Nada para limpiar'.
- bulkDelete throws → editReply with '❌ No se pudo limpiar el chat' +
  the underlying error.

Mocks: createMockInteraction now provides deleteReply. Tests rewritten
to drive the new flow: ordering check via call-order array (fetch →
bulk-delete), DM payload assertions, deleteReply on happy path,
editReply on fallback paths.

Tests: 243/243. Type fix on the parallel-download Promise return
annotation so Buffer<ArrayBuffer> narrows to DownloadedAttachment without
fighting Node 22's stricter Buffer generic.

* fix(mod): /clear skips DM when listener already covers it; messageDelete polish

The messageDelete event listener (events/message/messageDelete.ts) already
DMs the owner for every individual cached delete in the gmi2 channel.
discord.js fires messageDelete for cached messages that go through bulkDelete
too, so the simple /clear amount:1 case was producing two DMs: one from the
listener (single-message embed) and one from /clear (the recap).

Owner spec to dedup:
- /clear amount:1, text-only      → SKIP recap (listener has it)
- /clear amount:1, has attachment → SEND recap (image is high-signal)
- /clear amount:N (N > 1)         → SEND recap (multi-message context)
- manual deletion                 → listener handles it as before

Implementation: const skipRecap = messageArr.length === 1 && !hasAttachments.
The deferReply still happens (we need it during the fetch/download phase
even on the skip path), but the DM step is gated by skipRecap and the
deleteReply runs the same way regardless.

While I was in there, polished the listener:
- 'un imagen' → 'una imagen' typo (well, killed the line entirely — the
  embed-only path makes the duplicate content text redundant)
- Branded: title '🗑️ Mensaje eliminado', mod-red color (#ED4245),
  brand footer, timestamp
- Image and text cases now share the same embed shape — image case still
  re-uploads the URL via files: [url] so it survives the source delete,
  but the embed (with description if any) is the same as the text path.
  Old code dropped to a content-only DM for images, which had no title
  and no footer.
- Description omitted entirely when there's no text (instead of leaking
  empty trailing newlines from string-concat).
- Removed the noisy debug console.logs in the early-return guards.

Tests: 247/247.
- /clear: +2 (single text-only skips DM + still sends count notice; single
  with attachment DOES DM).
- messageDelete listener: +2 (image case carries the same branded embed,
  no-text image case has no description), updated existing assertions to
  the new branded shape.

* fix(messageDelete): drop title, move 'eliminado' hint to footer

The 🗑️ title up top was breaking the illusion the embed should give: the
DM is meant to read like the original message preserved (avatar + name +
content + original timestamp), not as a 'deletion log' entry. Owner asked
for the 'deleted' indicator to live at the end instead.

- setTitle removed
- setTimestamp now uses message.createdTimestamp (when the message was
  posted) rather than new Date() (when it got deleted) — reinforces the
  'this is the message' feel
- Footer text combines the deletion hint with the brand:
  '🗑️ Eliminado · Xiza Bot vX.Y.Z'
- mod-red color stays as the visual signal that something was removed

Tests updated: title is undefined, footer matches new pattern.

* fix: drop brand from messageDelete footer + singular Spanish in /clear count

messageDelete listener:
- Footer now reads just '🗑️ Eliminado' (no brand). The 'Xiza Bot vX.Y.Z'
  trailer was killing the same illusion the title removal was trying to
  preserve — the embed should look like the message itself, not like a
  bot-branded notice.

/clear public count notice:
- 'Se eliminaron **1** mensaje.' → 'Se eliminó **1** mensaje.' (correct
  Spanish singular conjugation). Plural path stays as 'Se eliminaron N
  mensajes.'

Tests: +1 (plural path), restored singular assertion that got dropped in
the earlier flow rewrite.

* fix(who): drop the duplicate mention description

The embed had setDescription('<@{id}>') and a '👤 Discord' field whose
value was '<@{id}> ({tag})' — same mention twice, stacked vertically.
Dropping the description; the field already carries the mention plus the
extra Discord tag context.
…in (#61)

The birthday-listing pair, last batch of mod facelift.

/nextcum:
- Title '🎂 Próximo cumple' (was 'Próximo cumpleaños 🎂' — emoji moved
  to start to match the convention)
- Color: hardcoded green 0x00ff00 → mod red colorForCategory('mod')
- Description now shows real name + Discord mention (was just mention)
- New '⏳ Faltan' field with Discord <t:UNIX:R> relative time so the
  reader sees 'in 8 months' rendered locally
- Brand footer added; allowedMentions: { parse: [] } so the embed mention
  doesn't ping the upcoming birthday person every time someone asks
- New 'private' opt-in (default: public — birthday lookup is community info)

/cums:
- Title '🎂 Cumpleaños del servidor' / '🎂 Cumpleaños de ${month}' when
  filtered (was 'Cumpleaños 🎂', emoji at end)
- Color mod red, brand footer, no setAuthor
- Description shows count summary with singular/plural Spanish:
  '**1** persona registrada' / '**N** personas registradas'
- Empty-states are now branded embeds instead of plain ephemeral text:
  - month filter with 0 results: '🎂 Sin cumpleaños en ${month}'
  - no registrations at all: '🎂 Sin cumpleaños registrados' with a
    pointer to /register
- Out-of-range month rejection now also catches 0 / negative (was only
  > 12 by accident — the 0-indexed conversion masked it)
- New 'private' opt-in (default: public)
- allowedMentions: { parse: [] } so the rendered <@id> mentions in the
  field values don't ping everyone

Helper extracted: nextBirthdayDate(day, month, from?) is now in
shared/utils/helpers.ts since both /who and /nextcum need the
'next-occurrence-or-roll-into-next-year' calculation. /who refactored
to import from there.

Tests: +13 (nextcum: real-name description, fields shape, branding,
private toggle; cums: total count + singular/plural, both empty paths,
sorted-by-day, branding, out-of-range-low, private toggle).
…/feedback, /team, /cum (#62)

Six new slash commands rounding out the bot now that the 18-command facelift
is complete.

/about (info)
- Branded info-color embed with version, stack, and GitHub link.
- 'private' opt-in (default public).

/uptime (info)
- Uses the existing formatUptime helper. Description is the formatted uptime
  string; '🚀 Activo desde' field uses Discord's <t:UNIX:R> for a localized
  relative timestamp.

/changelog (info)
- Hardcoded source at src/shared/constants/changelog.ts. Curatorial, not git
  history — bump a new entry on top when something ships that the server
  cares about.
- 'version' option with autocomplete shows a single version's highlights;
  no arg shows the full list (newest-first), trimmed at 3.8 KB to fit the
  embed description budget.

/feedback (info)
- Required 'message' string + optional 'anonymous' boolean.
- DMs the owner with a branded embed listing the message and either the
  invoker's mention/tag or '_(anónimo)_'. allowedMentions: { parse: [] }
  so the recap doesn't ping anyone.
- Replies ephemerally with '✅ Listo, gracias por tu feedback' on success,
  or a friendly error if the owner DM can't be delivered.

/team (fun)
- Shuffles a list and splits it into N teams via round-robin (max diff of 1
  in team sizes regardless of total). Same separator parser as /shuffle:
  comma, semicolon, or whitespace.
- Validations: teams ∈ [2, 10], list ≥ 2 items, list ≥ teams. Each rejection
  is an ephemeral notice.
- Branded fun-yellow embed with one inline field per team.

/cum (mod, owner-only)
- Manually fires the same logic as the daily birthday cron — useful as a
  test gate for the owner OR to re-broadcast the greeting if the morning
  cron got buried in channel noise.
- Refactor of birthdayReminder.ts:
  • buildBirthdayGreetingEmbed(user) extracted as exported helper so the
    cron and /cum share a single source of the embed.
  • reminder() returns { count } instead of void; the slash command uses
    that to report 'Disparé N saludos' / 'nadie está de cumple hoy'.

Manifest at src/slashCommands/index.ts updated with the six new entries
in their categories (3 in info, 1 in fun, 2 in mod after this lands).

Tests: 284/284 (was 259, +25). Each new command has its own *.test.ts
covering happy path, embed shape (color, footer, no setAuthor), permission
gates and edge cases (out-of-range, empty input, autocomplete, anonymous,
DM failure, count phrasing).
Marks the milestone where:
- The 18 inherited slash commands all got the facelift treatment
  (consistent branding: info blue, fun yellow, mod red; no setAuthor;
  brand footer with version; private-toggle convention)
- Six complementary commands shipped: /about, /uptime, /changelog,
  /feedback, /team, /cum
- Bot grew from 18 → 24 commands

The changelog entry for 0.4.0 was already in place
(src/shared/constants/changelog.ts); this just aligns package.json
so /about and /uptime read the right version at runtime.
@Xiza73 Xiza73 merged commit 5688466 into master May 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant