mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
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:
+13
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+13
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+13
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<PresenceActivityTypeValue, ActivityType> = {
|
||||
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<PresenceState> => {
|
||||
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<StringSelectMenuBuilder>().addComponents(activitySelect),
|
||||
);
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton),
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
|
||||
);
|
||||
|
||||
return container;
|
||||
@@ -233,12 +337,15 @@ const resolveReplyMessage = (value: unknown): Message | null => {
|
||||
|
||||
const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise<void> => {
|
||||
applyPresenceState(ctx.client, state);
|
||||
syncDynamicPresenceTimers(ctx.client, state);
|
||||
await savePresenceState(ctx.client, state);
|
||||
};
|
||||
|
||||
export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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.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<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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await this.pool.query(tableSql);
|
||||
await this.pool.query(migrationSql);
|
||||
await this.pool.query(backfillSql);
|
||||
}
|
||||
|
||||
public async getByBotId(botId: string): Promise<PresenceState> {
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -63,18 +117,39 @@ class PresenceStore {
|
||||
}
|
||||
|
||||
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(
|
||||
`
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(", ");
|
||||
@@ -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<string, unknown> =>
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user