oft-agt 0.5.1 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,85 +1,85 @@
1
- // Public diagnostics only. Never interpolate Discord error.message, request bodies or URLs.
2
- const ERRORS = {
3
- MESSAGE_SEND_FAILED: ['Envoi impossible', 'Le message n’a pas pu être envoyé. Vérifiez le salon, les accès et le contenu.'],
4
- MESSAGE_EDIT_FAILED: ['Modification impossible', 'Le message n’a pas pu être modifié. Vérifiez qu’il existe et appartient au bot.'],
5
- MESSAGE_DELETE_FAILED: ['Suppression impossible', 'Le message n’a pas pu être supprimé. Vérifiez son existence et les permissions du bot.'],
6
- CHANNEL_UNAVAILABLE: ['Salon inaccessible', 'Le salon est introuvable ou ne permet pas d’envoyer de messages.'],
7
- USER_UNAVAILABLE: ['Destinataire inaccessible', 'L’utilisateur est introuvable ou ne peut pas recevoir de message privé.'],
8
- MESSAGE_NOT_FOUND: ['Message introuvable', 'Le message cible n’existe plus ou le bloc qui devait l’envoyer n’a pas encore été exécuté.'],
9
- MESSAGE_EMPTY: ['Message vide', 'Ajoutez du texte ou au moins un embed renseigné.'],
10
- MESSAGE_TOO_LONG: ['Message trop long', 'Le texte du message dépasse 2 000 caractères. Réduisez-le ou utilisez un embed.'],
11
- TOO_MANY_EMBEDS: ['Trop d’embeds', 'Un message du Builder accepte au maximum 5 embeds.'],
12
- EMBED_TITLE_TOO_LONG: ['Titre d’embed trop long', 'Le titre d’un embed dépasse 256 caractères.'],
13
- EMBED_DESCRIPTION_TOO_LONG: ['Description d’embed trop longue', 'La description d’un embed dépasse 4 096 caractères.'],
14
- EMBED_AUTHOR_TOO_LONG: ['Auteur d’embed trop long', 'Le nom de l’auteur d’un embed dépasse 256 caractères.'],
15
- EMBED_FOOTER_TOO_LONG: ['Pied de page trop long', 'Le pied de page d’un embed dépasse 2 048 caractères.'],
16
- EMBED_FIELDS_INVALID: ['Champs d’embed invalides', 'Utilisez au maximum 25 champs par embed, avec un nom de 1 à 256 caractères et une valeur de 1 à 1 024 caractères.'],
17
- EMBEDS_TOO_LARGE: ['Embeds trop volumineux', 'Le texte de l’ensemble des embeds dépasse 6 000 caractères.'],
18
- MESSAGE_INVALID: ['Message invalide', 'Vérifiez les champs obligatoires, la couleur, les URL des images et les identifiants de destination.'],
19
- INVALID_WAIT: ['Délai invalide', 'Le délai doit être un nombre compris entre 0 et 300 secondes.'],
20
- TIMEOUT: ['Délai dépassé', 'L’action a dépassé son délai maximal. Vérifiez la connexion et réduisez les attentes.'],
21
- NETWORK_ERROR: ['Connexion interrompue', 'La connexion à Discord a échoué. Vérifiez le réseau du serveur puis réessayez.'],
22
- RATE_LIMITED: ['Trop de requêtes', 'Discord limite temporairement les actions du bot. Attendez avant de réessayer.'],
23
- DISCORD_UNAVAILABLE: ['Discord indisponible', 'Discord rencontre une erreur temporaire. Réessayez dans quelques instants.'],
24
- EXECUTION_FAILED: ['Erreur d’exécution', 'L’action n’a pas pu être effectuée. Vérifiez sa configuration et les accès du bot.'],
25
- };
26
- const DISCORD = {
27
- 10003: ['Salon introuvable', 'Le salon a été supprimé ou son identifiant est incorrect.'],
28
- 10008: ['Message introuvable', 'Le message a été supprimé ou son identifiant est incorrect.'],
29
- 10013: ['Utilisateur introuvable', 'Vérifiez l’identifiant du destinataire.'],
30
- 10062: ['Commande expirée', 'Le délai de réponse de la commande est dépassé.'],
31
- 40005: ['Message trop volumineux', 'Réduisez la taille du message ou de ses pièces jointes.'],
32
- 40058: ['Salon forum incompatible', 'Envoyez le message dans un salon textuel ou dans un fil existant du forum.'],
33
- 40060: ['Commande déjà acquittée', 'Cette commande a déjà reçu une réponse initiale. Utilisez une modification ou une réponse supplémentaire.'],
34
- 40094: ['Trop de réponses', 'La limite de réponses supplémentaires de cette commande est atteinte.'],
35
- 50001: ['Accès refusé', 'Le bot ne peut pas accéder à cette ressource.'],
36
- 50005: ['Modification interdite', 'Un bot ne peut modifier que ses propres messages.'],
37
- 50006: ERRORS.MESSAGE_EMPTY,
38
- 50007: ['Message privé impossible', 'Le destinataire ne peut pas recevoir de messages privés.'],
39
- 50008: ['Salon incompatible', 'Ce salon ne permet pas l’envoi de messages textuels.'],
40
- 50013: ['Permissions insuffisantes', 'Vérifiez les permissions Voir le salon, Envoyer des messages, Intégrer des liens et, pour une suppression, Gérer les messages.'],
41
- 50014: ['Authentification refusée', 'Le token du bot est invalide ou révoqué. Remplacez-le depuis Paramètres.'],
42
- 50021: ['Message système protégé', 'Discord interdit cette action sur un message système.'],
43
- 50024: ['Action incompatible', 'Cette action n’est pas disponible dans ce type de salon.'],
44
- 50034: ['Messages trop anciens', 'Discord refuse la suppression groupée de messages de plus de 14 jours.'],
45
- 50035: ['Contenu refusé par Discord', 'Vérifiez la taille du message, des embeds, des champs et les formats des paramètres.'],
46
- 50045: ['Fichier trop volumineux', 'La pièce jointe dépasse la taille autorisée par Discord.'],
47
- 50083: ['Fil archivé', 'Désarchivez le fil avant d’y envoyer ou d’y modifier un message.'],
48
- 200000: ['Message bloqué par AutoMod', 'Le contenu du message a été refusé par la modération automatique du serveur.'],
49
- 240000: ['Lien bloqué', 'Discord a bloqué un lien jugé dangereux dans le message.'],
50
- };
51
- export function runtimeError(code, operation, cause) {
52
- return Object.assign(new Error(code), { code, operation, cause });
53
- }
54
- export function errorVariables(error, action, blockId) {
55
- let current = error, diagnostic, code;
56
- for (let depth = 0; current && depth < 5; depth++, current = current.cause) {
57
- if (Object.hasOwn(DISCORD, current.code)) { diagnostic = DISCORD[current.code]; code = 'DISCORD_' + current.code; break; }
58
- if (Object.hasOwn(ERRORS, current.code)) { diagnostic = ERRORS[current.code]; code = current.code; break; }
59
- if (current.name === 'AbortError' || current.name === 'TimeoutError') code = 'TIMEOUT';
60
- else if (['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'].includes(current.code)) code = current.code === 'ETIMEDOUT' ? 'TIMEOUT' : 'NETWORK_ERROR';
61
- else if (current.status === 429) code = 'RATE_LIMITED';
62
- else if (Number.isInteger(current.status) && current.status >= 500 && current.status <= 599) code = 'DISCORD_UNAVAILABLE';
63
- if (code) { diagnostic = ERRORS[code]; break; }
64
- }
65
- const fallback = { sendMessage: 'MESSAGE_SEND_FAILED', editMessage: 'MESSAGE_EDIT_FAILED', deleteMessage: 'MESSAGE_DELETE_FAILED' };
66
- const requested = error?.operation || action;
67
- code ||= Object.hasOwn(fallback, requested) ? fallback[requested] : 'EXECUTION_FAILED'; diagnostic ||= ERRORS[code];
68
- const operations = { sendMessage: 'L’envoi du message a échoué.', editMessage: 'La modification du message a échoué.', deleteMessage: 'La suppression du message a échoué.', wait: 'L’attente a été interrompue.' };
69
- const operation = Object.hasOwn(operations, error?.operation) ? error.operation : action;
70
- const safeBlockId = typeof blockId === 'string' && /^[\w-]{1,64}$/.test(blockId) ? blockId : '';
71
- return { error_name: diagnostic[0], error_desc: operations[operation] || 'La commande n’a pas pu être exécutée.', error_reason: diagnostic[1], error_code: code, error_block_id: safeBlockId };
72
- }
73
-
74
- export function validateMessage(config) {
75
- if ((config.text || '').length > 2000) throw runtimeError('MESSAGE_TOO_LONG');
76
- if ((config.embeds || []).length > 5) throw runtimeError('TOO_MANY_EMBEDS');
77
- let total = 0;
78
- for (const embed of config.embeds || []) {
79
- for (const [value, limit, code] of [[embed.title, 256, 'EMBED_TITLE_TOO_LONG'], [embed.description, 4096, 'EMBED_DESCRIPTION_TOO_LONG'], [embed.author?.name, 256, 'EMBED_AUTHOR_TOO_LONG'], [embed.footer?.text, 2048, 'EMBED_FOOTER_TOO_LONG']]) { if ((value || '').length > limit) throw runtimeError(code); total += (value || '').length; }
80
- if ((embed.fields || []).length > 25 || (embed.fields || []).some(field => !field.name?.trim() || !field.value?.trim() || field.name.length > 256 || field.value.length > 1024)) throw runtimeError('EMBED_FIELDS_INVALID');
81
- total += (embed.fields || []).reduce((sum, field) => sum + field.name.length + field.value.length, 0);
82
- }
83
- if (total > 6000) throw runtimeError('EMBEDS_TOO_LARGE');
84
- if (!config.text?.trim() && !config.embeds?.length) throw runtimeError('MESSAGE_EMPTY');
85
- }
1
+ // Public diagnostics only. Never interpolate Discord error.message, request bodies or URLs.
2
+ const ERRORS = {
3
+ MESSAGE_SEND_FAILED: ['Envoi impossible', 'Le message n’a pas pu être envoyé. Vérifiez le salon, les accès et le contenu.'],
4
+ MESSAGE_EDIT_FAILED: ['Modification impossible', 'Le message n’a pas pu être modifié. Vérifiez qu’il existe et appartient au bot.'],
5
+ MESSAGE_DELETE_FAILED: ['Suppression impossible', 'Le message n’a pas pu être supprimé. Vérifiez son existence et les permissions du bot.'],
6
+ CHANNEL_UNAVAILABLE: ['Salon inaccessible', 'Le salon est introuvable ou ne permet pas d’envoyer de messages.'],
7
+ USER_UNAVAILABLE: ['Destinataire inaccessible', 'L’utilisateur est introuvable ou ne peut pas recevoir de message privé.'],
8
+ MESSAGE_NOT_FOUND: ['Message introuvable', 'Le message cible n’existe plus ou le bloc qui devait l’envoyer n’a pas encore été exécuté.'],
9
+ MESSAGE_EMPTY: ['Message vide', 'Ajoutez du texte ou au moins un embed renseigné.'],
10
+ MESSAGE_TOO_LONG: ['Message trop long', 'Le texte du message dépasse 2 000 caractères. Réduisez-le ou utilisez un embed.'],
11
+ TOO_MANY_EMBEDS: ['Trop d’embeds', 'Un message du Builder accepte au maximum 5 embeds.'],
12
+ EMBED_TITLE_TOO_LONG: ['Titre d’embed trop long', 'Le titre d’un embed dépasse 256 caractères.'],
13
+ EMBED_DESCRIPTION_TOO_LONG: ['Description d’embed trop longue', 'La description d’un embed dépasse 4 096 caractères.'],
14
+ EMBED_AUTHOR_TOO_LONG: ['Auteur d’embed trop long', 'Le nom de l’auteur d’un embed dépasse 256 caractères.'],
15
+ EMBED_FOOTER_TOO_LONG: ['Pied de page trop long', 'Le pied de page d’un embed dépasse 2 048 caractères.'],
16
+ EMBED_FIELDS_INVALID: ['Champs d’embed invalides', 'Utilisez au maximum 25 champs par embed, avec un nom de 1 à 256 caractères et une valeur de 1 à 1 024 caractères.'],
17
+ EMBEDS_TOO_LARGE: ['Embeds trop volumineux', 'Le texte de l’ensemble des embeds dépasse 6 000 caractères.'],
18
+ MESSAGE_INVALID: ['Message invalide', 'Vérifiez les champs obligatoires, la couleur, les URL des images et les identifiants de destination.'],
19
+ INVALID_WAIT: ['Délai invalide', 'Le délai doit être un nombre compris entre 0 et 300 secondes.'],
20
+ TIMEOUT: ['Délai dépassé', 'L’action a dépassé son délai maximal. Vérifiez la connexion et réduisez les attentes.'],
21
+ NETWORK_ERROR: ['Connexion interrompue', 'La connexion à Discord a échoué. Vérifiez le réseau du serveur puis réessayez.'],
22
+ RATE_LIMITED: ['Trop de requêtes', 'Discord limite temporairement les actions du bot. Attendez avant de réessayer.'],
23
+ DISCORD_UNAVAILABLE: ['Discord indisponible', 'Discord rencontre une erreur temporaire. Réessayez dans quelques instants.'],
24
+ EXECUTION_FAILED: ['Erreur d’exécution', 'L’action n’a pas pu être effectuée. Vérifiez sa configuration et les accès du bot.'],
25
+ };
26
+ const DISCORD = {
27
+ 10003: ['Salon introuvable', 'Le salon a été supprimé ou son identifiant est incorrect.'],
28
+ 10008: ['Message introuvable', 'Le message a été supprimé ou son identifiant est incorrect.'],
29
+ 10013: ['Utilisateur introuvable', 'Vérifiez l’identifiant du destinataire.'],
30
+ 10062: ['Commande expirée', 'Le délai de réponse de la commande est dépassé.'],
31
+ 40005: ['Message trop volumineux', 'Réduisez la taille du message ou de ses pièces jointes.'],
32
+ 40058: ['Salon forum incompatible', 'Envoyez le message dans un salon textuel ou dans un fil existant du forum.'],
33
+ 40060: ['Commande déjà acquittée', 'Cette commande a déjà reçu une réponse initiale. Utilisez une modification ou une réponse supplémentaire.'],
34
+ 40094: ['Trop de réponses', 'La limite de réponses supplémentaires de cette commande est atteinte.'],
35
+ 50001: ['Accès refusé', 'Le bot ne peut pas accéder à cette ressource.'],
36
+ 50005: ['Modification interdite', 'Un bot ne peut modifier que ses propres messages.'],
37
+ 50006: ERRORS.MESSAGE_EMPTY,
38
+ 50007: ['Message privé impossible', 'Le destinataire ne peut pas recevoir de messages privés.'],
39
+ 50008: ['Salon incompatible', 'Ce salon ne permet pas l’envoi de messages textuels.'],
40
+ 50013: ['Permissions insuffisantes', 'Vérifiez les permissions Voir le salon, Envoyer des messages, Intégrer des liens et, pour une suppression, Gérer les messages.'],
41
+ 50014: ['Authentification refusée', 'Le token du bot est invalide ou révoqué. Remplacez-le depuis Paramètres.'],
42
+ 50021: ['Message système protégé', 'Discord interdit cette action sur un message système.'],
43
+ 50024: ['Action incompatible', 'Cette action n’est pas disponible dans ce type de salon.'],
44
+ 50034: ['Messages trop anciens', 'Discord refuse la suppression groupée de messages de plus de 14 jours.'],
45
+ 50035: ['Contenu refusé par Discord', 'Vérifiez la taille du message, des embeds, des champs et les formats des paramètres.'],
46
+ 50045: ['Fichier trop volumineux', 'La pièce jointe dépasse la taille autorisée par Discord.'],
47
+ 50083: ['Fil archivé', 'Désarchivez le fil avant d’y envoyer ou d’y modifier un message.'],
48
+ 200000: ['Message bloqué par AutoMod', 'Le contenu du message a été refusé par la modération automatique du serveur.'],
49
+ 240000: ['Lien bloqué', 'Discord a bloqué un lien jugé dangereux dans le message.'],
50
+ };
51
+ export function runtimeError(code, operation, cause) {
52
+ return Object.assign(new Error(code), { code, operation, cause });
53
+ }
54
+ export function errorVariables(error, action, blockId) {
55
+ let current = error, diagnostic, code;
56
+ for (let depth = 0; current && depth < 5; depth++, current = current.cause) {
57
+ if (Object.hasOwn(DISCORD, current.code)) { diagnostic = DISCORD[current.code]; code = 'DISCORD_' + current.code; break; }
58
+ if (Object.hasOwn(ERRORS, current.code)) { diagnostic = ERRORS[current.code]; code = current.code; break; }
59
+ if (current.name === 'AbortError' || current.name === 'TimeoutError') code = 'TIMEOUT';
60
+ else if (['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'].includes(current.code)) code = current.code === 'ETIMEDOUT' ? 'TIMEOUT' : 'NETWORK_ERROR';
61
+ else if (current.status === 429) code = 'RATE_LIMITED';
62
+ else if (Number.isInteger(current.status) && current.status >= 500 && current.status <= 599) code = 'DISCORD_UNAVAILABLE';
63
+ if (code) { diagnostic = ERRORS[code]; break; }
64
+ }
65
+ const fallback = { sendMessage: 'MESSAGE_SEND_FAILED', editMessage: 'MESSAGE_EDIT_FAILED', deleteMessage: 'MESSAGE_DELETE_FAILED' };
66
+ const requested = error?.operation || action;
67
+ code ||= Object.hasOwn(fallback, requested) ? fallback[requested] : 'EXECUTION_FAILED'; diagnostic ||= ERRORS[code];
68
+ const operations = { sendMessage: 'L’envoi du message a échoué.', editMessage: 'La modification du message a échoué.', deleteMessage: 'La suppression du message a échoué.', wait: 'L’attente a été interrompue.' };
69
+ const operation = Object.hasOwn(operations, error?.operation) ? error.operation : action;
70
+ const safeBlockId = typeof blockId === 'string' && /^[\w-]{1,64}$/.test(blockId) ? blockId : '';
71
+ return { error_name: diagnostic[0], error_desc: operations[operation] || 'La commande n’a pas pu être exécutée.', error_reason: diagnostic[1], error_code: code, error_block_id: safeBlockId };
72
+ }
73
+
74
+ export function validateMessage(config) {
75
+ if ((config.text || '').length > 2000) throw runtimeError('MESSAGE_TOO_LONG');
76
+ if ((config.embeds || []).length > 5) throw runtimeError('TOO_MANY_EMBEDS');
77
+ let total = 0;
78
+ for (const embed of config.embeds || []) {
79
+ for (const [value, limit, code] of [[embed.title, 256, 'EMBED_TITLE_TOO_LONG'], [embed.description, 4096, 'EMBED_DESCRIPTION_TOO_LONG'], [embed.author?.name, 256, 'EMBED_AUTHOR_TOO_LONG'], [embed.footer?.text, 2048, 'EMBED_FOOTER_TOO_LONG']]) { if ((value || '').length > limit) throw runtimeError(code); total += (value || '').length; }
80
+ if ((embed.fields || []).length > 25 || (embed.fields || []).some(field => !field.name?.trim() || !field.value?.trim() || field.name.length > 256 || field.value.length > 1024)) throw runtimeError('EMBED_FIELDS_INVALID');
81
+ total += (embed.fields || []).reduce((sum, field) => sum + field.name.length + field.value.length, 0);
82
+ }
83
+ if (total > 6000) throw runtimeError('EMBEDS_TOO_LARGE');
84
+ if (!config.text?.trim() && !config.embeds?.length && !config.buttons?.length) throw runtimeError('MESSAGE_EMPTY');
85
+ }
@@ -1,75 +1,198 @@
1
- import { matchesCondition, validateCondition } from './conditions.mjs';
1
+ import { interpolateVariables, referencedVariables, VARIABLE_WARNING_CODES } from './custom-variables.mjs';
2
+ import { variableRuntimeOptions } from './variable-actions.mjs';
3
+ import { buttonRouter } from './button-router.mjs';
4
+ import { contextVariables } from './context-variables.mjs';
5
+
6
+ import { getScheduler } from './scheduler.mjs';
7
+
8
+ import { matchesCondition, selectConditionBranch, validateCondition } from './conditions.mjs';
9
+
2
10
  import { resolveOptions, interpolateOptions } from './options.mjs';
11
+
3
12
  import { FUNCTIONS } from './functions.mjs';
13
+
4
14
  import { errorVariables } from './errors.mjs';
15
+
5
16
  export { errorVariables } from './errors.mjs';
6
17
 
18
+
19
+
7
20
  /** Each invocation gets its own context; no messages or secrets are shared across users. */
21
+
8
22
  export async function executeCommand(command, interaction, client, options = {}) {
23
+
24
+ const scheduler = options.scheduler || getScheduler();
25
+
26
+ const release = scheduler.admit();
27
+
28
+ // Include acknowledgement and queue time in Discord's interaction lifetime.
29
+
30
+ const remaining = Math.min(870000, (interaction.createdTimestamp || Date.now()) + 870000 - Date.now());
31
+
32
+ if (remaining <= 0) { release(); throw new Error('Interaction expirée.'); }
33
+
34
+ const deadline = AbortSignal.timeout(Math.max(0, remaining));
35
+
36
+ const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
37
+
38
+ const warnings = new Set(), started = performance.now(); let success = true;
39
+ try { return await executeAdmitted(command, interaction, client, { ...options, scheduler, signal, onError: () => { success = false; options.onError?.(); }, onWarning: code => { if (VARIABLE_WARNING_CODES.includes(code)) warnings.add(code); options.onWarning?.(code); } }); }
40
+ catch (error) { success = false; throw error; }
41
+ finally {
42
+ release();
43
+ try { options.onExecution?.({ command: command.name, success, durationMs: Math.min(900000, Math.round(performance.now() - started)), warningCodes: [...warnings] }); } catch {}
44
+ }
45
+
46
+ }
47
+
48
+
49
+
50
+ async function executeAdmitted(command, interaction, client, options) {
51
+
9
52
  const registry = options.functions || FUNCTIONS;
53
+
10
54
  let count = 0;
55
+
11
56
  function validate(calls, depth = 0) {
57
+
12
58
  if (!Array.isArray(calls) || depth > 32) throw new Error('Plan d’exécution invalide.');
59
+
13
60
  for (const call of calls) {
61
+
14
62
  if (++count > 500) throw new Error('Plan d’exécution trop volumineux.');
63
+
15
64
  if (call.fn === 'condition') {
65
+
16
66
  const { rules } = validateCondition(call.args?.kind, call.args);
67
+
17
68
  if (!Array.isArray(call.branches) || call.branches.length !== rules.length || !call.otherwise) throw new Error('Branches invalides.');
69
+
18
70
  rules.forEach((rule, index) => { if (call.branches[index].ruleId !== rule.id) throw new Error('Branche invalide.'); validate(call.branches[index].calls, depth + 1); });
71
+
19
72
  validate(call.otherwise.calls, depth + 1);
73
+
20
74
  } else if (!Object.hasOwn(registry, call.fn)) throw new Error('Fonction inconnue.');
75
+
21
76
  }
77
+
22
78
  }
79
+
23
80
  const errorCalls = command.errorCalls || [];
81
+
24
82
  validate(command.calls); validate(errorCalls);
25
- const optionValues = { ...resolveOptions(command.options, interaction), user_id: interaction.user?.id || '', user_name: interaction.user?.username || '', channel_id: interaction.channelId || '' };
26
- function select(calls, variables = optionValues) {
27
- return calls.flatMap(call => {
28
- if (call.fn !== 'condition') return [call];
29
- const args = interpolateOptions(call.args, variables);
30
- const index = args.rules.findIndex(rule => matchesCondition(args.kind, rule, interaction));
31
- return select(index < 0 ? call.otherwise.calls : call.branches[index].calls, variables);
32
- });
33
- }
34
- const selectedCalls = select(command.calls);
35
- const firstReply = [...selectedCalls].find(call => call.fn === 'sendMessage' && call.args.sendMode === 'reply' && call.args.replyMode === 'command');
36
- const privateAck = !firstReply || Boolean(firstReply.args.ephemeral);
83
+
84
+ const optionValues = { ...(options.buttonSnapshot?.variables || resolveOptions(command.options, interaction)), ...contextVariables(interaction, client) };
85
+
86
+ optionValues.command_name=command.name;
87
+ const usesCustomVariables = Boolean(JSON.stringify(command).includes('randomCondition') || referencedVariables(command).length || JSON.stringify(command).includes('"fn":"setVariable"') || JSON.stringify(command).includes('"fn":"deleteVariable"'));
88
+ const privateAck = usesCustomVariables || (() => {
89
+ function flatten(calls) { return calls.flatMap(call => call.fn !== 'condition' ? [call] : (() => { const args = interpolateOptions(call.args, optionValues); const index = selectConditionBranch(args.kind, args.rules, interaction, options.random); return flatten(index < 0 ? call.otherwise.calls : call.branches[index].calls); })()); }
90
+ const firstReply = flatten(command.calls).find(call => call.fn === 'sendMessage' && call.args.sendMode === 'reply' && call.args.replyMode === 'command');
91
+ return !firstReply || Boolean(firstReply.args.ephemeral);
92
+ })();
37
93
  await interaction.deferReply(privateAck ? { flags: 64 } : {});
38
- const context = { interaction, client, messages: new Map(), hasCommandReply: false, signal: options.signal || AbortSignal.timeout(870_000) };
94
+ if (options.signal.aborted) throw options.signal.reason;
95
+ const context = { interaction, client, messages: new Map(options.buttonSnapshot?.messages), channels: new Map(options.buttonSnapshot?.channels), hasCommandReply: false, signal: options.signal || AbortSignal.timeout(870000), variableStore: options.variableStore, variableReplyMode: usesCustomVariables };
96
+ const definitions = options.variableDefinitions || [];
97
+ const warn = code => options.onWarning?.(code);
98
+ context.variableOptions = variableRuntimeOptions(context, optionValues, definitions, options.variableStore, warn);
99
+ async function* select(calls, variables = optionValues) {
100
+ for (const call of calls) {
101
+ if (call.fn !== 'condition') { yield call; continue; }
102
+ const args = await interpolateVariables(call.args, { ...context.variableOptions, base: { ...context.variableOptions.base, ...variables } });
103
+ const index = selectConditionBranch(args.kind, args.rules, interaction, options.random);
104
+ yield* select(index < 0 ? call.otherwise.calls : call.branches[index].calls, variables);
105
+ }
106
+ }
39
107
  let failedCall;
40
- try { for (const call of selectedCalls) {
108
+
109
+ try { for await (const call of select(command.calls)) {
110
+
41
111
  failedCall = call;
112
+
42
113
  context.signal.throwIfAborted();
43
- const result = await registry[call.fn](context, interpolateOptions(call.args, optionValues));
114
+
115
+ const args=await interpolateVariables(call.args,context.variableOptions);
116
+ const prepared=call.fn==='sendMessage'&&args.buttons?.length?(options.buttonRouter||buttonRouter).prepare(command,args.buttons,context,optionValues):null;
117
+ const invoke=async()=>{try{return await registry[call.fn](context,{...args,...(prepared?{components:prepared.components}:{})});}catch(error){prepared?.cancel();throw error;}};
118
+
119
+ let result;try{result=call.fn==='wait'?await invoke():await options.scheduler.run(invoke,{signal:context.signal});}catch(error){prepared?.cancel();throw error;}
120
+
44
121
  if (call.fn === 'sendMessage') context.messages.set(call.id, result);
122
+ prepared?.bind(result);
123
+
124
+ if (call.fn === 'createChannel') context.channels.set(call.id, result);
125
+
45
126
  } } catch (error) {
127
+
46
128
  options.onError?.();
129
+
47
130
  if (!errorCalls.length) throw error;
131
+
48
132
  const variables = errorVariables(error, failedCall?.fn, failedCall?.id);
133
+
49
134
  // A fresh, bounded error context prevents an expired main signal from skipping the handler.
50
- context.signal = AbortSignal.timeout(30000);
51
- for (const call of select(errorCalls, { ...optionValues, ...variables })) {
135
+
136
+ context.signal = AbortSignal.any([options.signal, AbortSignal.timeout(30000)]);
137
+
138
+ for await (const call of select(errorCalls, { ...optionValues, ...variables })) {
139
+
52
140
  context.signal.throwIfAborted();
53
- const result = await registry[call.fn](context, interpolateError(interpolateOptions(call.args, optionValues), variables));
141
+
142
+ const args=interpolateError(await interpolateVariables(call.args,{...context.variableOptions,base:{...context.variableOptions.base,...optionValues,...variables}}),variables);
143
+ const prepared=call.fn==='sendMessage'&&args.buttons?.length?(options.buttonRouter||buttonRouter).prepare(command,args.buttons,context,{...optionValues,...variables}):null;
144
+ const invoke=async()=>{try{return await registry[call.fn](context,{...args,...(prepared?{components:prepared.components}:{})});}catch(error){prepared?.cancel();throw error;}};
145
+
146
+ let result;try{result=call.fn==='wait'?await invoke():await options.scheduler.run(invoke,{signal:context.signal});}catch(error){prepared?.cancel();throw error;}
147
+
54
148
  if (call.fn === 'sendMessage') context.messages.set(call.id, result);
149
+ prepared?.bind(result);
150
+
151
+ if (call.fn === 'createChannel') context.channels.set(call.id, result);
152
+
55
153
  }
154
+
56
155
  }
156
+
57
157
  // For commands that only send externally, remove the private loading acknowledgement.
58
- if (!context.hasCommandReply) await interaction.deleteReply();
158
+
159
+ if (!context.hasCommandReply || context.removeDeferredReply) await interaction.deleteReply();
160
+
59
161
  return context.messages;
162
+
60
163
  }
61
164
 
165
+
166
+
62
167
  export function interpolateError(args, variables) {
168
+
63
169
  const result = structuredClone(args);
170
+
64
171
  const replace = (value, max) => typeof value === 'string' ? value.replace(/\{(error_name|error_desc|error_reason|error_code|error_block_id)\}/g, (_, key) => variables[key] || '').slice(0, max) : value;
172
+
65
173
  result.text = replace(result.text, 2000);
174
+
66
175
  let remaining = 6000;
176
+
67
177
  const embedText = (value, max) => { const text = replace(value, Math.min(max, remaining)); remaining -= text?.length || 0; return text; };
178
+
68
179
  for (const embed of result.embeds || []) {
180
+
69
181
  embed.title = embedText(embed.title, 256); embed.description = embedText(embed.description, 4096);
182
+
70
183
  if (embed.author) embed.author.name = embedText(embed.author.name, 256);
184
+
71
185
  if (embed.footer) embed.footer.text = embedText(embed.footer.text, 2048);
186
+
72
187
  for (const field of embed.fields || []) { field.name = embedText(field.name, 256); field.value = embedText(field.value, 1024); }
188
+
73
189
  }
190
+
74
191
  return result;
192
+
193
+ }
194
+
195
+
196
+ export async function executeButtonInteraction(interaction,client,options={}) {
197
+ return (options.buttonRouter||buttonRouter).handle(interaction,record=>executeCommand({...record.command,calls:record.branch.calls,options:[]},interaction,client,{...options,buttonSnapshot:record.snapshot}));
75
198
  }
@@ -1,48 +1,136 @@
1
+ import { VARIABLE_ACTIONS } from './variable-actions.mjs';
2
+ import { DISCORD_ACTIONS } from './discord-actions.mjs';
3
+
1
4
  import { setTimeout } from 'node:timers/promises';
5
+
2
6
  import { normalizeMessageConfig, messageConfigError, discordMessagePayload } from './message-config.mjs';
7
+
3
8
  import { runtimeError, validateMessage } from './errors.mjs';
4
9
 
10
+
11
+
12
+ // Authorize the caller, not just the bot, before sending to a guild channel.
13
+
14
+ async function authorizeChannel(interaction, channel) {
15
+
16
+ if (!interaction.guildId || channel?.guildId !== interaction.guildId || !interaction.user?.id || !interaction.guild) {
17
+
18
+ throw new Error('Destination Discord non autorisée.');
19
+
20
+ }
21
+
22
+ const member = await interaction.guild.members.fetch({ user: interaction.user.id, force: true });
23
+
24
+ const permissions = channel.permissionsFor(member);
25
+
26
+ const sendPermission = channel.isThread?.() ? 'SendMessagesInThreads' : 'SendMessages';
27
+
28
+ if (!permissions?.has(['ViewChannel', sendPermission])) throw new Error('Permissions Discord insuffisantes.');
29
+
30
+ // Private threads additionally require membership, unless the caller can manage threads.
31
+
32
+ if (channel.type === 12 && !permissions.has('ManageThreads')) {
33
+
34
+ const membership = await channel.members.fetch(interaction.user.id);
35
+
36
+ if (!membership) throw new Error('Fil privé inaccessible.');
37
+
38
+ }
39
+
40
+ }
41
+
42
+
43
+
5
44
  async function sendMessage(context, parameters) {
45
+
6
46
  const config = normalizeMessageConfig(parameters);
47
+
7
48
  validateMessage(config);
49
+
8
50
  const error = messageConfigError(config, undefined, false);
51
+
9
52
  if (error) throw runtimeError('MESSAGE_INVALID');
10
- const payload = discordMessagePayload(config);
53
+
54
+ const payload = {...discordMessagePayload(config),...(parameters.components?{components:parameters.components}:{})};
55
+
11
56
  const { interaction, client, messages } = context;
57
+
12
58
  if (config.sendMode === 'channel') {
13
- const channel = config.channelMode === 'id' ? await client.channels.fetch(config.channelId) : interaction.channel;
59
+
60
+ const channel = config.channelMode === 'created' ? context.channels.get(config.createdChannelBlockId) : config.channelMode === 'id' ? await client.channels.fetch(config.channelId) : interaction.channel;
61
+
14
62
  if (!channel?.send) throw runtimeError('CHANNEL_UNAVAILABLE');
63
+
64
+ await authorizeChannel(interaction, channel);
65
+
15
66
  return channel.send(payload);
67
+
16
68
  }
69
+
17
70
  if (config.sendMode === 'dm') {
71
+
18
72
  const user = config.dmMode === 'id' ? await client.users.fetch(config.userId) : interaction.user;
73
+
19
74
  if (!user?.send) throw runtimeError('USER_UNAVAILABLE');
75
+
20
76
  return user.send(payload);
77
+
21
78
  }
79
+
22
80
  if (config.replyMode === 'block' || config.replyMode === 'message') {
81
+
23
82
  const message = config.replyMode === 'block' ? messages.get(config.targetBlockId) : await interaction.channel?.messages.fetch(config.messageId);
83
+
24
84
  if (!message?.reply) throw runtimeError('MESSAGE_NOT_FOUND');
85
+ if(message.guildId||message.channel?.guildId)await authorizeChannel(interaction,message.channel);
86
+
25
87
  return message.reply(payload);
88
+
26
89
  }
90
+
27
91
  if (!context.hasCommandReply) {
28
- const message = await interaction.editReply(payload);
92
+
93
+ const message = context.variableReplyMode && !config.ephemeral ? await interaction.followUp({ ...payload, flags: 0 }) : await interaction.editReply(payload);
94
+ if (context.variableReplyMode && !config.ephemeral) context.removeDeferredReply = true;
95
+
29
96
  context.hasCommandReply = true;
97
+
30
98
  return message;
99
+
31
100
  }
101
+
32
102
  return interaction.followUp({ ...payload, ...(config.ephemeral ? { flags: 64 } : {}) });
103
+
33
104
  }
34
105
 
106
+
107
+
35
108
  async function wait(context, { seconds }) {
109
+
36
110
  if (!Number.isFinite(seconds) || seconds < 0 || seconds > 300) throw runtimeError('INVALID_WAIT');
111
+
37
112
  await setTimeout(seconds * 1000, undefined, { signal: context.signal });
113
+
38
114
  }
39
115
 
116
+
117
+
40
118
  async function deleteMessage(context, parameters) {
119
+
41
120
  if (parameters.deleteMode === 'manual' && !/^\d{17,20}$/.test(parameters.messageId || '')) throw runtimeError('MESSAGE_INVALID');
121
+
42
122
  const message = parameters.deleteMode === 'block' ? context.messages.get(parameters.targetBlockId) : await context.interaction.channel?.messages.fetch(parameters.messageId);
123
+
43
124
  if (!message?.delete) throw runtimeError('MESSAGE_NOT_FOUND');
125
+ if(message.guildId||message.channel?.guildId)await authorizeChannel(context.interaction,message.channel);
126
+
44
127
  await message.delete();
128
+
45
129
  }
46
130
 
131
+
132
+
47
133
  // Extend this registry together with compiler validation when adding a new block type.
48
- export const FUNCTIONS = Object.freeze(Object.assign(Object.create(null), Object.fromEntries(Object.entries({ sendMessage, wait, deleteMessage }).map(([name, fn]) => [name, async (context, args) => { try { return await fn(context, args); } catch (error) { throw runtimeError(undefined, name, error); } }]))));
134
+
135
+ export const FUNCTIONS = Object.freeze(Object.assign(Object.create(null), Object.fromEntries(Object.entries({ sendMessage, wait, deleteMessage, ...DISCORD_ACTIONS, ...VARIABLE_ACTIONS }).map(([name, fn]) => [name, async (context, args) => { try { return await fn(context, args); } catch (error) { throw runtimeError(undefined, name, error); } }]))));
136
+
@@ -120,5 +120,5 @@ export async function removeLocalInstance(home, base, config) {
120
120
  await rm(directory, { recursive: true }); return;
121
121
  }
122
122
  if (directory !== root) throw new Error('INSTANCE_INVALID');
123
- for (const item of ['installation', 'versions', 'releases', 'agent.json', 'agent.json.tmp', 'private.pem', 'discord-secret.json', 'auth-rotation.json', 'auth-rotation.json.tmp', 'auth-operation.lock', 'private.pem.tmp', 'discord-secret.json.tmp', 'agent-release.json', 'active.json', 'paused.json', 'agent.lock', 'daemon.json', 'daemon-stop.json', 'agent.log', 'agent.log.previous', 'usage.json']) await rm(path.join(directory, item), { recursive: true, force: true });
123
+ for (const item of ['installation', 'versions', 'releases', 'data', 'agent.json', 'agent.json.tmp', 'private.pem', 'discord-secret.json', 'auth-rotation.json', 'auth-rotation.json.tmp', 'auth-operation.lock', 'private.pem.tmp', 'discord-secret.json.tmp', 'agent-release.json', 'active.json', 'paused.json', 'agent.lock', 'daemon.json', 'daemon-stop.json', 'agent.log', 'agent.log.previous', 'usage.json']) await rm(path.join(directory, item), { recursive: true, force: true });
124
124
  }
@@ -0,0 +1,17 @@
1
+ export const BUTTON_DEFAULTS = {label:'Bouton',style:1,emoji:'',disabled:false,url:'',skuId:'',row:0,order:0,authorOnly:true,once:false,lifetime:86400};
2
+ export function validateButton(raw = {}, templates = true) {
3
+ const c=Object.fromEntries(Object.entries(BUTTON_DEFAULTS).map(([k,v])=>[k,raw[k]??v]));
4
+ c.style=Number(c.style);c.row=Number(c.row);c.order=Number(c.order);c.lifetime=Number(c.lifetime);
5
+ if(![1,2,3,4,5,6].includes(c.style)||!Number.isInteger(c.order)||c.order<0||c.order>24||!Number.isInteger(c.row)||c.row<0||c.row>4||!Number.isInteger(c.lifetime)||c.lifetime<60||c.lifetime>604800) throw Error('Style, ligne ou durée du bouton invalide.');
6
+ for(const key of ['disabled','authorOnly','once']) if(typeof c[key]!=='boolean') throw Error('Paramètre de bouton invalide.');
7
+ if(typeof c.label!=='string'||c.label.length>80||typeof c.emoji!=='string'||c.emoji.length>100) throw Error('Texte du bouton : 80 caractères maximum.');
8
+ if(c.style!==6&&!c.label.trim()&&!c.emoji) throw Error('Ajoutez un texte ou un emoji au bouton.');
9
+ if(c.style===5){if(typeof c.url!=='string'||c.url.length>512)throw Error('URL du bouton invalide.');try{if(!(templates&&/\{[a-zA-Z0-9_-]+\}/.test(c.url))&&!['http:','https:'].includes(new URL(c.url).protocol))throw Error();}catch{throw Error('Le lien doit être une URL HTTP ou HTTPS.');}}
10
+ if(c.style===6&&!/^\d{17,20}$/.test(c.skuId))throw Error('Identifiant SKU Discord requis.');
11
+ return c;
12
+ }
13
+ export function buttonComponent(config, customId) {
14
+ const c=validateButton(config,false);
15
+ const emoji=c.emoji.match(/^<(a?):([a-zA-Z0-9_]+):(\d{17,20})>$/);
16
+ return {type:2,style:c.style,disabled:c.disabled,...(c.style===6?{sku_id:c.skuId}:{...(c.label?{label:c.label}:{}),...(c.emoji?{emoji:emoji?{id:emoji[3],name:emoji[2],animated:Boolean(emoji[1])}:{name:c.emoji}}:{}),...(c.style===5?{url:c.url}:{custom_id:customId})})};
17
+ }