From e2bcb92e155e8d046f2fca5a347c383e6781ab47 Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sun, 12 Apr 2026 11:38:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ajouter=20la=20gestion=20des=20textes?= =?UTF-8?q?=20d'activit=C3=A9=20multiples=20et=20de=20l'intervalle=20de=20?= =?UTF-8?q?rotation=20dans=20la=20commande=20de=20pr=C3=A9sence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.json | 19 +- locales/es.json | 19 +- locales/fr.json | 19 +- src/commands/utility/presence.ts | 269 +++++++++++++++--- src/framework/presence/presenceStore.ts | 85 +++++- .../presence/presenceTemplateVariables.ts | 89 ++++++ src/framework/presence/presenceTypes.ts | 78 ++++- src/framework/utils/templateVariables.ts | 91 ++++++ 8 files changed, 595 insertions(+), 74 deletions(-) create mode 100644 src/framework/presence/presenceTemplateVariables.ts create mode 100644 src/framework/utils/templateVariables.ts diff --git a/locales/en.json b/locales/en.json index a6b1ad7..6241eb0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -69,12 +69,13 @@ }, "responses": { "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}}.", "activityUpdated": "Activity type updated to {{activityType}}.", "textUpdated": "Presence text updated to: {{activityText}}", "notOwner": "Only the command author can use this panel.", "invalidSelection": "Invalid selection received.", + "invalidInterval": "Invalid interval. Use an integer between {{minSeconds}} and {{maxSeconds}} seconds.", "modalTimeout": "Text update timed out. Click the button again." }, "ui": { @@ -134,7 +135,7 @@ }, "embed": { "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", "fields": { "status": "Status", @@ -142,11 +143,17 @@ "text": "Text" } }, - "textButton": "📝 Edit text", + "textButton": "📝 Edit texts", + "intervalButton": "⏱️ Interval", "modal": { - "title": "Presence text", - "label": "Activity text", - "placeholder": "Example: 🚀 In maintenance" + "title": "Presence texts", + "label": "One text per line (use {{var}})", + "placeholder": "Maintenance\n{{guild_count}} servers | {{prefix}}help" + }, + "intervalModal": { + "title": "Rotation interval", + "label": "Seconds between each text", + "placeholder": "Example: 30" } } }, diff --git a/locales/es.json b/locales/es.json index cba826b..d4412b7 100644 --- a/locales/es.json +++ b/locales/es.json @@ -69,12 +69,13 @@ }, "responses": { "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}}.", "activityUpdated": "Tipo de actividad actualizado a {{activityType}}.", "textUpdated": "Texto de presencia actualizado: {{activityText}}", "notOwner": "Solo el autor del comando puede usar este panel.", "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." }, "ui": { @@ -134,7 +135,7 @@ }, "embed": { "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", "fields": { "status": "Estado", @@ -142,11 +143,17 @@ "text": "Texto" } }, - "textButton": "📝 Editar texto", + "textButton": "📝 Editar textos", + "intervalButton": "⏱️ Intervalo", "modal": { - "title": "Texto de presencia", - "label": "Texto de actividad", - "placeholder": "Ejemplo: 🚀 En mantenimiento" + "title": "Textos de presencia", + "label": "Un texto por linea (usa {{var}})", + "placeholder": "Mantenimiento\n{{guild_count}} servidores | {{prefix}}ayuda" + }, + "intervalModal": { + "title": "Intervalo de rotacion", + "label": "Segundos entre cada texto", + "placeholder": "Ejemplo: 30" } } }, diff --git a/locales/fr.json b/locales/fr.json index 5a12373..a49c4d3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -69,12 +69,13 @@ }, "responses": { "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}}.", "activityUpdated": "Type d activite mis a jour: {{activityType}}.", "textUpdated": "Texte de presence mis a jour: {{activityText}}", "notOwner": "Seul l auteur de la commande peut utiliser ce panneau.", "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." }, "ui": { @@ -134,7 +135,7 @@ }, "embed": { "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", "fields": { "status": "Statut", @@ -142,11 +143,17 @@ "text": "Texte" } }, - "textButton": "📝 Modifier le texte", + "textButton": "📝 Modifier les textes", + "intervalButton": "⏱️ Intervalle", "modal": { - "title": "Texte de presence", - "label": "Texte d activite", - "placeholder": "Exemple: 🚀 En maintenance" + "title": "Textes de presence", + "label": "Un texte par ligne (utilise {{var}})", + "placeholder": "Maintenance\n{{guild_count}} serveurs | {{prefix}}aide" + }, + "intervalModal": { + "title": "Intervalle de rotation", + "label": "Secondes entre chaque texte", + "placeholder": "Exemple: 30" } } }, diff --git a/src/commands/utility/presence.ts b/src/commands/utility/presence.ts index 2176816..cd89ecf 100644 --- a/src/commands/utility/presence.ts +++ b/src/commands/utility/presence.ts @@ -16,13 +16,24 @@ import { import { defineCommand } from "../../framework/commands/defineCommand.js"; import { env } from "../../framework/config/env.js"; import { getPresenceStore } from "../../framework/presence/presenceStore.js"; +import { + PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS, + containsPresenceTemplateVariables, + getPresenceTemplateHelpText, + renderPresenceTemplate, +} from "../../framework/presence/presenceTemplateVariables.js"; import { PRESENCE_ACTIVITY_TYPES, PRESENCE_STATUSES, + MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, + MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, createDefaultPresenceState, isPresenceActivityTypeValue, + isPresenceRotationIntervalSecondsValue, isPresenceStatusValue, sanitizeActivityText, + sanitizeActivityTexts, + sanitizePresenceRotationIntervalSeconds, type PresenceActivityTypeValue, type PresenceState, type PresenceStatusValue, @@ -33,10 +44,17 @@ interface PresenceCustomIds { statusSelect: string; activitySelect: string; textButton: string; + intervalButton: string; textModal: 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 = { PLAYING: ActivityType.Playing, STREAMING: ActivityType.Streaming, @@ -53,6 +71,66 @@ const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatu 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 => { const botId = resolveBotId(client); if (!botId) { @@ -76,8 +154,11 @@ const applyPresenceState = (client: Client, state: PresenceState): void => { return; } + normalizePresenceActivityState(state); + 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") { client.user.setPresence({ status, @@ -123,8 +204,11 @@ const createCustomIds = (): PresenceCustomIds => { statusSelect: `presence:status:${nonce}`, activitySelect: `presence:activity:${nonce}`, textButton: `presence:text:${nonce}`, + intervalButton: `presence:interval:${nonce}`, textModal: `presence:modal:${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`); 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", { status: statusLabel(ctx, state.status), 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}`; @@ -186,6 +284,12 @@ const buildContainer = ( .setStyle(ButtonStyle.Secondary) .setDisabled(disabled); + const intervalButton = new ButtonBuilder() + .setCustomId(customIds.intervalButton) + .setLabel(ctx.ct("ui.intervalButton")) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled); + const container = new ContainerBuilder(); container.addTextDisplayComponents( @@ -199,7 +303,7 @@ const buildContainer = ( new ActionRowBuilder().addComponents(activitySelect), ); container.addActionRowComponents( - new ActionRowBuilder().addComponents(textButton), + new ActionRowBuilder().addComponents(textButton, intervalButton), ); return container; @@ -233,12 +337,15 @@ const resolveReplyMessage = (value: unknown): Message | null => { const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise => { applyPresenceState(ctx.client, state); + syncDynamicPresenceTimers(ctx.client, state); await savePresenceState(ctx.client, state); }; export const restorePresenceFromStorage = async (client: Client): Promise => { const state = await loadPresenceState(client); + activePresenceTextIndex = 0; applyPresenceState(client, state); + syncDynamicPresenceTimers(client, state); }; export const presenceCommand = defineCommand({ @@ -255,6 +362,7 @@ export const presenceCommand = defineCommand({ execute: async (ctx) => { const state = await loadPresenceState(ctx.client); applyPresenceState(ctx.client, state); + syncDynamicPresenceTimers(ctx.client, state); const customIds = createCustomIds(); @@ -320,54 +428,133 @@ export const presenceCommand = defineCommand({ } if (interaction.isButton()) { - if (interaction.customId !== customIds.textButton) { - await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + if (interaction.customId === customIds.textButton) { + normalizePresenceActivityState(state); + + const modal = new ModalBuilder() + .setCustomId(customIds.textModal) + .setTitle(ctx.ct("ui.modal.title")) + .addComponents( + new ActionRowBuilder().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; } - const modal = new ModalBuilder() - .setCustomId(customIds.textModal) - .setTitle(ctx.ct("ui.modal.title")) - .addComponents( - new ActionRowBuilder().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)), - ), - ); + if (interaction.customId === customIds.intervalButton) { + normalizePresenceActivityState(state); - await interaction.showModal(modal); + const modal = new ModalBuilder() + .setCustomId(customIds.intervalModal) + .setTitle(ctx.ct("ui.intervalModal.title")) + .addComponents( + new ActionRowBuilder().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 { - const submitted = await interaction.awaitModalSubmit({ - time: 120_000, - filter: (modalInteraction) => - modalInteraction.customId === customIds.textModal - && modalInteraction.user.id === ownerId, - }); + await interaction.showModal(modal); - const nextText = sanitizeActivityText(submitted.fields.getTextInputValue(customIds.textInput)); - state.activity.text = nextText; - await persistAndApplyPresence(ctx, state); + try { + const submitted = await interaction.awaitModalSubmit({ + 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({ - flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds)], - }); - } catch { - await interaction.followUp({ - content: ctx.ct("responses.modalTimeout"), - ephemeral: true, - }).catch(() => undefined); + const nextSeconds = Number(rawSeconds); + if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) { + await submitted.reply({ + content: ctx.ct("responses.invalidInterval", { + minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, + maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, + }), + ephemeral: true, + }); + 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; } diff --git a/src/framework/presence/presenceStore.ts b/src/framework/presence/presenceStore.ts index 9f113e1..e9e7752 100644 --- a/src/framework/presence/presenceStore.ts +++ b/src/framework/presence/presenceStore.ts @@ -2,10 +2,13 @@ import { Pool } from "pg"; import { env } from "../config/env.js"; import { + DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS, createDefaultPresenceState, isPresenceActivityTypeValue, isPresenceStatusValue, sanitizeActivityText, + sanitizeActivityTexts, + sanitizePresenceRotationIntervalSeconds, type PresenceActivityTypeValue, type PresenceState, type PresenceStatusValue, @@ -15,6 +18,8 @@ interface PresenceRow { status: string; activity_type: string; activity_text: string; + activity_texts: string | null; + rotation_interval_seconds: number | null; } const tableSql = ` @@ -23,20 +28,67 @@ CREATE TABLE IF NOT EXISTS bot_presence_states ( status TEXT NOT NULL, activity_type TEXT NOT NULL, activity_text TEXT NOT NULL, + activity_texts TEXT, + rotation_interval_seconds INTEGER, 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 => { if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) { return null; } + const texts = parseStoredTexts(row.activity_texts, row.activity_text); + const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds( + row.rotation_interval_seconds ?? DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS, + ); + return { status: row.status as PresenceStatusValue, activity: { 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 { await this.pool.query(tableSql); + await this.pool.query(migrationSql); + await this.pool.query(backfillSql); } public async getByBotId(botId: string): Promise { const result = await this.pool.query( - "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], ); @@ -63,18 +117,39 @@ class PresenceStore { } public async upsertByBotId(botId: string, state: PresenceState): Promise { + const activityTexts = sanitizeActivityTexts(state.activity.texts); + const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text); + const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds); + await this.pool.query( ` - INSERT INTO bot_presence_states (bot_id, status, activity_type, activity_text, updated_at) - VALUES ($1, $2, $3, $4, NOW()) + INSERT INTO bot_presence_states ( + 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) DO UPDATE SET status = EXCLUDED.status, activity_type = EXCLUDED.activity_type, activity_text = EXCLUDED.activity_text, + activity_texts = EXCLUDED.activity_texts, + rotation_interval_seconds = EXCLUDED.rotation_interval_seconds, updated_at = NOW() `, - [botId, state.status, state.activity.type, sanitizeActivityText(state.activity.text)], + [ + botId, + state.status, + state.activity.type, + primaryText, + JSON.stringify(activityTexts), + rotationIntervalSeconds, + ], ); } diff --git a/src/framework/presence/presenceTemplateVariables.ts b/src/framework/presence/presenceTemplateVariables.ts new file mode 100644 index 0000000..daf8c5f --- /dev/null +++ b/src/framework/presence/presenceTemplateVariables.ts @@ -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 = { + 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([ + ...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 => { + 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(", "); diff --git a/src/framework/presence/presenceTypes.ts b/src/framework/presence/presenceTypes.ts index cb6208c..a1f2e33 100644 --- a/src/framework/presence/presenceTypes.ts +++ b/src/framework/presence/presenceTypes.ts @@ -11,21 +11,29 @@ export const PRESENCE_ACTIVITY_TYPES = [ export type PresenceStatusValue = (typeof PRESENCE_STATUSES)[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 { status: PresenceStatusValue; activity: { type: PresenceActivityTypeValue; text: string; + texts: string[]; + rotationIntervalSeconds: number; }; } -export const DEFAULT_ACTIVITY_TEXT = "Ready to help"; - export const createDefaultPresenceState = (): PresenceState => ({ status: "online", activity: { type: "CUSTOM", 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); }; +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 => Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -60,21 +104,35 @@ export const parsePresenceState = (value: unknown): PresenceState | null => { } const activityType = activityValue.type; - const activityText = activityValue.text; - - if ( - typeof activityType !== "string" - || !isPresenceActivityTypeValue(activityType) - || typeof activityText !== "string" - ) { + if (typeof activityType !== "string" || !isPresenceActivityTypeValue(activityType)) { 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 { status: statusValue, activity: { type: activityType, - text: sanitizeActivityText(activityText), + text: activityTexts[0] ?? DEFAULT_ACTIVITY_TEXT, + texts: activityTexts, + rotationIntervalSeconds, }, }; }; diff --git a/src/framework/utils/templateVariables.ts b/src/framework/utils/templateVariables.ts new file mode 100644 index 0000000..c7969d1 --- /dev/null +++ b/src/framework/utils/templateVariables.ts @@ -0,0 +1,91 @@ +export interface TemplateRenderOptions { + aliases?: Record; + keepUnknown?: boolean; +} + +const createTemplateTokenRegex = (): RegExp => /\{\{([a-zA-Z0-9_]+)\}\}/g; + +const normalizeVariableName = (rawName: string): string => rawName.toLowerCase(); + +const normalizeAliases = (aliases: Record = {}): Record => + Object.entries(aliases).reduce>((acc, [from, to]) => { + acc[normalizeVariableName(from)] = normalizeVariableName(to); + return acc; + }, {}); + +const resolveVariableName = (rawName: string, aliases: Record): string => { + const normalized = normalizeVariableName(rawName); + return aliases[normalized] ?? normalized; +}; + +export const extractTemplateVariables = (template: string, aliases: Record = {}): string[] => { + const normalizedAliases = normalizeAliases(aliases); + const found = new Set(); + + 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, + aliases: Record = {}, +): 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, + options: TemplateRenderOptions = {}, +): string => { + const normalizedAliases = normalizeAliases(options.aliases); + const keepUnknown = options.keepUnknown ?? true; + const normalizedValues = Object.entries(values).reduce>((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; + }, + ); +};