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": {
|
"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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(", ");
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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