Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
setAuthor(decisión post-revisión: el header del bot ya identifica al bot, no hace falta repetirlo)Xiza Bot vX.Y.Zprivate:opt-in (default público para casi todo, excepto info/admin commands)Highlights por comando
messageDeleteshuffle()ahora es puro6 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íaOtros refactors técnicos relevantes
Métricas
Test plan