From bb1a97d77ef020bf64eeaf0394de77e68af983c7 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Tue, 10 Nov 2026 04:52:24 +0100 Subject: [PATCH 1/2] fix(message-quote, starboard): lisent le texte et les images d'un message Components V2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un message mis en page avec des composants n'a pas de contenu, et ses fichiers ne s'affichent qu'à travers ses composants : sa citation affichait « Message sans texte » et son repost au starboard n'avait que l'en-tête. Les deux lisent désormais ses affichages de texte et ses galeries (#lib/message-content.ts), message transféré compris, ce qui vaut pour les réponses du bot comme pour les messages d'autres apps. Les spoilers posés sur un conteneur, un élément de galerie, une miniature ou un fichier sont conservés, et un fichier montré par une galerie n'apparaît qu'une fois au starboard. --- docs/functional.md | 3 +- src/lib/message-content.test.ts | 174 +++++++++++++++++ src/lib/message-content.ts | 133 +++++++++++++ .../message-quote/services/quote.test.ts | 176 +++++++++++++++++- src/modules/message-quote/services/quote.ts | 27 ++- src/modules/starboard/services/view.test.ts | 148 +++++++++++++++ src/modules/starboard/services/view.ts | 51 ++++- 7 files changed, 693 insertions(+), 19 deletions(-) create mode 100644 src/lib/message-content.test.ts create mode 100644 src/lib/message-content.ts diff --git a/docs/functional.md b/docs/functional.md index 7db3f6e4..18c2d9d0 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -410,6 +410,7 @@ Reposte dans un salon dédié les messages qui reçoivent assez de réactions, e - Si le message d'origine est supprimé, son repost l'est aussi. Si un modérateur supprime le repost, le message n'est plus jamais reposté. - Ne sont jamais repostés : les messages du salon starboard et de ses fils, les messages système, ceux du bot lui-même (dont les reposts d'un ancien salon starboard), ceux des bots et des webhooks (sauf si `ignoreBots` est désactivé), ceux des salons exclus ou hors des salons surveillés, ceux d'un salon NSFW vers un starboard qui ne l'est pas, et ceux des fils privés. - Les salons réservés à un rôle sont repostés comme les autres, même si le salon starboard est visible par des membres qui ne peuvent pas les lire : pour qu'un salon du staff n'y apparaisse jamais, l'ajouter aux salons ou catégories exclus. Un forum, un salon de médias ou une scène ne se sélectionne pas dans les salons exclus : excluez sa catégorie. Exclure un salon n'efface pas les reposts déjà publiés, qui restent à supprimer à la main. +- Un message mis en page avec des composants (Components V2, comme ceux d'autres apps) est reposté avec le texte et les images de ses composants, en gardant leurs spoilers ; un message transféré aussi. - Si le bot ne peut pas voir le salon starboard ou y écrire, rien n'est reposté. Changer de salon starboard laisse les anciens reposts en place ; leur compteur continue d'être mis à jour. - Quand un message atteint le seuil mais n'est pas reposté parce qu'il vient d'un salon NSFW ou d'un fil privé, la raison est journalisée une fois par message ; une permission manquante dans le salon starboard l'est en avertissement. Les salons exclus ou non surveillés ne sont journalisés qu'en niveau `debug`. - Si le salon d'origine devient NSFW, le repost est supprimé à la réaction ou à la modification suivante, même si le salon a aussi été exclu entre-temps. @@ -748,7 +749,7 @@ Cite les messages Discord dont le lien est posté sur le serveur. ### Comportement automatique -Quand un membre poste un lien vers un message (`https://discord.com/channels/…`, y compris `ptb.` et `canary.`), le bot répond avec une citation : auteur et avatar, texte, première image, autres pièces jointes en liens, salon d'origine, membre qui cite et date du message. Un bouton « Aller au message » ouvre l'original. Les mentions présentes dans le message cité ne notifient personne, et la citation part en @silent : le message qui contient le lien a déjà notifié le salon. +Quand un membre poste un lien vers un message (`https://discord.com/channels/…`, y compris `ptb.` et `canary.`), le bot répond avec une citation : auteur et avatar, texte, première image, autres pièces jointes en liens, salon d'origine, membre qui cite et date du message. Un bouton « Aller au message » ouvre l'original. Un message mis en page avec des composants (Components V2) est cité avec le texte et la première image de ses composants. Les mentions présentes dans le message cité ne notifient personne, et la citation part en @silent : le message qui contient le lien a déjà notifié le salon. Un message transféré est cité avec son contenu transféré. Un message sans texte reprend la description (ou le titre) de son premier embed, à défaut le bot indique qu'il n'a pas de texte. diff --git a/src/lib/message-content.test.ts b/src/lib/message-content.test.ts new file mode 100644 index 00000000..410712bd --- /dev/null +++ b/src/lib/message-content.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { + componentsMedia, + componentsText, + hiddenAttachments, +} from "./message-content.js"; + +// A celebration as the bot posts it: a heading, the card, then the mentions; +// one component as discord.js gives it (with toJSON), the others as JSON. +const announcement = { + components: [ + { toJSON: () => ({ type: 10, content: "🎉 **Ancienneté**" }) }, + { + type: 12, + items: [{ media: { url: "https://cdn/card.webp" } }], + }, + { + type: 17, + components: [ + { type: 10, content: "**5 ans** · <@1>" }, + { + type: 9, + components: [{ type: 10, content: "inside a section" }], + accessory: { type: 2, label: "a button" }, + }, + { + type: 12, + items: [{ media: { url: "https://cdn/x.png" }, spoiler: true }], + }, + ], + }, + ], +}; + +describe("componentsText", () => { + it("reads the text displays in order, sections and containers included", () => { + expect(componentsText(announcement as never)).toBe( + "🎉 **Ancienneté**\n**5 ans** · <@1>\ninside a section" + ); + }); + + it("reads nothing from a message without components", () => { + expect(componentsText({ components: [] } as never)).toBe(""); + expect(componentsText({} as never)).toBe(""); + }); +}); + +describe("componentsMedia", () => { + it("lists the gallery media, keeping their spoiler", () => { + expect(componentsMedia(announcement as never)).toEqual([ + { url: "https://cdn/card.webp", spoiler: false }, + { url: "https://cdn/x.png", spoiler: true }, + ]); + }); + + it("ignores buttons and rows of a classic message", () => { + const classic = { components: [{ components: [{ customId: "x" }] }] }; + expect(componentsMedia(classic as never)).toEqual([]); + expect(componentsText(classic as never)).toBe(""); + }); +}); + +describe("a container marked as a spoiler", () => { + const hidden = { + components: [ + { + type: 17, + spoiler: true, + components: [ + { type: 10, content: "the ||end|| of the film" }, + { type: 12, items: [{ media: { url: "https://cdn/end.png" } }] }, + ], + }, + { type: 10, content: "visible" }, + ], + }; + + it("keeps its text behind a spoiler", () => { + expect(componentsText(hidden as never)).toBe( + "||the end of the film||\nvisible" + ); + }); + + it("keeps its media behind a spoiler", () => { + expect(componentsMedia(hidden as never)).toEqual([ + { url: "https://cdn/end.png", spoiler: true }, + ]); + }); +}); + +it("gives the attachment a gallery item shows, to not show it twice", () => { + const uploaded = { + components: [ + { + type: 12, + items: [ + { media: { url: "https://cdn/card.webp", attachment_id: "42" } }, + ], + }, + ], + }; + expect(componentsMedia(uploaded as never)).toEqual([ + { url: "https://cdn/card.webp", spoiler: false, attachmentId: "42" }, + ]); +}); + +describe("hiddenAttachments", () => { + it("lists the uploads any component shows behind a spoiler", () => { + const message = { + components: [ + { + type: 17, + spoiler: true, + components: [ + { + type: 9, + components: [{ type: 10, content: "x" }], + accessory: { type: 11, media: { url: "u", attachment_id: "1" } }, + }, + ], + }, + { type: 13, file: { url: "f", attachment_id: "2" }, spoiler: true }, + { + type: 12, + items: [ + { media: { url: "a", attachment_id: "3" }, spoiler: true }, + { media: { url: "b", attachment_id: "4" } }, + ], + }, + { type: 11, media: { url: "c", attachment_id: "5" } }, + ], + }; + expect([...hiddenAttachments(message as never)].sort()).toEqual([ + "1", + "2", + "3", + ]); + }); +}); + +it("closes the spoiler of a hidden text ending with a backslash", () => { + const message = { + components: [ + { type: 17, spoiler: true, components: [{ type: 10, content: "a\\" }] }, + ], + }; + expect(componentsText(message as never)).toBe("||a||"); +}); + +it("keeps the escaped backslashes ending a hidden text", () => { + const message = { + components: [ + { + type: 17, + spoiler: true, + components: [{ type: 10, content: "C:\\\\" }], + }, + ], + }; + expect(componentsText(message as never)).toBe("||C:\\\\||"); +}); + +it("drops one backslash of an odd run ending a hidden text", () => { + const message = { + components: [ + { + type: 17, + spoiler: true, + components: [{ type: 10, content: "a" + "\\".repeat(5) }], + }, + ], + }; + expect(componentsText(message as never)).toBe(`||a${"\\".repeat(4)}||`); +}); diff --git a/src/lib/message-content.ts b/src/lib/message-content.ts new file mode 100644 index 00000000..2e80b5a6 --- /dev/null +++ b/src/lib/message-content.ts @@ -0,0 +1,133 @@ +import type { Message } from "discord.js"; + +// What a message laid out with components (Components V2) reads and shows: +// it has no content of its own, and the files it uploads only show through +// its components, so the modules that render someone else's message (quotes, +// the starboard) would show it empty. Its text displays, in order, and the +// media of its galleries stand for them. + +const TEXT_DISPLAY = 10; +const THUMBNAIL = 11; +const MEDIA_GALLERY = 12; +const FILE = 13; + +interface Media { + url?: string; + attachment_id?: string; + content_type?: string; +} + +interface Node { + type?: number; + content?: string; + spoiler?: boolean; + components?: Node[]; + accessory?: Node; + media?: Media; + file?: Media; + items?: { media?: Media; spoiler?: boolean }[]; +} + +export interface ComponentMedia { + url: string; + spoiler: boolean; + /** Its MIME type, when Discord knows it (not for every external link). */ + contentType?: string; + /** The uploaded file it shows, also listed in the message's attachments. */ + attachmentId?: string; +} + +type WithComponents = Partial>; + +/** The components as Discord sends them, from discord.js's objects. */ +function nodes(message: WithComponents): Node[] { + return (message.components ?? []).map((component) => + "toJSON" in component && typeof component.toJSON === "function" + ? (component.toJSON() as Node) + : (component as Node) + ); +} + +/** Visits every component, telling whether a spoiler container hides it. */ +function walk( + list: readonly Node[], + visit: (node: Node, hidden: boolean) => void, + hidden = false +): void { + for (const node of list) { + const inside = hidden || node.spoiler === true; + visit(node, inside); + walk(node.components ?? [], visit, inside); + if (node.accessory) { + walk([node.accessory], visit, inside); + } + } +} + +/** The text displays of a message, one per line; empty without any. */ +export function componentsText(message: WithComponents): string { + const texts: string[] = []; + walk(nodes(message), (node, hidden) => { + if (node.type === TEXT_DISPLAY && node.content) { + // Spoilers do not nest: the inner ones would reveal what they wrap, + // and an odd trailing backslash would escape the closing one. + const inner = node.content + .replaceAll("||", "") + .replace(/\\+$/, (run) => (run.length % 2 ? run.slice(1) : run)); + texts.push(hidden ? `||${inner}||` : node.content); + } + }); + return texts.join("\n"); +} + +/** The media of a message's galleries, in order. */ +export function componentsMedia(message: WithComponents): ComponentMedia[] { + const media: ComponentMedia[] = []; + walk(nodes(message), (node, hidden) => { + if (node.type !== MEDIA_GALLERY) { + return; + } + for (const item of node.items ?? []) { + const url = item.media?.url; + if (!url) { + continue; + } + const { attachment_id: attachmentId, content_type: contentType } = + item.media ?? {}; + media.push({ + url, + spoiler: hidden || item.spoiler === true, + ...(contentType ? { contentType } : {}), + ...(attachmentId ? { attachmentId } : {}), + }); + } + }); + return media; +} + +/** + * The uploads a component shows behind a spoiler: a gallery item, a thumbnail + * or a file. The spoiler is set on the component, so the attachment itself, + * also listed in the message's attachments, does not carry it. + */ +export function hiddenAttachments(message: WithComponents): Set { + const ids = new Set(); + const add = (media: Media | undefined, spoiler: boolean): void => { + if (spoiler && media?.attachment_id) { + ids.add(media.attachment_id); + } + }; + // `hidden` already counts the component's own spoiler. + walk(nodes(message), (node, hidden) => { + if (node.type === THUMBNAIL) { + add(node.media, hidden); + } else if (node.type === FILE) { + add(node.file, hidden); + } else if (node.type === MEDIA_GALLERY) { + for (const item of node.items ?? []) { + add(item.media, hidden || item.spoiler === true); + } + } + }); + return ids; +} diff --git a/src/modules/message-quote/services/quote.test.ts b/src/modules/message-quote/services/quote.test.ts index 12dd7d6e..f6f83011 100644 --- a/src/modules/message-quote/services/quote.test.ts +++ b/src/modules/message-quote/services/quote.test.ts @@ -99,17 +99,19 @@ function fakeQuoted( contentType: string; spoiler: boolean; }>[]; - snapshot?: { content: string; embeds?: unknown[] }; + snapshot?: { content: string; embeds?: unknown[]; components?: unknown[] }; embeds?: unknown[]; authorId?: string; withMember?: boolean; buttonIds?: string[]; + /** A message laid out with components: no content, no attachments. */ + components?: unknown[]; } = {} ) { const attachments = new Collection( (options.attachments ?? []).map((attachment, index) => [ String(index), - { spoiler: false, ...attachment }, + { id: String(index), spoiler: false, ...attachment }, ]) ); const author = { @@ -127,6 +129,7 @@ function fakeQuoted( "s", { embeds: [], + components: [], ...options.snapshot, attachments: new Collection(), }, @@ -144,13 +147,15 @@ function fakeQuoted( createdAt: new Date("2026-10-14T19:58:00Z"), channel: { name: "general" }, client: { user: { id: "bot" } }, - components: options.buttonIds - ? [ - { - components: options.buttonIds.map((customId) => ({ customId })), - }, - ] - : [], + components: options.components + ? options.components + : options.buttonIds + ? [ + { + components: options.buttonIds.map((customId) => ({ customId })), + }, + ] + : [], } as unknown as Message; } @@ -465,6 +470,159 @@ describe("resolveQuote", () => { }); describe("quoteEmbed", () => { + it("reads a message laid out with components, text and image", () => { + // Without content or attachments: its text displays and gallery instead. + const embed = quoteEmbed( + fakeQuoted({ + components: [ + { type: 10, content: "🎂 **Joyeux anniversaire !**" }, + { type: 12, items: [{ media: { url: "https://cdn/card.webp" } }] }, + { type: 10, content: "<@1>" }, + ], + }), + member, + t + ).data; + expect(embed.description).toBe("🎂 **Joyeux anniversaire !**\n<@1>"); + expect(embed.image?.url).toBe("https://cdn/card.webp"); + }); + + it("reads a forwarded message laid out with components", () => { + const embed = quoteEmbed( + fakeQuoted({ + snapshot: { + content: "", + components: [ + { type: 10, content: "🎂 forwarded" }, + { type: 12, items: [{ media: { url: "https://cdn/card.webp" } }] }, + ], + }, + }), + member, + t + ).data; + expect(embed.description).toBe("🎂 forwarded"); + expect(embed.image?.url).toBe("https://cdn/card.webp"); + }); + + it("links the other uploads of a gallery, the spoilered one included", () => { + const upload = (name: string) => ({ + name, + url: `https://cdn/${name}`, + contentType: "image/png", + }); + const embed = quoteEmbed( + fakeQuoted({ + attachments: [upload("a.png"), upload("b.png"), upload("c.png")], + components: [ + { + type: 12, + items: ["a", "b", "c"].map((name, index) => ({ + media: { + url: `https://cdn/${name}.png`, + attachment_id: `${index}`, + }, + spoiler: name === "a", + })), + }, + ], + }), + member, + t + ).data; + expect(embed.image?.url).toBe("https://cdn/b.png"); + expect(embed.description).toBe( + "📎 [a.png](https://cdn/a.png)\n📎 [c.png](https://cdn/c.png)" + ); + }); + + it("keeps hidden an uploaded thumbnail inside a spoiler container", () => { + const embed = quoteEmbed( + fakeQuoted({ + attachments: [ + { name: "t.png", url: "https://cdn/t.png", contentType: "image/png" }, + ], + components: [ + { + type: 17, + spoiler: true, + components: [ + { + type: 9, + components: [{ type: 10, content: "secret" }], + accessory: { + type: 11, + media: { url: "https://cdn/t.png", attachment_id: "0" }, + }, + }, + ], + }, + ], + }), + member, + t + ).data; + expect(embed.image).toBeUndefined(); + }); + + it("does not take a gallery video for the image", () => { + const embed = quoteEmbed( + fakeQuoted({ + components: [ + { + type: 12, + items: [ + { + media: { + url: "https://cdn/clip.mp4", + content_type: "video/mp4", + }, + }, + { media: { url: "https://cdn/card.webp" } }, + ], + }, + ], + }), + member, + t + ).data; + expect(embed.image?.url).toBe("https://cdn/card.webp"); + }); + + it("keeps hidden the uploaded image a gallery shows as a spoiler", () => { + // Its gallery item holds the spoiler; the attachment itself does not. + const embed = quoteEmbed( + fakeQuoted({ + attachments: [ + { + name: "end.png", + url: "https://cdn/end.png", + contentType: "image/png", + }, + ], + components: [ + { type: 10, content: "the end" }, + { + type: 12, + items: [ + { + media: { url: "https://cdn/end.png", attachment_id: "0" }, + spoiler: true, + }, + ], + }, + ], + }), + member, + t + ).data; + // Linked like a classic spoilered attachment, but not shown. + expect(embed.image).toBeUndefined(); + expect(embed.description).toBe( + "the end\n📎 [end.png](https://cdn/end.png)" + ); + }); + it("shows the author, the content, the channel and the quoter", () => { const embed = quoteEmbed(fakeQuoted({ content: "hello" }), member, t).data; expect(embed.author).toMatchObject({ diff --git a/src/modules/message-quote/services/quote.ts b/src/modules/message-quote/services/quote.ts index 6d6b514d..41724489 100644 --- a/src/modules/message-quote/services/quote.ts +++ b/src/modules/message-quote/services/quote.ts @@ -22,6 +22,11 @@ import { stripHiddenText } from "#lib/hidden-text.js"; import type { TFunction } from "#lib/i18n.js"; import { loggerMaker } from "#lib/logger.js"; import { NO_MENTIONS_REPLY } from "#lib/mentions.js"; +import { + componentsMedia, + componentsText, + hiddenAttachments, +} from "#lib/message-content.js"; import { truncate } from "#lib/text.js"; import { deleteButton, @@ -159,20 +164,40 @@ export function quoteEmbed( ...quoted.attachments.values(), ...(snapshot?.attachments.values() ?? []), ]; - const image = attachments.find(isImage); + // A message laid out with components sets the spoiler of its uploads on + // the components showing them, not on the attachments. + const hidden = new Set([ + ...hiddenAttachments(quoted), + ...(snapshot ? hiddenAttachments(snapshot) : []), + ]); + const image = attachments.find( + (attachment) => isImage(attachment) && !hidden.has(attachment.id) + ); + // Its galleries can also show media from a link, which are not uploads. + const linked = [ + ...componentsMedia(quoted), + ...(snapshot ? componentsMedia(snapshot) : []), + ].filter((item) => !item.attachmentId); const files = attachments .filter((attachment) => attachment !== image) .map((attachment) => `📎 [${attachment.name}](${attachment.url})`); const firstEmbed = quoted.embeds[0] ?? snapshot?.embeds[0]; const text = quoted.content || + // A message laid out with components has no content: its text displays. + componentsText(quoted) || snapshot?.content || + (snapshot ? componentsText(snapshot) : "") || firstEmbed?.description || firstEmbed?.title || ""; const description = [text, ...files].filter(Boolean).join("\n"); const imageUrl = image?.url ?? + linked.find( + (item) => + !item.spoiler && (item.contentType?.startsWith("image/") ?? true) + )?.url ?? (text.includes("||") ? undefined : (firstEmbed?.image?.url ?? firstEmbed?.thumbnail?.url)); diff --git a/src/modules/starboard/services/view.test.ts b/src/modules/starboard/services/view.test.ts index f4dd31bb..bb3759c1 100644 --- a/src/modules/starboard/services/view.test.ts +++ b/src/modules/starboard/services/view.test.ts @@ -81,6 +81,142 @@ describe("counterLine", () => { }); describe("starboardMessage", () => { + it("shows the text and image of a message laid out with components", () => { + const { json } = render( + fakeMessage({ + content: "", + components: [ + { type: 10, content: "🎂 **Joyeux anniversaire !**" }, + { type: 12, items: [{ media: { url: "https://cdn/card.webp" } }] }, + { type: 10, content: "<@1>" }, + ], + }) + ); + expect(json).toContain("🎂 **Joyeux anniversaire !**\\n<@1>"); + expect(json).toContain("https://cdn/card.webp"); + }); + + it("shows a forwarded message laid out with components", () => { + const { json } = render( + fakeMessage({ + content: "", + messageSnapshots: new Collection([ + [ + "s", + fakeMessage({ + content: "", + components: [ + { type: 10, content: "🎂 forwarded" }, + { + type: 12, + items: [{ media: { url: "https://cdn/card.webp" } }], + }, + ], + }), + ], + ]), + }) + ); + expect(json).toContain("🎂 forwarded"); + expect(json).toContain("https://cdn/card.webp"); + }); + + it("shows an uploaded image once, with the spoiler of its gallery", () => { + const card = { + id: "a1", + ...attachment("card.png", "image/png"), + }; + const { payload } = render( + fakeMessage({ + content: "", + attachments: new Collection([["a1", card]]), + components: [ + { + type: 12, + items: [ + { + media: { url: card.url, attachment_id: "a1" }, + spoiler: true, + }, + ], + }, + ], + }) + ); + const items = JSON.stringify(payload.components).match(/card\.png/g); + expect(items).toHaveLength(1); + expect(JSON.stringify(payload.components)).toContain('"spoiler":true'); + }); + + it("keeps the spoiler of an upload's file name when a gallery shows it", () => { + const card = { + id: "a1", + ...attachment("SPOILER_card.png", "image/png", true), + }; + const { json } = render( + fakeMessage({ + content: "", + attachments: new Collection([["a1", card]]), + components: [ + { + type: 12, + items: [{ media: { url: card.url, attachment_id: "a1" } }], + }, + ], + }) + ); + expect(json).toContain('"spoiler":true'); + }); + + it("keeps hidden an upload a spoiler container shows as a thumbnail", () => { + const thumb = { id: "a1", ...attachment("t.png", "image/png") }; + const { json } = render( + fakeMessage({ + content: "", + attachments: new Collection([["a1", thumb]]), + components: [ + { + type: 17, + spoiler: true, + components: [ + { + type: 9, + components: [{ type: 10, content: "secret" }], + accessory: { + type: 11, + media: { url: thumb.url, attachment_id: "a1" }, + }, + }, + ], + }, + ], + }) + ); + expect(json).toContain('"spoiler":true'); + }); + + it("hides a gallery upload another component shows as a spoiler", () => { + const card = { id: "a1", ...attachment("card.png", "image/png") }; + const { json } = render( + fakeMessage({ + content: "", + attachments: new Collection([["a1", card]]), + components: [ + { + type: 12, + items: [{ media: { url: card.url, attachment_id: "a1" } }], + }, + { + type: 13, + file: { url: card.url, attachment_id: "a1" }, + spoiler: true, + }, + ], + }) + ); + expect(json).toContain('"spoiler":true'); + }); + it("never pings anyone", () => { expect(render(fakeMessage()).payload.allowedMentions).toBe(NO_MENTIONS); }); @@ -178,6 +314,18 @@ describe("starboardMessage", () => { ); }); + it("shows the text of a reply laid out with components", () => { + const reply = fakeMessage({ + author: { id: "other" }, + content: "", + components: [{ type: 10, content: "🎂 happy birthday" }], + }) as unknown as Message; + + const { json } = render(fakeMessage(), reply); + + expect(json).toContain("↪ Replying to <@other>: 🎂 happy birthday"); + }); + it("shows the content of a forwarded message", () => { const snapshot = { content: "forwarded words", diff --git a/src/modules/starboard/services/view.ts b/src/modules/starboard/services/view.ts index 80d6991d..b76b3fcd 100644 --- a/src/modules/starboard/services/view.ts +++ b/src/modules/starboard/services/view.ts @@ -12,6 +12,11 @@ import { } from "discord.js"; import type { TFunction } from "#lib/i18n.js"; import { NO_MENTIONS } from "#lib/mentions.js"; +import { + componentsMedia, + componentsText, + hiddenAttachments, +} from "#lib/message-content.js"; import { truncate } from "#lib/text.js"; import type { Tally } from "./tally.js"; @@ -28,7 +33,10 @@ const SHOWN_FILES = 3; const GALLERY_LIMIT = 10; const HEADER_GAP = "\n\n"; -type Body = Pick; +type Body = Pick< + Message, + "content" | "attachments" | "embeds" | "stickers" | "components" +>; interface GalleryItem { url: string; @@ -88,7 +96,7 @@ function embedMedia(embed: Embed): string | undefined { } } -function gallery(body: Body): GalleryItem[] { +function gallery(body: Body, hidden: ReadonlySet): GalleryItem[] { const embedItems: GalleryItem[] = []; for (const embed of body.embeds) { const url = embedMedia(embed); @@ -99,7 +107,7 @@ function gallery(body: Body): GalleryItem[] { const items: GalleryItem[] = [ ...body.attachments.filter(isMedia).map((attachment) => ({ url: attachment.url, - spoiler: attachment.spoiler, + spoiler: attachment.spoiler || hidden.has(attachment.id), })), ...body.stickers .filter((sticker) => sticker.format !== StickerFormatType.Lottie) @@ -152,7 +160,7 @@ function extras(body: Body, t: TFunction): string { function replyLine(reply: Message, t: TFunction): string { const excerpt = truncate( - reply.content.replace(/\s+/g, " ").trim(), + (reply.content || componentsText(reply)).replace(/\s+/g, " ").trim(), REPLY_LIMIT ); return `-# ${t("reply", { @@ -186,7 +194,30 @@ export function starboardMessage( t: TFunction, withMedia = true ) { - const { body, forwarded } = bodyOf(message); + const { body: raw, forwarded } = bodyOf(message); + // A message laid out with components shows its uploads through them, and + // sets their spoiler there: its galleries are shown as they are (the upload + // once, hidden by either spoiler), its other uploads with that spoiler. + const hidden = hiddenAttachments(raw); + const shown = componentsMedia(raw).map((item) => ({ + url: item.url, + spoiler: + item.spoiler || + (!!item.attachmentId && + (hidden.has(item.attachmentId) || + !!raw.attachments.get(item.attachmentId)?.spoiler)), + id: item.attachmentId, + })); + const shownIds = new Set(shown.map((item) => item.id)); + const body: Body = { + content: raw.content, + attachments: raw.attachments.filter( + (attachment) => !shownIds.has(attachment.id) + ), + embeds: raw.embeds, + stickers: raw.stickers, + components: raw.components, + }; const container = new ContainerBuilder().setAccentColor(ACCENT); const replied = reply ? replyLine(reply, t) : null; @@ -213,8 +244,10 @@ export function starboardMessage( others.length - counter.length - HEADER_GAP.length; - const content = body.content - ? `${header}${HEADER_GAP}${truncate(body.content, Math.min(CONTENT_LIMIT, budget))}` + // A message laid out with components has no content: its text displays. + const text = body.content || componentsText(body); + const content = text + ? `${header}${HEADER_GAP}${truncate(text, Math.min(CONTENT_LIMIT, budget))}` : header; const avatar = message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(); @@ -228,7 +261,9 @@ export function starboardMessage( container.addTextDisplayComponents((text) => text.setContent(others)); } - const media = withMedia ? gallery(body) : []; + const media = withMedia + ? [...gallery(body, hidden), ...shown].slice(0, GALLERY_LIMIT) + : []; if (media.length > 0) { container.addMediaGalleryComponents((mediaGallery) => mediaGallery.addItems( From 47cc0df5d592fc506f44e400d82cfc6cbe7c0538 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Mon, 9 Nov 2026 16:10:36 +0100 Subject: [PATCH 2/2] feat(birthday): fiche d'anniversaire en Components V2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /birthday set et /birthday show partagent une même fiche encadrée : date et salon d'annonce avec le bouton à côté, jumeaux, puis en petit les données conservées. /birthday upcoming répond aussi dans un conteneur, ce qui porte la limite de texte à 4 000 caractères. --- docs/functional.md | 6 +- src/lib/format.ts | 2 + src/lib/testing.ts | 40 +++++++ .../commands/birthday.command.test.ts | 65 +++++++++- .../birthday/commands/birthday.command.ts | 64 ++++------ src/modules/birthday/commands/own-birthday.ts | 111 ++++++++++++++---- src/modules/birthday/i18n/en.json | 17 ++- src/modules/birthday/i18n/fr.json | 17 ++- .../interactions/announce.button.test.ts | 50 ++++++-- .../birthday/interactions/announce.button.ts | 17 ++- src/modules/birthday/rendering/theme.ts | 2 + 11 files changed, 285 insertions(+), 106 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index 18c2d9d0..917bbe53 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -526,11 +526,11 @@ Ces annonces ne citent que des chiffres : ni membre, ni date. Tous les anniversa ### `/birthday set [announce]` -Enregistre ou modifie l'anniversaire du membre. Une date qui n'existe pas (30 février…) est refusée ; le 29 février est accepté. Avec `announce: False`, l'anniversaire n'est jamais annoncé, ni nommé, ni affiché : il compte seulement dans les statistiques. Ce compte peut laisser deviner qu'une date est prise (en voyant le nombre de dates partagées changer), jamais par qui. La réponse, visible du membre seul, rappelle où il sera annoncé et ce qui est conservé. +Enregistre ou modifie l'anniversaire du membre. Une date qui n'existe pas (30 février…) est refusée ; le 29 février est accepté. Avec `announce: False`, l'anniversaire n'est jamais annoncé, ni nommé, ni affiché : il compte seulement dans les statistiques. Ce compte peut laisser deviner qu'une date est prise (en voyant le nombre de dates partagées changer), jamais par qui. La réponse, visible du membre seul, est la fiche d'anniversaire décrite sous `/birthday show`, titrée « Anniversaire enregistré ». ### `/birthday show` et `/birthday remove` -Affiche ce que le module conserve sur le membre, ou le supprime. Réponses visibles du membre seul. Sous la réponse de `/birthday set` et de `/birthday show`, un bouton **Ne plus annoncer mon anniversaire** (ou **Annoncer mon anniversaire**) change ce choix sans retaper la date ; il agit toujours sur l'anniversaire de qui clique, et retire aussitôt le rôle d'anniversaire quand l'anniversaire n'est plus annoncé. +Affiche ce que le module conserve sur le membre, ou le supprime. Réponses visibles du membre seul. `/birthday show` répond par une fiche encadrée à la couleur du module : la date, le salon où elle est annoncée (« Sera annoncé dès qu'un salon d'annonces sera défini » quand aucun ne l'est, ou « Non annoncé », compté seulement dans `/birthday stats`), les jumeaux d'anniversaire, puis en petit ce qui est conservé et comment le supprimer. À côté de la date, sur la fiche de `/birthday set` comme de `/birthday show`, un bouton **Ne plus annoncer mon anniversaire** (ou **Annoncer mon anniversaire**) change ce choix sans retaper la date, et met la fiche à jour ; il agit toujours sur l'anniversaire de qui clique, et retire aussitôt le rôle d'anniversaire quand l'anniversaire n'est plus annoncé. ### Jumeaux d'anniversaire @@ -538,7 +538,7 @@ La réponse de `/birthday set` et de `/birthday show` indique aussi avec qui le ### `/birthday upcoming` -Liste les anniversaires des 30 prochains jours, aujourd'hui compris, dans le fuseau du serveur, pour les seuls membres qui ont accepté d'être annoncés. Personne n'est notifié. Si la liste dépasse la taille d'un message Discord, les anniversaires les plus lointains sont omis, et une dernière ligne indique combien. +Liste les anniversaires des 30 prochains jours, aujourd'hui compris, dans le fuseau du serveur, pour les seuls membres qui ont accepté d'être annoncés. La liste est encadrée à la couleur du module, comme la réponse quand aucun anniversaire n'approche. Personne n'est notifié. Si la liste dépasse les 4 000 caractères qu'un tel message peut contenir, les anniversaires les plus lointains sont omis, et une dernière ligne indique combien. ### `/birthday stats [role | channel]` diff --git a/src/lib/format.ts b/src/lib/format.ts index db8ae5b8..44d5ca7b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -43,6 +43,8 @@ export function formatPercentage( } const DESCRIPTION_LIMIT = 4096; +// What a message laid out with components may hold, all its text included. +export const COMPONENTS_TEXT_LIMIT = 4000; const MORE_LINE_RESERVE = 64; // Lines within an embed description, or a message with a smaller `limit`; a diff --git a/src/lib/testing.ts b/src/lib/testing.ts index 5b4998d0..ce020e1a 100644 --- a/src/lib/testing.ts +++ b/src/lib/testing.ts @@ -112,3 +112,43 @@ export function fakeMessage(overrides: Record = {}) { ...overrides, }; } + +/** + * The text a sent message shows: its content, or the text displays of its + * components (Components V2), in order, one per line. Both when a message + * carries both, which Discord refuses: a test then sees the content too. + */ +export function messageText(message: { + content?: string; + components?: readonly unknown[]; +}): string { + const texts: string[] = message.content ? [message.content] : []; + const walk = (node: unknown): void => { + const json = + node && typeof node === "object" && "toJSON" in node + ? (node as { toJSON(): unknown }).toJSON() + : node; + if (!json || typeof json !== "object") { + return; + } + const { type, content, components, accessory } = json as { + type?: number; + content?: string; + components?: unknown[]; + accessory?: unknown; + }; + if (type === 10 && content !== undefined) { + texts.push(content); + } + for (const child of components ?? []) { + walk(child); + } + if (accessory) { + walk(accessory); + } + }; + for (const component of message.components ?? []) { + walk(component); + } + return texts.join("\n"); +} diff --git a/src/modules/birthday/commands/birthday.command.test.ts b/src/modules/birthday/commands/birthday.command.test.ts index fbd2f860..1ed705b5 100644 --- a/src/modules/birthday/commands/birthday.command.test.ts +++ b/src/modules/birthday/commands/birthday.command.test.ts @@ -13,7 +13,12 @@ import { vi, } from "vitest"; import type { ConfigData } from "#lib/config.js"; -import { initTestI18n, makeTestConfig, silenceLogs } from "#lib/testing.js"; +import { + initTestI18n, + makeTestConfig, + messageText, + silenceLogs, +} from "#lib/testing.js"; import { birthdayConfigSchema, type BirthdayConfigSchema, @@ -159,7 +164,18 @@ function config(values: Partial> = {}) { } function content(reply: ReturnType): string { - return (reply.mock.calls[0] as [{ content: string }])[0].content; + return messageText((reply.mock.calls[0] as [{ content?: string }])[0]); +} + +// A reply laid out with components: the flag, and no content beside them. +function expectFramed(reply: ReturnType) { + const sent = ( + reply.mock.calls[0] as [{ content?: string; flags?: number }] + )[0]; + expect(sent.content ?? "").toBe(""); + expect((sent.flags ?? 0) & MessageFlags.IsComponentsV2).toBe( + MessageFlags.IsComponentsV2 + ); } beforeAll(async () => { @@ -211,7 +227,10 @@ describe("/birthday set", () => { }); expect(deferReply).toHaveBeenCalledWith({ flags: MessageFlags.Ephemeral }); expect(content(reply)).toContain("**14 mars**"); - expect(content(reply)).toContain("Il sera annoncé dans <#chan>."); + expect(content(reply)).toContain("Annoncé dans <#chan>"); + expect(content(reply)).toContain("Anniversaire enregistré"); + expect(content(reply)).toContain("votre choix d'annonce"); + expectFramed(reply); expect(checkMilestones).toHaveBeenCalledWith("guild", provider); }); @@ -226,7 +245,32 @@ describe("/birthday set", () => { "guild", expect.objectContaining({ announce: false }) ); - expect(content(reply)).toContain("Il ne sera pas annoncé"); + expect(content(reply)).toContain("Non annoncé"); + }); +}); + +describe("/birthday show", () => { + it("answers with the member's sheet", async () => { + service.list.mockResolvedValue([ + { userId: "me", day: 14, month: 3, announce: true }, + ]); + const { interaction: i, reply } = interaction({ subcommand: "show" }); + await command.execute(i, config()); + expect(content(reply)).toContain("Votre anniversaire"); + expect(content(reply)).toContain("**14 mars**"); + expect(content(reply)).toContain( + "Sera annoncé dès qu'un salon d'annonces sera défini" + ); + expectFramed(reply); + }); + + it("frames the answer when no birthday is saved", async () => { + const { interaction: i, reply } = interaction({ subcommand: "show" }); + await command.execute(i, config()); + expect(content(reply)).toBe( + "Vous n'avez pas enregistré votre anniversaire sur ce serveur." + ); + expectFramed(reply); }); }); @@ -356,7 +400,7 @@ describe("birthday twins in /birthday set and show", () => { booleans: { announce: false }, }); await command.execute(i, config()); - expect(content(reply)).toContain("Il ne sera pas annoncé"); + expect(content(reply)).toContain("Non annoncé"); expect(content(reply)).toContain("avec <@a>."); }); @@ -621,9 +665,18 @@ describe("/birthday upcoming", () => { const { interaction: i, reply } = interaction({ subcommand: "upcoming" }); await command.execute(i, config({ timeZone: "UTC" })); const text = content(reply); - expect(text.length).toBeLessThanOrEqual(2000); + // A message laid out with components holds 4000 characters of text. + expect(text.length).toBeLessThanOrEqual(4000); const shown = (text.match(/<@/g) ?? []).length; expect(text).toContain(`… et ${200 - shown} autres anniversaires`); + expectFramed(reply); + }); + + it("frames the reply when nobody's birthday is coming", async () => { + const { interaction: i, reply } = interaction({ subcommand: "upcoming" }); + await command.execute(i, config({ timeZone: "UTC" })); + expect(content(reply)).toContain("Aucun anniversaire"); + expectFramed(reply); }); it("only lists the announced birthdays", async () => { diff --git a/src/modules/birthday/commands/birthday.command.ts b/src/modules/birthday/commands/birthday.command.ts index a1347be1..a0a731a3 100644 --- a/src/modules/birthday/commands/birthday.command.ts +++ b/src/modules/birthday/commands/birthday.command.ts @@ -14,13 +14,12 @@ import { } from "#lib/calendar.js"; import { declareCommand } from "#lib/command.js"; import type { ConfigProvider } from "#lib/config.js"; -import { describeLines } from "#lib/format.js"; +import { COMPONENTS_TEXT_LIMIT, describeLines } from "#lib/format.js"; import { fetchImage } from "#lib/imaging.js"; import { loggerMaker } from "#lib/logger.js"; import { NO_MENTIONS } from "#lib/mentions.js"; import type { BirthdayConfigSchema } from "#modules/birthday/birthday.config.js"; import { renderStatsCard } from "#modules/birthday/rendering/stats-card.js"; -import { MESSAGE_LIMIT } from "#modules/birthday/services/announcements.js"; import birthdayService, { type BirthdayEntry, } from "#modules/birthday/services/birthday.service.js"; @@ -37,7 +36,7 @@ import { statsText, type BirthdayStats, } from "#modules/birthday/services/stats.js"; -import { announceRow, showMessage, twinsLine } from "./own-birthday.js"; +import { ownBirthdaySheet, sheetNotice } from "./own-birthday.js"; const UPCOMING_DAYS = 30; @@ -62,12 +61,8 @@ const MONTHS = [ ] as const; /** A reply only the member sees; an edit once the reply was deferred. */ -async function replyPrivately( - interaction: GuildInteraction, - content: string, - components: ReturnType[] = [] -) { - const message = { content, components, allowedMentions: NO_MENTIONS }; +async function replyPrivately(interaction: GuildInteraction, content: string) { + const message = { content, allowedMentions: NO_MENTIONS }; if (interaction.deferred) { await interaction.editReply(message); } else { @@ -97,28 +92,11 @@ async function setBirthday(interaction: GuildInteraction, config: Config) { await dropBirthdayRole(config, interaction.guildId, entry.userId); } - const channel = config.get("channel"); - const visibility = entry.announce - ? channel - ? config.t("set.announced", { channel: `<#${channel.id}>` }) - : config.t("set.announcedLater") - : config.t("set.private"); - const twins = await twinsLine( - interaction, - config, - entry, - await birthdayService.list(interaction.guildId) - ); - await replyPrivately( - interaction, - [ - config.t("set.done", { date: formatDayOfYear(entry, config.locale) }), - visibility, - ...(twins ? [twins] : []), - config.t("set.data"), - ].join("\n"), - [announceRow(config, entry.announce)] - ); + const entries = await birthdayService.list(interaction.guildId); + await interaction.editReply({ + ...(await ownBirthdaySheet(interaction, config, entry, entries, "set")), + allowedMentions: NO_MENTIONS, + }); await checkMilestones(interaction.guildId, config).catch((err: unknown) => logger.error( @@ -134,11 +112,15 @@ async function showBirthday(interaction: GuildInteraction, config: Config) { const entries = await birthdayService.list(interaction.guildId); const entry = entries.find(({ userId }) => userId === interaction.user.id); if (!entry) { - await replyPrivately(interaction, config.t("show.none")); + // Framed like the sheet, as the button says it once the birthday is gone. + await interaction.editReply({ + ...sheetNotice(config.t("show.none")), + allowedMentions: NO_MENTIONS, + }); return; } await interaction.editReply({ - ...(await showMessage(interaction, config, entry, entries)), + ...(await ownBirthdaySheet(interaction, config, entry, entries)), allowedMentions: NO_MENTIONS, }); } @@ -178,7 +160,7 @@ async function showUpcoming(interaction: GuildInteraction, config: Config) { const coming = upcoming(entries, today, UPCOMING_DAYS); if (coming.length === 0) { await interaction.reply({ - content: config.t("upcoming.none", { count: UPCOMING_DAYS }), + ...sheetNotice(config.t("upcoming.none", { count: UPCOMING_DAYS })), allowedMentions: NO_MENTIONS, }); return; @@ -194,12 +176,16 @@ async function showUpcoming(interaction: GuildInteraction, config: Config) { ); // A single reply: past Discord's limit, the furthest birthdays are left // out, and counted. - const content = describeLines( - [config.t("upcoming.heading", { count: UPCOMING_DAYS }), ...lines], - (count) => config.t("upcoming.more", { count }), - MESSAGE_LIMIT + const heading = config.t("upcoming.heading", { count: UPCOMING_DAYS }); + const list = describeLines( + lines, + (count) => `-# ${config.t("upcoming.more", { count })}`, + COMPONENTS_TEXT_LIMIT - heading.length ); - await interaction.reply({ content, allowedMentions: NO_MENTIONS }); + await interaction.reply({ + ...sheetNotice(heading, list), + allowedMentions: NO_MENTIONS, + }); } /** The stats card, or `null` when it cannot be drawn. */ diff --git a/src/modules/birthday/commands/own-birthday.ts b/src/modules/birthday/commands/own-birthday.ts index eb01df91..ac57982c 100644 --- a/src/modules/birthday/commands/own-birthday.ts +++ b/src/modules/birthday/commands/own-birthday.ts @@ -1,7 +1,11 @@ import { - ActionRowBuilder, ButtonBuilder, ButtonStyle, + ContainerBuilder, + MessageFlags, + SectionBuilder, + SeparatorSpacingSize, + TextDisplayBuilder, type ButtonInteraction, type ChatInputCommandInteraction, } from "discord.js"; @@ -9,6 +13,7 @@ import { formatDayOfYear } from "#lib/calendar.js"; import type { ConfigProvider } from "#lib/config.js"; import { loggerMaker } from "#lib/logger.js"; import type { BirthdayConfigSchema } from "#modules/birthday/birthday.config.js"; +import { ACCENT_COLOR } from "#modules/birthday/rendering/theme.js"; import type { BirthdayEntry } from "#modules/birthday/services/birthday.service.js"; // Twins named in the reply to /birthday set and show; the rest are counted. @@ -98,31 +103,93 @@ export async function twinsLine( }); } -/** The button that hides an announced birthday, or announces a hidden one. */ -export function announceRow(config: Config, announce: boolean) { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`${ANNOUNCE_BUTTON_ID}:${announce ? "off" : "on"}`) - .setLabel(config.t(announce ? "announce.hide" : "announce.show")) - .setStyle(ButtonStyle.Secondary) - ); +/** Which command the sheet answers: `/birthday set` or `/birthday show`. */ +export type SheetTitle = "set" | "show"; + +/** + * The button that hides an announced birthday, or announces a hidden one. It + * carries the sheet's title, so that its edit keeps it. + */ +function announceButton(config: Config, announce: boolean, title: SheetTitle) { + const id = `${ANNOUNCE_BUTTON_ID}:${announce ? "off" : "on"}`; + return new ButtonBuilder() + .setCustomId(title === "set" ? `${id}:set` : id) + .setLabel(config.t(announce ? "announce.hide" : "announce.show")) + .setStyle(ButtonStyle.Secondary); +} + +/** Where the birthday is announced, if it is. */ +function visibility(config: Config, announce: boolean): string { + if (!announce) { + return config.t("own.private"); + } + const channel = config.get("channel"); + return channel + ? config.t("own.announced", { channel: `<#${channel.id}>` }) + : config.t("own.announcedLater"); +} + +/** A message laid out with components: the flag goes with every send and edit. */ +export interface SheetMessage { + components: ContainerBuilder[]; + flags: MessageFlags.IsComponentsV2; +} + +/** Text in the module's frame: a reply, or the edit of a sheet message. */ +export function sheetNotice(...texts: string[]): SheetMessage { + return { + components: [ + new ContainerBuilder() + .setAccentColor(ACCENT_COLOR) + .addTextDisplayComponents( + texts.map((text) => new TextDisplayBuilder().setContent(text)) + ), + ], + flags: MessageFlags.IsComponentsV2, + }; } -/** What `/birthday show` says about the member's own birthday, with its button. */ -export async function showMessage( +/** + * The member's own birthday, as `/birthday set` and `/birthday show` answer + * it and its button updates it: the date and where it is announced beside + * the button that changes it, the members who share it, then what is stored. + */ +export async function ownBirthdaySheet( interaction: MemberInteraction, config: Config, entry: BirthdayEntry, - entries: readonly BirthdayEntry[] -) { + entries: readonly BirthdayEntry[], + title: SheetTitle = "show" +): Promise { const twins = await twinsLine(interaction, config, entry, entries); - return { - content: [ - config.t("show.entry", { date: formatDayOfYear(entry, config.locale) }), - config.t(entry.announce ? "show.announced" : "show.private"), - ...(twins ? [twins] : []), - config.t("show.stored"), - ].join("\n"), - components: [announceRow(config, entry.announce)], - }; + const container = new ContainerBuilder() + .setAccentColor(ACCENT_COLOR) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(config.t(`own.title.${title}`)) + ) + .addSectionComponents( + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + [ + `**${formatDayOfYear(entry, config.locale)}**`, + visibility(config, entry.announce), + ].join("\n") + ) + ) + .setButtonAccessory(announceButton(config, entry.announce, title)) + ); + if (twins) { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(twins) + ); + } + container + .addSeparatorComponents((separator) => + separator.setSpacing(SeparatorSpacingSize.Small) + ) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`-# ${config.t("own.stored")}`) + ); + return { components: [container], flags: MessageFlags.IsComponentsV2 }; } diff --git a/src/modules/birthday/i18n/en.json b/src/modules/birthday/i18n/en.json index 89a6a5d3..e9942616 100644 --- a/src/modules/birthday/i18n/en.json +++ b/src/modules/birthday/i18n/en.json @@ -72,17 +72,14 @@ "paradox.field.nextValue": "{{count}} registrations · {{percent}}", "set.invalid": "This date does not exist.", - "set.done": "🎂 Your birthday is registered: **{{date}}**.", - "set.announced": "It will be announced in {{channel}}.", - "set.announcedLater": "It will be announced once an announcement channel is set.", - "set.private": "It will not be announced: it only counts in `/birthday stats`.", - "set.data": "Only the day and the month are stored. `/birthday show` shows them, `/birthday remove` deletes them; leaving the server deletes them too.", "show.none": "You have not registered your birthday on this server.", - "show.entry": "Your birthday: **{{date}}**.", - "show.announced": "It is announced on the server.", - "show.private": "It is not announced: it only counts in the statistics.", - "show.stored": "That is all the module stores about you, with your Discord ID. `/birthday remove` deletes it.", + "own.title.set": "### 🎂 Birthday saved", + "own.title.show": "### 🎂 Your birthday", + "own.announced": "Announced in {{channel}}", + "own.announcedLater": "Will be announced once an announcement channel is set", + "own.private": "Not announced: only counted in `/birthday stats`", + "own.stored": "Only the day, the month and your announcement choice are kept, with your Discord ID. `/birthday remove` deletes them; leaving the server does too.", "announce.hide": "Stop announcing my birthday", "announce.show": "Announce my birthday", @@ -93,7 +90,7 @@ "remove.done": "Your birthday is deleted.", "remove.none": "You had no birthday registered on this server.", - "upcoming.heading": "🎂 **Birthdays of the next {{count}} days**", + "upcoming.heading": "### 🎂 Birthdays in the next {{count}} days", "upcoming.none": "No birthday in the next {{count}} days.", "upcoming.line": "**{{date}}** · {{user}} · {{when}}", "upcoming.more_one": "… and {{count}} more birthday", diff --git a/src/modules/birthday/i18n/fr.json b/src/modules/birthday/i18n/fr.json index 11221a19..9d049b32 100644 --- a/src/modules/birthday/i18n/fr.json +++ b/src/modules/birthday/i18n/fr.json @@ -70,17 +70,14 @@ "paradox.field.nextValue": "{{count}} inscriptions · {{percent}}", "set.invalid": "Cette date n'existe pas.", - "set.done": "🎂 Votre anniversaire est enregistré : **{{date}}**.", - "set.announced": "Il sera annoncé dans {{channel}}.", - "set.announcedLater": "Il sera annoncé dès qu'un salon d'annonces sera défini.", - "set.private": "Il ne sera pas annoncé : il compte seulement dans `/birthday stats`.", - "set.data": "Seuls le jour et le mois sont conservés. `/birthday show` les affiche, `/birthday remove` les supprime ; quitter le serveur les supprime aussi.", "show.none": "Vous n'avez pas enregistré votre anniversaire sur ce serveur.", - "show.entry": "Votre anniversaire : **{{date}}**.", - "show.announced": "Il est annoncé sur le serveur.", - "show.private": "Il n'est pas annoncé : il compte seulement dans les statistiques.", - "show.stored": "C'est tout ce que le module conserve à votre sujet, avec votre identifiant Discord. `/birthday remove` le supprime.", + "own.title.set": "### 🎂 Anniversaire enregistré", + "own.title.show": "### 🎂 Votre anniversaire", + "own.announced": "Annoncé dans {{channel}}", + "own.announcedLater": "Sera annoncé dès qu'un salon d'annonces sera défini", + "own.private": "Non annoncé : compte seulement dans `/birthday stats`", + "own.stored": "Seuls le jour, le mois et votre choix d'annonce sont conservés, avec votre identifiant Discord. `/birthday remove` les supprime ; quitter le serveur aussi.", "announce.hide": "Ne plus annoncer mon anniversaire", "announce.show": "Annoncer mon anniversaire", @@ -91,7 +88,7 @@ "remove.done": "Votre anniversaire est supprimé.", "remove.none": "Vous n'aviez pas d'anniversaire enregistré sur ce serveur.", - "upcoming.heading": "🎂 **Les anniversaires des {{count}} prochains jours**", + "upcoming.heading": "### 🎂 Les anniversaires des {{count}} prochains jours", "upcoming.none": "Aucun anniversaire dans les {{count}} prochains jours.", "upcoming.line": "**{{date}}** · {{user}} · {{when}}", "upcoming.more_one": "… et {{count}} autre anniversaire", diff --git a/src/modules/birthday/interactions/announce.button.test.ts b/src/modules/birthday/interactions/announce.button.test.ts index a17384b5..6dd04389 100644 --- a/src/modules/birthday/interactions/announce.button.test.ts +++ b/src/modules/birthday/interactions/announce.button.test.ts @@ -1,6 +1,11 @@ -import type { ButtonInteraction } from "discord.js"; +import { MessageFlags, type ButtonInteraction } from "discord.js"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { initTestI18n, makeTestConfig, silenceLogs } from "#lib/testing.js"; +import { + initTestI18n, + makeTestConfig, + messageText, + silenceLogs, +} from "#lib/testing.js"; import { birthdayConfigSchema } from "#modules/birthday/birthday.config.js"; import en from "#modules/birthday/i18n/en.json" with { type: "json" }; import fr from "#modules/birthday/i18n/fr.json" with { type: "json" }; @@ -30,12 +35,18 @@ function click() { return { interaction, editReply }; } -type Reply = { content: string; components: { toJSON(): unknown }[] }; +type Reply = { + content?: string; + flags?: number; + components: { toJSON(): unknown }[]; +}; const replyOf = (editReply: ReturnType) => (editReply.mock.calls[0] as [Reply])[0]; +// The button sits beside the date, in the sheet's section. const buttonId = (reply: Reply) => - (reply.components[0]!.toJSON() as { components: { custom_id: string }[] }) - .components[0]!.custom_id; + JSON.stringify(reply.components[0]!.toJSON()).match( + /"custom_id":"([^"]+)"/ + )![1]; beforeAll(async () => { silenceLogs(); @@ -67,8 +78,24 @@ describe("announce button", () => { "me" ); const reply = replyOf(editReply); - expect(reply.content).toContain("Il n'est pas annoncé"); + expect(messageText(reply)).toContain("Non annoncé"); + // Laid out with components, as the reply it edits. + expect(reply.flags).toBe(MessageFlags.IsComponentsV2); + // A reply sent before the sheets had content: the edit clears it. + expect(reply.content).toBe(""); expect(buttonId(reply)).toBe("birthday-announce:on"); + expect(messageText(reply)).toContain("Votre anniversaire"); + }); + + it("keeps the title of the /birthday set sheet it edits", async () => { + service.list.mockResolvedValue([ + { userId: "me", day: 14, month: 3, announce: true }, + ]); + const { interaction, editReply } = click(); + await button.execute(interaction, ["off", "set"], config()); + const reply = replyOf(editReply); + expect(messageText(reply)).toContain("Anniversaire enregistré"); + expect(buttonId(reply)).toBe("birthday-announce:on:set"); }); it("announces a hidden birthday", async () => { @@ -100,9 +127,12 @@ describe("announce button", () => { const { interaction, editReply } = click(); await button.execute(interaction, ["off"], config()); expect(service.set).not.toHaveBeenCalled(); - expect(replyOf(editReply)).toMatchObject({ - content: "Vous n'avez pas enregistré votre anniversaire sur ce serveur.", - components: [], - }); + const reply = replyOf(editReply); + expect(messageText(reply)).toBe( + "Vous n'avez pas enregistré votre anniversaire sur ce serveur." + ); + // An edit of a sheet must stay one: Discord refuses plain content there. + expect(reply.flags).toBe(MessageFlags.IsComponentsV2); + expect(reply.content).toBe(""); }); }); diff --git a/src/modules/birthday/interactions/announce.button.ts b/src/modules/birthday/interactions/announce.button.ts index 2ae282b2..4c78593b 100644 --- a/src/modules/birthday/interactions/announce.button.ts +++ b/src/modules/birthday/interactions/announce.button.ts @@ -5,7 +5,8 @@ import { NO_MENTIONS } from "#lib/mentions.js"; import type { BirthdayConfigSchema } from "#modules/birthday/birthday.config.js"; import { ANNOUNCE_BUTTON_ID, - showMessage, + ownBirthdaySheet, + sheetNotice, } from "#modules/birthday/commands/own-birthday.js"; import birthdayService from "#modules/birthday/services/birthday.service.js"; import { dropBirthdayRole } from "#modules/birthday/services/role.js"; @@ -24,7 +25,7 @@ export default declareInteractionHandler< access: "everyone", check: (interaction): interaction is ButtonInteraction => interaction.isButton(), - async execute(interaction, [state], config) { + async execute(interaction, [state, title], config) { if (!interaction.inGuild() || (state !== "on" && state !== "off")) { return; } @@ -34,9 +35,11 @@ export default declareInteractionHandler< const entries = await birthdayService.list(guildId); const own = entries.find((entry) => entry.userId === userId); if (!own) { + // A reply sent before the sheets had content: an edit to components + // must clear it, or Discord refuses it. await interaction.editReply({ - content: config.t("show.none"), - components: [], + content: "", + ...sheetNotice(config.t("show.none")), allowedMentions: NO_MENTIONS, }); return; @@ -53,11 +56,13 @@ export default declareInteractionHandler< } } await interaction.editReply({ - ...(await showMessage( + content: "", + ...(await ownBirthdaySheet( interaction, config, entry, - entries.map((other) => (other.userId === userId ? entry : other)) + entries.map((other) => (other.userId === userId ? entry : other)), + title === "set" ? "set" : "show" )), allowedMentions: NO_MENTIONS, }); diff --git a/src/modules/birthday/rendering/theme.ts b/src/modules/birthday/rendering/theme.ts index f182662b..8c2e8da9 100644 --- a/src/modules/birthday/rendering/theme.ts +++ b/src/modules/birthday/rendering/theme.ts @@ -2,4 +2,6 @@ import { festiveConfetti } from "#lib/celebration-card.js"; import type { Rgb } from "#lib/imaging.js"; export const ACCENT: Rgb = [255, 158, 196]; +/** The accent, as Discord takes a container's colour. */ +export const ACCENT_COLOR = (ACCENT[0] << 16) | (ACCENT[1] << 8) | ACCENT[2]; export const CONFETTI = festiveConfetti(ACCENT);