feat: ajouter la gestion des textes d'activité multiples et de l'intervalle de rotation dans la commande de présence

This commit is contained in:
Puechberty Arthur
2026-04-12 11:38:47 +02:00
parent 4e75be191a
commit e2bcb92e15
8 changed files with 595 additions and 74 deletions
+13 -6
View File
@@ -69,12 +69,13 @@
}, },
"responses": { "responses": {
"slashOnly": "This command is only available as a slash command.", "slashOnly": "This command is only available as a slash command.",
"panel": "Presence panel\nStatus: {{status}}\nActivity: {{activityType}}\nText: {{activityText}}", "panel": "Presence panel\nStatus: {{status}}\nActivity: {{activityType}}\nRotation: every {{rotationIntervalSeconds}}s\nActive text ({{currentTextIndex}}/{{textCount}}): {{activityText}}\nRendered preview: {{activityPreview}}\nTexts: {{activityTexts}}\nVariables ({{doubleBracesHint}}): {{variables}}",
"statusUpdated": "Status updated to {{status}}.", "statusUpdated": "Status updated to {{status}}.",
"activityUpdated": "Activity type updated to {{activityType}}.", "activityUpdated": "Activity type updated to {{activityType}}.",
"textUpdated": "Presence text updated to: {{activityText}}", "textUpdated": "Presence text updated to: {{activityText}}",
"notOwner": "Only the command author can use this panel.", "notOwner": "Only the command author can use this panel.",
"invalidSelection": "Invalid selection received.", "invalidSelection": "Invalid selection received.",
"invalidInterval": "Invalid interval. Use an integer between {{minSeconds}} and {{maxSeconds}} seconds.",
"modalTimeout": "Text update timed out. Click the button again." "modalTimeout": "Text update timed out. Click the button again."
}, },
"ui": { "ui": {
@@ -134,7 +135,7 @@
}, },
"embed": { "embed": {
"title": "Presence Configuration", "title": "Presence Configuration",
"description": "Change status, activity type, and text in real time.", "description": "Change status, activity type, multiple texts, and rotation interval in real time.",
"footer": "Interactive /presence panel", "footer": "Interactive /presence panel",
"fields": { "fields": {
"status": "Status", "status": "Status",
@@ -142,11 +143,17 @@
"text": "Text" "text": "Text"
} }
}, },
"textButton": "📝 Edit text", "textButton": "📝 Edit texts",
"intervalButton": "⏱️ Interval",
"modal": { "modal": {
"title": "Presence text", "title": "Presence texts",
"label": "Activity text", "label": "One text per line (use {{var}})",
"placeholder": "Example: 🚀 In maintenance" "placeholder": "Maintenance\n{{guild_count}} servers | {{prefix}}help"
},
"intervalModal": {
"title": "Rotation interval",
"label": "Seconds between each text",
"placeholder": "Example: 30"
} }
} }
}, },
+13 -6
View File
@@ -69,12 +69,13 @@
}, },
"responses": { "responses": {
"slashOnly": "Este comando solo esta disponible como slash.", "slashOnly": "Este comando solo esta disponible como slash.",
"panel": "Panel de presencia\nEstado: {{status}}\nActividad: {{activityType}}\nTexto: {{activityText}}", "panel": "Panel de presencia\nEstado: {{status}}\nActividad: {{activityType}}\nRotacion: cada {{rotationIntervalSeconds}}s\nTexto activo ({{currentTextIndex}}/{{textCount}}): {{activityText}}\nVista previa: {{activityPreview}}\nTextos: {{activityTexts}}\nVariables ({{doubleBracesHint}}): {{variables}}",
"statusUpdated": "Estado actualizado a {{status}}.", "statusUpdated": "Estado actualizado a {{status}}.",
"activityUpdated": "Tipo de actividad actualizado a {{activityType}}.", "activityUpdated": "Tipo de actividad actualizado a {{activityType}}.",
"textUpdated": "Texto de presencia actualizado: {{activityText}}", "textUpdated": "Texto de presencia actualizado: {{activityText}}",
"notOwner": "Solo el autor del comando puede usar este panel.", "notOwner": "Solo el autor del comando puede usar este panel.",
"invalidSelection": "Seleccion invalida.", "invalidSelection": "Seleccion invalida.",
"invalidInterval": "Intervalo invalido. Usa un entero entre {{minSeconds}} y {{maxSeconds}} segundos.",
"modalTimeout": "Tiempo agotado para editar el texto. Pulsa el boton otra vez." "modalTimeout": "Tiempo agotado para editar el texto. Pulsa el boton otra vez."
}, },
"ui": { "ui": {
@@ -134,7 +135,7 @@
}, },
"embed": { "embed": {
"title": "Configuracion de Presencia", "title": "Configuracion de Presencia",
"description": "Cambia el estado, el tipo de actividad y el texto en tiempo real.", "description": "Cambia el estado, el tipo de actividad, varios textos y el intervalo de rotacion en tiempo real.",
"footer": "Panel interactivo /presence", "footer": "Panel interactivo /presence",
"fields": { "fields": {
"status": "Estado", "status": "Estado",
@@ -142,11 +143,17 @@
"text": "Texto" "text": "Texto"
} }
}, },
"textButton": "📝 Editar texto", "textButton": "📝 Editar textos",
"intervalButton": "⏱️ Intervalo",
"modal": { "modal": {
"title": "Texto de presencia", "title": "Textos de presencia",
"label": "Texto de actividad", "label": "Un texto por linea (usa {{var}})",
"placeholder": "Ejemplo: 🚀 En mantenimiento" "placeholder": "Mantenimiento\n{{guild_count}} servidores | {{prefix}}ayuda"
},
"intervalModal": {
"title": "Intervalo de rotacion",
"label": "Segundos entre cada texto",
"placeholder": "Ejemplo: 30"
} }
} }
}, },
+13 -6
View File
@@ -69,12 +69,13 @@
}, },
"responses": { "responses": {
"slashOnly": "Cette commande est disponible uniquement en slash.", "slashOnly": "Cette commande est disponible uniquement en slash.",
"panel": "Panneau presence\nStatut: {{status}}\nActivite: {{activityType}}\nTexte: {{activityText}}", "panel": "Panneau presence\nStatut: {{status}}\nActivite: {{activityType}}\nRotation: toutes les {{rotationIntervalSeconds}}s\nTexte actif ({{currentTextIndex}}/{{textCount}}): {{activityText}}\nApercu rendu: {{activityPreview}}\nTextes: {{activityTexts}}\nVariables ({{doubleBracesHint}}): {{variables}}",
"statusUpdated": "Statut mis a jour: {{status}}.", "statusUpdated": "Statut mis a jour: {{status}}.",
"activityUpdated": "Type d activite mis a jour: {{activityType}}.", "activityUpdated": "Type d activite mis a jour: {{activityType}}.",
"textUpdated": "Texte de presence mis a jour: {{activityText}}", "textUpdated": "Texte de presence mis a jour: {{activityText}}",
"notOwner": "Seul l auteur de la commande peut utiliser ce panneau.", "notOwner": "Seul l auteur de la commande peut utiliser ce panneau.",
"invalidSelection": "Selection invalide.", "invalidSelection": "Selection invalide.",
"invalidInterval": "Intervalle invalide. Utilise un entier entre {{minSeconds}} et {{maxSeconds}} secondes.",
"modalTimeout": "Temps depasse pour la modification du texte. Clique a nouveau sur le bouton." "modalTimeout": "Temps depasse pour la modification du texte. Clique a nouveau sur le bouton."
}, },
"ui": { "ui": {
@@ -134,7 +135,7 @@
}, },
"embed": { "embed": {
"title": "Configuration de la presence", "title": "Configuration de la presence",
"description": "Modifie le statut, le type d activite et le texte en direct.", "description": "Modifie le statut, le type d activite, plusieurs textes et leur rotation.",
"footer": "Panneau interactif /presence", "footer": "Panneau interactif /presence",
"fields": { "fields": {
"status": "Statut", "status": "Statut",
@@ -142,11 +143,17 @@
"text": "Texte" "text": "Texte"
} }
}, },
"textButton": "📝 Modifier le texte", "textButton": "📝 Modifier les textes",
"intervalButton": "⏱️ Intervalle",
"modal": { "modal": {
"title": "Texte de presence", "title": "Textes de presence",
"label": "Texte d activite", "label": "Un texte par ligne (utilise {{var}})",
"placeholder": "Exemple: 🚀 En maintenance" "placeholder": "Maintenance\n{{guild_count}} serveurs | {{prefix}}aide"
},
"intervalModal": {
"title": "Intervalle de rotation",
"label": "Secondes entre chaque texte",
"placeholder": "Exemple: 30"
} }
} }
}, },
+228 -41
View File
@@ -16,13 +16,24 @@ import {
import { defineCommand } from "../../framework/commands/defineCommand.js"; import { defineCommand } from "../../framework/commands/defineCommand.js";
import { env } from "../../framework/config/env.js"; import { env } from "../../framework/config/env.js";
import { getPresenceStore } from "../../framework/presence/presenceStore.js"; import { getPresenceStore } from "../../framework/presence/presenceStore.js";
import {
PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS,
containsPresenceTemplateVariables,
getPresenceTemplateHelpText,
renderPresenceTemplate,
} from "../../framework/presence/presenceTemplateVariables.js";
import { import {
PRESENCE_ACTIVITY_TYPES, PRESENCE_ACTIVITY_TYPES,
PRESENCE_STATUSES, PRESENCE_STATUSES,
MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
createDefaultPresenceState, createDefaultPresenceState,
isPresenceActivityTypeValue, isPresenceActivityTypeValue,
isPresenceRotationIntervalSecondsValue,
isPresenceStatusValue, isPresenceStatusValue,
sanitizeActivityText, sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
type PresenceActivityTypeValue, type PresenceActivityTypeValue,
type PresenceState, type PresenceState,
type PresenceStatusValue, type PresenceStatusValue,
@@ -33,10 +44,17 @@ interface PresenceCustomIds {
statusSelect: string; statusSelect: string;
activitySelect: string; activitySelect: string;
textButton: string; textButton: string;
intervalButton: string;
textModal: string; textModal: string;
textInput: string; textInput: string;
intervalModal: string;
intervalInput: string;
} }
let dynamicPresenceRefreshTimer: NodeJS.Timeout | null = null;
let presenceRotationTimer: NodeJS.Timeout | null = null;
let activePresenceTextIndex = 0;
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = { const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
PLAYING: ActivityType.Playing, PLAYING: ActivityType.Playing,
STREAMING: ActivityType.Streaming, STREAMING: ActivityType.Streaming,
@@ -53,6 +71,66 @@ const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatu
const resolveBotId = (client: Client): string | null => client.user?.id ?? null; const resolveBotId = (client: Client): string | null => client.user?.id ?? null;
const normalizePresenceActivityState = (state: PresenceState): void => {
const activityTexts = sanitizeActivityTexts(state.activity.texts);
state.activity.texts = activityTexts;
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
if (activePresenceTextIndex >= activityTexts.length) {
activePresenceTextIndex = 0;
}
};
const getActivePresenceTemplateText = (state: PresenceState): string => {
normalizePresenceActivityState(state);
return state.activity.texts[activePresenceTextIndex] ?? state.activity.text;
};
const hasTemplateVariablesInPresenceState = (state: PresenceState): boolean => {
normalizePresenceActivityState(state);
return state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
};
const syncDynamicPresenceTimers = (client: Client, state: PresenceState): void => {
normalizePresenceActivityState(state);
if (dynamicPresenceRefreshTimer) {
clearInterval(dynamicPresenceRefreshTimer);
dynamicPresenceRefreshTimer = null;
}
if (presenceRotationTimer) {
clearInterval(presenceRotationTimer);
presenceRotationTimer = null;
}
if (state.activity.texts.length > 1) {
presenceRotationTimer = setInterval(() => {
normalizePresenceActivityState(state);
if (state.activity.texts.length <= 1) {
activePresenceTextIndex = 0;
return;
}
activePresenceTextIndex = (activePresenceTextIndex + 1) % state.activity.texts.length;
applyPresenceState(client, state);
}, state.activity.rotationIntervalSeconds * 1_000);
presenceRotationTimer.unref?.();
}
if (!hasTemplateVariablesInPresenceState(state)) {
return;
}
dynamicPresenceRefreshTimer = setInterval(() => {
applyPresenceState(client, state);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
dynamicPresenceRefreshTimer.unref?.();
};
const loadPresenceState = async (client: Client): Promise<PresenceState> => { const loadPresenceState = async (client: Client): Promise<PresenceState> => {
const botId = resolveBotId(client); const botId = resolveBotId(client);
if (!botId) { if (!botId) {
@@ -76,8 +154,11 @@ const applyPresenceState = (client: Client, state: PresenceState): void => {
return; return;
} }
normalizePresenceActivityState(state);
const status = resolveDiscordStatus(state.status); const status = resolveDiscordStatus(state.status);
const text = sanitizeActivityText(state.activity.text); const templateText = getActivePresenceTemplateText(state);
const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") { if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({ client.user.setPresence({
status, status,
@@ -123,8 +204,11 @@ const createCustomIds = (): PresenceCustomIds => {
statusSelect: `presence:status:${nonce}`, statusSelect: `presence:status:${nonce}`,
activitySelect: `presence:activity:${nonce}`, activitySelect: `presence:activity:${nonce}`,
textButton: `presence:text:${nonce}`, textButton: `presence:text:${nonce}`,
intervalButton: `presence:interval:${nonce}`,
textModal: `presence:modal:${nonce}`, textModal: `presence:modal:${nonce}`,
textInput: `presence:text-input:${nonce}`, textInput: `presence:text-input:${nonce}`,
intervalModal: `presence:interval-modal:${nonce}`,
intervalInput: `presence:interval-input:${nonce}`,
}; };
}; };
@@ -135,10 +219,24 @@ const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActiv
ctx.ct(`ui.activity.options.${activityType}.label`); ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (ctx: CommandExecutionContext, state: PresenceState): string => { const panelContent = (ctx: CommandExecutionContext, state: PresenceState): string => {
normalizePresenceActivityState(state);
const templateText = getActivePresenceTemplateText(state);
const activityPreview = renderPresenceTemplate(ctx.client, templateText);
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
const currentIndex = Math.min(activePresenceTextIndex + 1, state.activity.texts.length);
const summary = ctx.ct("responses.panel", { const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status), status: statusLabel(ctx, state.status),
activityType: activityLabel(ctx, state.activity.type), activityType: activityLabel(ctx, state.activity.type),
activityText: sanitizeActivityText(state.activity.text), activityText: templateText,
activityPreview,
activityTexts,
textCount: state.activity.texts.length,
currentTextIndex: currentIndex,
rotationIntervalSeconds: state.activity.rotationIntervalSeconds,
doubleBracesHint: "{{var}}",
variables: getPresenceTemplateHelpText(),
}); });
return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`; return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`;
@@ -186,6 +284,12 @@ const buildContainer = (
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
.setDisabled(disabled); .setDisabled(disabled);
const intervalButton = new ButtonBuilder()
.setCustomId(customIds.intervalButton)
.setLabel(ctx.ct("ui.intervalButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const container = new ContainerBuilder(); const container = new ContainerBuilder();
container.addTextDisplayComponents( container.addTextDisplayComponents(
@@ -199,7 +303,7 @@ const buildContainer = (
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect), new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
); );
container.addActionRowComponents( container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton), new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
); );
return container; return container;
@@ -233,12 +337,15 @@ const resolveReplyMessage = (value: unknown): Message | null => {
const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise<void> => { const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise<void> => {
applyPresenceState(ctx.client, state); applyPresenceState(ctx.client, state);
syncDynamicPresenceTimers(ctx.client, state);
await savePresenceState(ctx.client, state); await savePresenceState(ctx.client, state);
}; };
export const restorePresenceFromStorage = async (client: Client): Promise<void> => { export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
const state = await loadPresenceState(client); const state = await loadPresenceState(client);
activePresenceTextIndex = 0;
applyPresenceState(client, state); applyPresenceState(client, state);
syncDynamicPresenceTimers(client, state);
}; };
export const presenceCommand = defineCommand({ export const presenceCommand = defineCommand({
@@ -255,6 +362,7 @@ export const presenceCommand = defineCommand({
execute: async (ctx) => { execute: async (ctx) => {
const state = await loadPresenceState(ctx.client); const state = await loadPresenceState(ctx.client);
applyPresenceState(ctx.client, state); applyPresenceState(ctx.client, state);
syncDynamicPresenceTimers(ctx.client, state);
const customIds = createCustomIds(); const customIds = createCustomIds();
@@ -320,54 +428,133 @@ export const presenceCommand = defineCommand({
} }
if (interaction.isButton()) { if (interaction.isButton()) {
if (interaction.customId !== customIds.textButton) { if (interaction.customId === customIds.textButton) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); normalizePresenceActivityState(state);
const modal = new ModalBuilder()
.setCustomId(customIds.textModal)
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(1_800)
.setValue(state.activity.texts.join("\n")),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextTexts = sanitizeActivityTexts(
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
);
state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text;
activePresenceTextIndex = 0;
await persistAndApplyPresence(ctx, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
ephemeral: true,
}).catch(() => undefined);
}
return; return;
} }
const modal = new ModalBuilder() if (interaction.customId === customIds.intervalButton) {
.setCustomId(customIds.textModal) normalizePresenceActivityState(state);
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(128)
.setValue(sanitizeActivityText(state.activity.text)),
),
);
await interaction.showModal(modal); const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal)
.setTitle(ctx.ct("ui.intervalModal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.intervalInput)
.setLabel(ctx.ct("ui.intervalModal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.intervalModal.placeholder"))
.setRequired(true)
.setMaxLength(4)
.setValue(String(state.activity.rotationIntervalSeconds)),
),
);
try { await interaction.showModal(modal);
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextText = sanitizeActivityText(submitted.fields.getTextInputValue(customIds.textInput)); try {
state.activity.text = nextText; const submitted = await interaction.awaitModalSubmit({
await persistAndApplyPresence(ctx, state); time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.intervalModal
&& modalInteraction.user.id === ownerId,
});
await submitted.deferUpdate(); const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
if (!/^\d+$/.test(rawSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
ephemeral: true,
});
return;
}
await replyMessage.edit({ const nextSeconds = Number(rawSeconds);
flags: MessageFlags.IsComponentsV2, if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) {
components: [buildContainer(ctx, state, customIds)], await submitted.reply({
}); content: ctx.ct("responses.invalidInterval", {
} catch { minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
await interaction.followUp({ maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
content: ctx.ct("responses.modalTimeout"), }),
ephemeral: true, ephemeral: true,
}).catch(() => undefined); });
return;
}
state.activity.rotationIntervalSeconds = nextSeconds;
await persistAndApplyPresence(ctx, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
ephemeral: true,
}).catch(() => undefined);
}
return;
} }
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true });
return; return;
} }
+80 -5
View File
@@ -2,10 +2,13 @@ import { Pool } from "pg";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { import {
DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS,
createDefaultPresenceState, createDefaultPresenceState,
isPresenceActivityTypeValue, isPresenceActivityTypeValue,
isPresenceStatusValue, isPresenceStatusValue,
sanitizeActivityText, sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
type PresenceActivityTypeValue, type PresenceActivityTypeValue,
type PresenceState, type PresenceState,
type PresenceStatusValue, type PresenceStatusValue,
@@ -15,6 +18,8 @@ interface PresenceRow {
status: string; status: string;
activity_type: string; activity_type: string;
activity_text: string; activity_text: string;
activity_texts: string | null;
rotation_interval_seconds: number | null;
} }
const tableSql = ` const tableSql = `
@@ -23,20 +28,67 @@ CREATE TABLE IF NOT EXISTS bot_presence_states (
status TEXT NOT NULL, status TEXT NOT NULL,
activity_type TEXT NOT NULL, activity_type TEXT NOT NULL,
activity_text TEXT NOT NULL, activity_text TEXT NOT NULL,
activity_texts TEXT,
rotation_interval_seconds INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
`; `;
const migrationSql = `
ALTER TABLE bot_presence_states
ADD COLUMN IF NOT EXISTS activity_texts TEXT;
ALTER TABLE bot_presence_states
ADD COLUMN IF NOT EXISTS rotation_interval_seconds INTEGER;
`;
const backfillSql = `
UPDATE bot_presence_states
SET
activity_texts = COALESCE(activity_texts, '[]'),
rotation_interval_seconds = COALESCE(rotation_interval_seconds, ${DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS})
WHERE activity_texts IS NULL OR rotation_interval_seconds IS NULL;
`;
const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => {
if (typeof rawTexts === "string" && rawTexts.trim().length > 0) {
try {
const parsed = JSON.parse(rawTexts) as unknown;
if (Array.isArray(parsed)) {
const stringValues = parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (stringValues.length > 0) {
return sanitizeActivityTexts(stringValues);
}
}
} catch {
// Fallback to legacy single text when malformed JSON is encountered.
}
}
return sanitizeActivityTexts([fallbackText]);
};
const toPresenceState = (row: PresenceRow): PresenceState | null => { const toPresenceState = (row: PresenceRow): PresenceState | null => {
if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) { if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) {
return null; return null;
} }
const texts = parseStoredTexts(row.activity_texts, row.activity_text);
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(
row.rotation_interval_seconds ?? DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS,
);
return { return {
status: row.status as PresenceStatusValue, status: row.status as PresenceStatusValue,
activity: { activity: {
type: row.activity_type as PresenceActivityTypeValue, type: row.activity_type as PresenceActivityTypeValue,
text: sanitizeActivityText(row.activity_text), text: texts[0] ?? sanitizeActivityText(row.activity_text),
texts,
rotationIntervalSeconds,
}, },
}; };
}; };
@@ -46,11 +98,13 @@ class PresenceStore {
public async init(): Promise<void> { public async init(): Promise<void> {
await this.pool.query(tableSql); await this.pool.query(tableSql);
await this.pool.query(migrationSql);
await this.pool.query(backfillSql);
} }
public async getByBotId(botId: string): Promise<PresenceState> { public async getByBotId(botId: string): Promise<PresenceState> {
const result = await this.pool.query<PresenceRow>( const result = await this.pool.query<PresenceRow>(
"SELECT status, activity_type, activity_text FROM bot_presence_states WHERE bot_id = $1 LIMIT 1", "SELECT status, activity_type, activity_text, activity_texts, rotation_interval_seconds FROM bot_presence_states WHERE bot_id = $1 LIMIT 1",
[botId], [botId],
); );
@@ -63,18 +117,39 @@ class PresenceStore {
} }
public async upsertByBotId(botId: string, state: PresenceState): Promise<void> { public async upsertByBotId(botId: string, state: PresenceState): Promise<void> {
const activityTexts = sanitizeActivityTexts(state.activity.texts);
const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
await this.pool.query( await this.pool.query(
` `
INSERT INTO bot_presence_states (bot_id, status, activity_type, activity_text, updated_at) INSERT INTO bot_presence_states (
VALUES ($1, $2, $3, $4, NOW()) bot_id,
status,
activity_type,
activity_text,
activity_texts,
rotation_interval_seconds,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (bot_id) ON CONFLICT (bot_id)
DO UPDATE SET DO UPDATE SET
status = EXCLUDED.status, status = EXCLUDED.status,
activity_type = EXCLUDED.activity_type, activity_type = EXCLUDED.activity_type,
activity_text = EXCLUDED.activity_text, activity_text = EXCLUDED.activity_text,
activity_texts = EXCLUDED.activity_texts,
rotation_interval_seconds = EXCLUDED.rotation_interval_seconds,
updated_at = NOW() updated_at = NOW()
`, `,
[botId, state.status, state.activity.type, sanitizeActivityText(state.activity.text)], [
botId,
state.status,
state.activity.type,
primaryText,
JSON.stringify(activityTexts),
rotationIntervalSeconds,
],
); );
} }
@@ -0,0 +1,89 @@
import type { Client } from "discord.js";
import { env } from "../config/env.js";
import { hasTemplateVariable, renderTemplate } from "../utils/templateVariables.js";
import { sanitizeActivityText } from "./presenceTypes.js";
export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000;
export const PRESENCE_VISIBLE_TEMPLATE_VARIABLES = [
"bot_name",
"bot_tag",
"bot_id",
"guild_count",
"member_count",
"channel_count",
"uptime",
"uptime_seconds",
"prefix",
] as const;
const PRESENCE_TEMPLATE_VARIABLE_ALIASES: Record<string, string> = {
bot: "bot_name",
guilds: "guild_count",
servers: "guild_count",
users: "member_count",
members: "member_count",
channels: "channel_count",
};
const PRESENCE_KNOWN_TEMPLATE_VARIABLES = new Set<string>([
...PRESENCE_VISIBLE_TEMPLATE_VARIABLES,
...Object.keys(PRESENCE_TEMPLATE_VARIABLE_ALIASES),
]);
const formatUptime = (totalSeconds: number): string => {
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0 || days > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0 || hours > 0 || days > 0) {
parts.push(`${minutes}m`);
}
parts.push(`${seconds}s`);
return parts.join(" ");
};
const buildPresenceTemplateValues = (client: Client): Record<string, string> => {
const guildCount = client.guilds.cache.size;
const memberCount = client.guilds.cache.reduce((total, guild) => total + (guild.memberCount ?? 0), 0);
const channelCount = client.channels.cache.size;
const uptimeSeconds = Math.max(0, Math.floor((client.uptime ?? process.uptime() * 1_000) / 1_000));
return {
bot_name: client.user?.username ?? "bot",
bot_tag: client.user?.tag ?? "bot#0000",
bot_id: client.user?.id ?? "unknown",
guild_count: String(guildCount),
member_count: String(memberCount),
channel_count: String(channelCount),
uptime: formatUptime(uptimeSeconds),
uptime_seconds: String(uptimeSeconds),
prefix: env.PREFIX,
};
};
export const renderPresenceTemplate = (client: Client, template: string): string => {
const sanitizedTemplate = sanitizeActivityText(template);
const rendered = renderTemplate(sanitizedTemplate, buildPresenceTemplateValues(client), {
aliases: PRESENCE_TEMPLATE_VARIABLE_ALIASES,
keepUnknown: true,
});
return sanitizeActivityText(rendered);
};
export const containsPresenceTemplateVariables = (template: string): boolean =>
hasTemplateVariable(template, PRESENCE_KNOWN_TEMPLATE_VARIABLES, PRESENCE_TEMPLATE_VARIABLE_ALIASES);
export const getPresenceTemplateHelpText = (): string =>
PRESENCE_VISIBLE_TEMPLATE_VARIABLES.map((name) => `{{${name}}}`).join(", ");
+68 -10
View File
@@ -11,21 +11,29 @@ export const PRESENCE_ACTIVITY_TYPES = [
export type PresenceStatusValue = (typeof PRESENCE_STATUSES)[number]; export type PresenceStatusValue = (typeof PRESENCE_STATUSES)[number];
export type PresenceActivityTypeValue = (typeof PRESENCE_ACTIVITY_TYPES)[number]; export type PresenceActivityTypeValue = (typeof PRESENCE_ACTIVITY_TYPES)[number];
export const DEFAULT_ACTIVITY_TEXT = "Ready to help";
export const DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS = 30;
export const MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS = 5;
export const MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS = 3_600;
export const MAX_ACTIVITY_ROTATION_TEXTS = 10;
export interface PresenceState { export interface PresenceState {
status: PresenceStatusValue; status: PresenceStatusValue;
activity: { activity: {
type: PresenceActivityTypeValue; type: PresenceActivityTypeValue;
text: string; text: string;
texts: string[];
rotationIntervalSeconds: number;
}; };
} }
export const DEFAULT_ACTIVITY_TEXT = "Ready to help";
export const createDefaultPresenceState = (): PresenceState => ({ export const createDefaultPresenceState = (): PresenceState => ({
status: "online", status: "online",
activity: { activity: {
type: "CUSTOM", type: "CUSTOM",
text: DEFAULT_ACTIVITY_TEXT, text: DEFAULT_ACTIVITY_TEXT,
texts: [DEFAULT_ACTIVITY_TEXT],
rotationIntervalSeconds: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}, },
}); });
@@ -44,6 +52,42 @@ export const sanitizeActivityText = (value: string): string => {
return trimmed.slice(0, 128); return trimmed.slice(0, 128);
}; };
export const sanitizeActivityTexts = (values: readonly string[]): string[] => {
const cleaned = values
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => sanitizeActivityText(value))
.slice(0, MAX_ACTIVITY_ROTATION_TEXTS);
if (cleaned.length === 0) {
return [DEFAULT_ACTIVITY_TEXT];
}
return cleaned;
};
export const sanitizePresenceRotationIntervalSeconds = (value: number): number => {
if (!Number.isFinite(value)) {
return DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
}
const normalized = Math.floor(value);
if (normalized < MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS) {
return MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS;
}
if (normalized > MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS) {
return MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS;
}
return normalized;
};
export const isPresenceRotationIntervalSecondsValue = (value: number): boolean =>
Number.isInteger(value)
&& value >= MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS
&& value <= MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS;
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value); Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -60,21 +104,35 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
} }
const activityType = activityValue.type; const activityType = activityValue.type;
const activityText = activityValue.text; if (typeof activityType !== "string" || !isPresenceActivityTypeValue(activityType)) {
if (
typeof activityType !== "string"
|| !isPresenceActivityTypeValue(activityType)
|| typeof activityText !== "string"
) {
return null; return null;
} }
const activityTextsValue = activityValue.texts;
const legacyActivityTextValue = activityValue.text;
const intervalValue = activityValue.rotationIntervalSeconds;
const activityTexts = Array.isArray(activityTextsValue)
? sanitizeActivityTexts(activityTextsValue.filter((entry): entry is string => typeof entry === "string"))
: typeof legacyActivityTextValue === "string"
? sanitizeActivityTexts([legacyActivityTextValue])
: null;
if (!activityTexts) {
return null;
}
const rotationIntervalSeconds = typeof intervalValue === "number"
? sanitizePresenceRotationIntervalSeconds(intervalValue)
: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
return { return {
status: statusValue, status: statusValue,
activity: { activity: {
type: activityType, type: activityType,
text: sanitizeActivityText(activityText), text: activityTexts[0] ?? DEFAULT_ACTIVITY_TEXT,
texts: activityTexts,
rotationIntervalSeconds,
}, },
}; };
}; };
+91
View File
@@ -0,0 +1,91 @@
export interface TemplateRenderOptions {
aliases?: Record<string, string>;
keepUnknown?: boolean;
}
const createTemplateTokenRegex = (): RegExp => /\{\{([a-zA-Z0-9_]+)\}\}/g;
const normalizeVariableName = (rawName: string): string => rawName.toLowerCase();
const normalizeAliases = (aliases: Record<string, string> = {}): Record<string, string> =>
Object.entries(aliases).reduce<Record<string, string>>((acc, [from, to]) => {
acc[normalizeVariableName(from)] = normalizeVariableName(to);
return acc;
}, {});
const resolveVariableName = (rawName: string, aliases: Record<string, string>): string => {
const normalized = normalizeVariableName(rawName);
return aliases[normalized] ?? normalized;
};
export const extractTemplateVariables = (template: string, aliases: Record<string, string> = {}): string[] => {
const normalizedAliases = normalizeAliases(aliases);
const found = new Set<string>();
for (const match of template.matchAll(createTemplateTokenRegex())) {
const rawName = match[1];
if (!rawName) {
continue;
}
found.add(resolveVariableName(rawName, normalizedAliases));
}
return [...found];
};
export const hasTemplateVariable = (
template: string,
knownVariableNames: Iterable<string>,
aliases: Record<string, string> = {},
): boolean => {
const normalizedAliases = normalizeAliases(aliases);
const normalizedKnownVariables = new Set(
[...knownVariableNames].map((name) => normalizeVariableName(name)),
);
for (const match of template.matchAll(createTemplateTokenRegex())) {
const rawName = match[1];
if (!rawName) {
continue;
}
const resolvedName = resolveVariableName(rawName, normalizedAliases);
if (normalizedKnownVariables.has(resolvedName)) {
return true;
}
}
return false;
};
export const renderTemplate = (
template: string,
values: Record<string, string>,
options: TemplateRenderOptions = {},
): string => {
const normalizedAliases = normalizeAliases(options.aliases);
const keepUnknown = options.keepUnknown ?? true;
const normalizedValues = Object.entries(values).reduce<Record<string, string>>((acc, [key, value]) => {
acc[normalizeVariableName(key)] = value;
return acc;
}, {});
return template.replace(
createTemplateTokenRegex(),
(match: string, doubleBracedName?: string) => {
const rawName = doubleBracedName;
if (!rawName) {
return match;
}
const resolvedName = resolveVariableName(rawName, normalizedAliases);
const resolvedValue = normalizedValues[resolvedName];
if (resolvedValue === undefined) {
return keepUnknown ? match : "";
}
return resolvedValue;
},
);
};