Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/functional.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -525,19 +526,19 @@ Ces annonces ne citent que des chiffres : ni membre, ni date. Tous les anniversa

### `/birthday set <day> <month> [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

La réponse de `/birthday set` et de `/birthday show` indique aussi avec qui le membre partage sa date sur le serveur : « Vous partagez votre date avec @A et @B. », ou rien quand personne ne la partage. Ne sont nommés que les membres qui ont accepté d'être annoncés et qui sont encore sur le serveur ; un membre caché n'est jamais nommé ni compté. Un membre caché voit lui aussi ses jumeaux annoncés, déjà visibles dans `/birthday upcoming`. La date est celle enregistrée : le 29 février n'a pour jumeaux que d'autres 29 février, comme dans `/birthday stats`, même s'il est fêté le 28 les années non bissextiles. Au-delà de dix jumeaux, les suivants sont comptés (« et 3 autres membres »). Personne n'est notifié. Si Discord ne dit pas qui est encore sur le serveur, la ligne est omise.

### `/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]`

Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
174 changes: 174 additions & 0 deletions src/lib/message-content.test.ts
Original file line number Diff line number Diff line change
@@ -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)}||`);
});
133 changes: 133 additions & 0 deletions src/lib/message-content.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<Message, "components">>;

/** 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<string> {
const ids = new Set<string>();
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;
}
Loading
Loading