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
4 changes: 2 additions & 2 deletions docs/functional.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ Supprime l'anniversaire d'un membre. Il n'existe volontairement aucune commande

## Module Ancienneté

Fête l'ancienneté des membres sur le serveur et l'anniversaire du serveur. Le module enregistre le dernier jour annoncé de chaque serveur et les anciennetés recalculées par un administrateur (`/seniority-admin recompute`) ; la date d'arrivée d'un membre et la date de création du serveur viennent de Discord. Il a besoin de l'intent privilégié **Server Members** pour lister les membres et leur date d'arrivée. La recherche de messages du recalcul n'abonne le bot à aucun intent de plus, mais Discord ne l'ouvre qu'aux applications qui ont l'accès **Message Content** ; Discord y renvoie les plus anciens messages du membre, contenu compris, mais seule la date du plus ancien sert : leur contenu n'est ni utilisé ni enregistré.
Fête l'ancienneté des membres sur le serveur et l'anniversaire du serveur. Le module enregistre le dernier jour annoncé de chaque serveur et les anciennetés recalculées par un administrateur (`/seniority-admin recompute`), avec le jour où le bot a constaté le départ du membre ; la date d'arrivée d'un membre et la date de création du serveur viennent de Discord. Il a besoin de l'intent privilégié **Server Members** pour lister les membres et leur date d'arrivée. La recherche de messages du recalcul n'abonne le bot à aucun intent de plus, mais Discord ne l'ouvre qu'aux applications qui ont l'accès **Message Content** ; Discord y renvoie les plus anciens messages du membre, contenu compris, mais seule la date du plus ancien sert : leur contenu n'est ni utilisé ni enregistré.

### Annonces quotidiennes

Expand All @@ -589,7 +589,7 @@ Fait partir l'ancienneté d'un membre de son premier message sur le serveur quan

La réponse, visible du seul administrateur, donne la date retenue et l'arrivée actuelle ; si Discord refuse la recherche (accès Message Content non activé pour l'application, aucun salon lisible), elle le dit plutôt que d'inviter à réessayer. Le jour fêté devient celui du premier message : l'année du recalcul, un membre peut donc être fêté deux fois, ou pas du tout, selon que ce jour tombe après ou avant celui de son arrivée. Seul un membre présent peut être recalculé, jamais un bot. Si Discord indexe encore les messages du serveur, la commande attend jusqu'à une minute (plus si Discord limite le débit des recherches) puis demande de réessayer plus tard ; de même quand Discord indexe encore l'historique ancien et qu'aucun message plus ancien n'est trouvé. Le recalcul se fait membre par membre, à la demande : il n'existe pas de recalcul de tout le serveur.

Une ancienneté recalculée est conservée quand le membre quitte le serveur, pour rester juste s'il revient. Elle est supprimée par `/privacy delete`, ce qui n'empêche pas un administrateur de la recalculer ensuite, et quand le module est désactivé : `/modules` demande alors confirmation en indiquant combien d'anciennetés recalculées seraient perdues.
Une ancienneté recalculée est conservée un an après le départ du membre, pour rester juste s'il revient dans l'année, puis supprimée ; un retour constaté par le bot annule le décompte. Le bot vérifie chaque jour, d'après la liste des membres du serveur, qui a quitté le serveur ou y est revenu : le départ est daté à un jour près, ou au redémarrage du bot s'il a eu lieu pendant une panne. Si le bot quitte le serveur, les anciennetés recalculées et leurs dates de départ restent enregistrées jusqu'à son retour, comme la configuration. Une ancienneté recalculée est aussi supprimée par `/privacy delete`, ce qui n'empêche pas un administrateur de la recalculer ensuite, et quand le module est désactivé : `/modules` demande alors confirmation en indiquant combien d'anciennetés recalculées seraient perdues.

### Configuration — `/config seniority`

Expand Down
4 changes: 2 additions & 2 deletions docs/site/en/legal/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ The Birthdays module stores, only for the members who register it themselves wit

### Member anniversaries

The Member anniversaries module stores, for each guild, the last day announced. It reads the members' join dates and the guild's creation date, provided by Discord, without storing them. When an administrator recomputes a member's seniority (`/seniority-admin recompute`), it stores the date it counts from: the member's first message on the guild, when it predates their current arrival. Purpose: celebrating the real seniority of a member who left and came back, whose join date Discord reset. Legal basis: the guild's legitimate interest in celebrating its members' real seniority; the member can have this date deleted at any time with `/privacy delete`.
The Member anniversaries module stores, for each guild, the last day announced. It reads the members' join dates and the guild's creation date, provided by Discord, without storing them. When an administrator recomputes a member's seniority (`/seniority-admin recompute`), it stores the date it counts from: the member's first message on the guild, when it predates their current arrival, and, once the member has left, the day the bot noticed their departure. Purpose: celebrating the real seniority of a member who left and came back, whose join date Discord reset. Legal basis: the guild's legitimate interest in celebrating its members' real seniority; the member can have this date deleted at any time with `/privacy delete`.

## Message Content Read Without Being Stored

Expand Down Expand Up @@ -118,7 +118,7 @@ Data is stored in a PostgreSQL database managed by the bot instance operator. Th
- **Sticky messages**: kept until an administrator removes them in `/config`, or the channel is deleted.
- **Starboard**: an entry is deleted with the original message, with its repost when it falls below the threshold and the administrators chose to remove it, when the original channel or thread is deleted (the repost is deleted too), or when the starboard channel is deleted. When a moderator deletes a repost, the entry is kept so that the message is never reposted. The bot only sees these deletions while the module is enabled and the bot is online: a message or channel deleted while the module is disabled, or while the bot is offline, leaves its entry and its repost in place. The guild's administrators can then delete the repost by hand.
- **Temporary voice channels**: a channel's entry is deleted with the channel, when it empties or is deleted; if the bot is offline at that moment, it is deleted when the bot comes back, even if the module was disabled in the meantime. A member's remembered settings are deleted one year after they last changed them or created a channel, even if the module is disabled.
- **Recomputed seniority**: kept when the member leaves the guild, so that it still holds if they come back; deleted by `/privacy delete` (an administrator can recompute it again afterwards) and when the module is disabled on the guild, unlike the data of other modules. A member who has left the guild can no longer run `/privacy delete`: they ask the instance operator to delete it.
- **Recomputed seniority**: kept for a year after the bot notices the member left the guild (checked daily, or when the bot restarts after an outage), so that it still holds if they come back within the year, then deleted; if the bot leaves the guild, it stays stored until the bot comes back, like the configuration; deleted by `/privacy delete` (an administrator can recompute it again afterwards) and when the module is disabled on the guild, unlike the data of other modules. A member who has left the guild can no longer run `/privacy delete`: they ask the instance operator to delete it.
- **Birthdays**: permanently deleted when the member removes them (`/birthday remove` or `/privacy delete`), leaves the guild, or when the module is disabled on the guild, like recomputed seniority and unlike the data of other modules. If the bot leaves the guild, they stay stored, like the configuration, until it comes back. `/birthday show` shows what is kept.

Any member can delete the data the bot keeps about them on a guild themselves, at any time, with the `/privacy delete` command: their activity statistics, their game data (4-hour game, RNGdle, Clash of Code link), their birthday, their recomputed seniority and their mention preference. Reports are not part of it: they stay with the moderators until they are deleted automatically. For any other deletion request, contact the instance operator.
Expand Down
4 changes: 2 additions & 2 deletions docs/site/fr/legal/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Le module Anniversaires enregistre, pour les seuls membres qui l'enregistrent eu

### Ancienneté

Le module Ancienneté enregistre, pour chaque serveur, le dernier jour annoncé. Il lit la date d'arrivée des membres et la date de création du serveur, fournies par Discord, sans les stocker. Quand un administrateur recalcule l'ancienneté d'un membre (`/seniority-admin recompute`), il enregistre la date d'où elle part, celle du premier message du membre sur le serveur si elle précède son arrivée actuelle. Finalité : fêter l'ancienneté réelle d'un membre parti puis revenu, dont Discord a remis la date d'arrivée à zéro. Base légale : l'intérêt légitime du serveur à fêter l'ancienneté réelle de ses membres ; le membre peut faire supprimer cette date à tout moment avec `/privacy delete`.
Le module Ancienneté enregistre, pour chaque serveur, le dernier jour annoncé. Il lit la date d'arrivée des membres et la date de création du serveur, fournies par Discord, sans les stocker. Quand un administrateur recalcule l'ancienneté d'un membre (`/seniority-admin recompute`), il enregistre la date d'où elle part, celle du premier message du membre sur le serveur si elle précède son arrivée actuelle, et, une fois le membre parti, le jour où le bot a constaté son départ. Finalité : fêter l'ancienneté réelle d'un membre parti puis revenu, dont Discord a remis la date d'arrivée à zéro. Base légale : l'intérêt légitime du serveur à fêter l'ancienneté réelle de ses membres ; le membre peut faire supprimer cette date à tout moment avec `/privacy delete`.

## Contenu des messages lu sans être stocké

Expand Down Expand Up @@ -118,7 +118,7 @@ Les données sont stockées dans une base de données PostgreSQL gérée par l'o
- **Messages collants** : conservés jusqu'à ce qu'un administrateur les retire dans `/config`, ou que le salon soit supprimé.
- **Starboard** : une entrée est supprimée avec le message d'origine, avec son repost quand il repasse sous le seuil et que les administrateurs ont choisi de le retirer, quand le salon ou le fil d'origine est supprimé (le repost l'est aussi), ou quand le salon starboard est supprimé. Quand un modérateur supprime un repost, l'entrée est conservée pour que le message ne soit plus jamais reposté. Le bot ne voit ces suppressions que quand le module est activé et qu'il est en ligne : un message ou un salon supprimé pendant que le module est désactivé, ou que le bot est hors ligne, laisse son entrée et son repost en place. Le repost peut alors être supprimé à la main par les administrateurs du serveur.
- **Salons vocaux temporaires** : l'entrée d'un salon est supprimée avec le salon, quand il se vide ou qu'il est supprimé ; si le bot est hors ligne à ce moment-là, elle l'est à son retour, même si le module a été désactivé entre-temps. Les réglages mémorisés d'un membre sont supprimés un an après la dernière fois qu'il les a modifiés ou qu'il a créé un salon, même si le module est désactivé.
- **Ancienneté recalculée** : conservée quand le membre quitte le serveur, pour rester juste s'il revient ; supprimée par `/privacy delete` (un administrateur peut la recalculer ensuite) et quand le module est désactivé sur le serveur, contrairement aux données des autres modules. Un membre qui a quitté le serveur ne peut plus lancer `/privacy delete` : il en demande la suppression à l'opérateur de l'instance.
- **Ancienneté recalculée** : conservée un an après que le bot a constaté le départ du membre (vérifié chaque jour, ou à son redémarrage après une panne), pour rester juste s'il revient dans l'année, puis supprimée ; si le bot quitte le serveur, elle reste enregistrée jusqu'à son retour, comme la configuration ; supprimée par `/privacy delete` (un administrateur peut la recalculer ensuite) et quand le module est désactivé sur le serveur, contrairement aux données des autres modules. Un membre qui a quitté le serveur ne peut plus lancer `/privacy delete` : il en demande la suppression à l'opérateur de l'instance.
- **Anniversaires** : supprimés définitivement quand le membre les retire (`/birthday remove` ou `/privacy delete`), quand il quitte le serveur, ou quand le module est désactivé sur le serveur, comme l'ancienneté recalculée et contrairement aux données des autres modules. Si le bot quitte le serveur, ils restent enregistrés, comme la configuration, jusqu'à son retour. `/birthday show` affiche ce qui est conservé.

Chaque membre peut supprimer lui-même, à tout moment, les données que le bot garde sur lui sur un serveur, avec la commande `/privacy delete` : ses statistiques d'activité, ses données de jeu (jeu des 4h, RNGdle, liaison Clash of Code), son anniversaire, son ancienneté recalculée et sa préférence de mention. Les signalements n'en font pas partie : ils restent aux modérateurs jusqu'à leur suppression automatique. Pour toute autre demande de suppression, contactez l'opérateur de l'instance.
Expand Down
2 changes: 1 addition & 1 deletion src/core/loaders/maintenance-tasks.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe("maintenance tasks of the shipped modules", async () => {
// A module that fails to import is skipped, not thrown: make sure the
// ones with maintenance tasks are actually checked below.
expect(modules.map((module) => module.id)).toEqual(
expect.arrayContaining(["reports", "server-stats"])
expect.arrayContaining(["reports", "server-stats", "seniority"])
);
});

Expand Down
18 changes: 17 additions & 1 deletion src/lib/moderation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DiscordAPIError, RESTJSONErrorCodes, type Guild } from "discord.js";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { fetchMember } from "./moderation.js";

function apiError(code: number) {
Expand Down Expand Up @@ -29,6 +29,22 @@ describe("fetchMember", () => {
).resolves.toBeNull();
});

it("treats an id Discord knows no user for as gone", async () => {
await expect(
fetchMember(
guildFailingWith(apiError(RESTJSONErrorCodes.UnknownUser)),
"author"
)
).resolves.toBeNull();
});

it("can ask Discord rather than the cache", async () => {
const fetch = vi.fn(async () => ({ id: "author" }));
const guild = { members: { fetch } } as unknown as Guild;
await fetchMember(guild, "author", { force: true });
expect(fetch).toHaveBeenCalledWith({ user: "author", force: true });
});

it("does not mistake another failure for a member who left", async () => {
const error = new Error("network down");

Expand Down
15 changes: 12 additions & 3 deletions src/lib/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@ import {

export const MAX_BAN_PURGE_SECONDS = 7 * 24 * 60 * 60;

/**
* The member, or null when they are not on the server, or when Discord knows no
* user with that id (a deleted account still has one). `force` asks Discord rather than the cache, which can keep a
* member whose departure the bot missed.
*/
export async function fetchMember(
guild: Guild,
userId: string
userId: string,
{ force = false } = {}
): Promise<GuildMember | null> {
try {
return await guild.members.fetch(userId);
return await guild.members.fetch(
force ? { user: userId, force: true } : userId
);
} catch (error) {
if (
error instanceof DiscordAPIError &&
error.code === RESTJSONErrorCodes.UnknownMember
(error.code === RESTJSONErrorCodes.UnknownMember ||
error.code === RESTJSONErrorCodes.UnknownUser)
) {
return null;
}
Expand Down
6 changes: 5 additions & 1 deletion src/modules/seniority/seniority.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { GatewayIntentBits } from "discord.js";
import { loggerMaker } from "#lib/logger.js";
import { defineModule } from "#lib/module.js";
import type { ScheduledTask } from "#lib/task.js";
import seniorityAdminCommand from "./commands/seniority-admin.command.js";
import { seniorityConfigSchema } from "./seniority.config.js";
import { clearGuild, remove } from "./services/overrides.js";
import { uninstallWarning } from "./services/uninstall.js";
import { seniorityTasks } from "./tasks.js";
import { seniorityMaintenanceTasks, seniorityTasks } from "./tasks.js";

const logger = loggerMaker("seniority");

Expand Down Expand Up @@ -49,6 +50,9 @@ const seniorityModule = defineModule({

uninstallWarning,

maintenanceTasks: (): ScheduledTask[] =>
seniorityMaintenanceTasks(seniorityModule.id),

async deleteMemberData(guildId, userId) {
await remove(guildId, userId);
},
Expand Down
7 changes: 5 additions & 2 deletions src/modules/seniority/seniority.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ model SeniorityState {
// A member's seniority recomputed by an administrator: the earliest of their
// current arrival and their first message still on the server. Discord resets
// the arrival date of a member who leaves and comes back; this row is kept
// after they leave, so that it still holds when they return. Deleted by
// `/privacy delete` and when the module is uninstalled.
// after they leave, so that it still holds when they return, for a year.
// Deleted by `/privacy delete` and when the module is uninstalled.
model SeniorityOverride {
guildId String
userId String
since DateTime
// When the daily sweep (`sweepDepartures`) first found the member gone, null
// while they are on the server: the row is deleted a year later.
leftAt DateTime?

@@id([guildId, userId])
}
Loading
Loading