feat(mod): facelift /clear, /who, /register — branding + bug fixes#60
Merged
Conversation
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).
… /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).
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.
…rmation
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.
…ete 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.
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.
…r 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.
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.
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.
Summary
Batch del facelift de mod — la mitad destructiva/admin. La otra mitad (
/cumsy/nextcum) va en un PR separado./clear
/who
/register
Test plan
Smoke en Discord